Affected: station-v0.78 · Class: spend-cap integrity (CWE-840) · Signed receipt: attached (railcall verify → SIGNATURE VALID, offline)
Relation to shweta's open bug (distinct root cause — credit where due)
shweta's thread "Workflow-level cumulative spend cap never applies to agent nodes" correctly shows the run-level capabilities.max_spend_cents is gated on kind == "effect" and so skips agent nodes — and explicitly treats the agent's own per-node budget.max_spend_cents (via agent_gate) as the working backstop. This report is a different, deeper root cause that also breaks that backstop. The defect is in the shared amount estimator, not the effect-vs-agent wiring — so it defeats the cap even where the cap IS correctly wired: the per-node agent cap shweta relies on, every effect-node cap, and the plan-time blast radius.
The defect
_find_amount_in (workflow_engine.py:459-475, 494-527) only consults the ambiguous keys (amount/price/total/cost) when a currency/currency_code/iso_currency sibling exists in the same dict — an intentional v0.60 (Dave #4) rule to avoid inflating blast radius with non-monetary fields. Side effect: a money-mover whose amount is a bare amount in whole units, with no currency sibling, estimates 0. agent_gate uses this same estimator (agent_gate.py:53-64), so within_cap(spent, 0, cap) passes for ANY cap — including the per-node agent budget shweta treats as sound.
Proof (container, real station functions, cap = 100 cents)
amount_cents=100000 -> est 100000 | within_cap(0,est,100): False # blocked, correct
amount=100000 (bare) -> est 0 | within_cap(0,est,100): True # $1000 passes a $1 cap
amount=100000+currency-> est 100000| within_cap(0,est,100): False # a currency sibling closes it
# full agent_gate, external_send money-mover, per-node budget cap = 100 cents:
verdict for $1000 under a $1 per-node agent cap (bare amount): proceed
verdict for $1000 under a $1 per-node agent cap (amount_cents): blocked
Real modules use the bare shape: marcofgv-freelancer-com's place_bid / create_milestone / release_milestone all take a whole-unit amount with no currency field.
Three guards, one root cause
- Per-node agent budget (
budget.max_spend_cents, agent_gate) — the backstop shweta's report relies on — passes an over-cap bare-amountcharge. - Effect-loop cap (
capabilities.max_spend_cents, workflow_engine.py:716-719) reads the same 0 for a bare-amounteffect node. - Blast radius shows
spend_cents: 0/$0.00for a real charge, so the human who approves the node (agent nodes are unconditionallyrequire_human, 365-367) is shown a false figure.
Honest scope: this is not an unattended-money bypass — the operator still approves the node; static external_send is still require_human. It is a cap-integrity + informed-consent break: the number the operator relies on to bound spend, and the number shown at approval, are both silently 0.
Fix (one line)
In _find_amount_in, when an ambiguous key is present but no currency sibling is, return (0, unbounded=True) rather than leaving it unread — the same "a confident 0 is what let a real charge plan as spends-nothing (Dave #4)" logic already applied to unresolved templates. within_cap fails closed on unbounded, closing the agent cap, the effect cap, and the $0.00 display at once.
Found and reported by @marcofgv. Reviewed adversarially against the source (and against shweta's overlapping thread) before posting.
Signed receipt (railcall verify → SIGNATURE VALID, offline)
{
"schema": "railcall_audit_receipt.v1",
"ran_at": "2026-08-11T19:29:25",
"file": {
"name": "findings_capgap.csv",
"sha256": "sha256:17897e82d9114b55a9a06bbb05f8b474e9f50ed796d30e43bf7d5cefbedc6066",
"bytes": 321
},
"audit": {
"rows": 1,
"columns": 1,
"import_breakers": 1,
"pii_columns": 0,
"formula_injection_cells": 0,
"findings": [
{
"severity": "warn",
"detail": "1 row has the wrong number of columns"
}
]
},
"network_audit": {
"lsof_available": false,
"error": "lsof_not_found",
"external_sockets_open": null
},
"result": "audited_with_input_warning",
"input_warning": "input does not look like CSV (no CSV dialect detected and only 1 column parsed) \u2014 parsed as CSV anyway; results may be meaningless",
"receipt_version": "v2",
"flow": {
"dry_run": true,
"name": "audit",
"action_type": "audit"
},
"governance": {
"policy_ref": "none",
"policy_hash": "ff56072e81ed4908ea91f567741238b387e536cd1f5974513ee18df0d5c575b9",
"approval_chain": [],
"risk_classification": "unknown",
"irreversible": false
},
"execution": {
"input_sha256": "sha256:17897e82d9114b55a9a06bbb05f8b474e9f50ed796d30e43bf7d5cefbedc6066",
"output_sha256": "",
"duration_ms": 0,
"exit_code": 0
},
"signer_alg": "ed25519",
"public_key_hex": "ea2446fec9cc4de478c853fb35c778262d4327ac7d32d6ccff36bdbbfcd775e2",
"signature_hex": "38b2f2504295dd1c456a16beb4ac13b275b243432f7680040079caad406b2094bdc66dd5f5e70290f1b0e6dfd7771afd82c807ee1dfb702891330666ea63380e"
}```