Affected: station-v1.5.7 · primitives/team_rules.py (matcher) + all three team_approval.gate() call sites
The control that is advertised
team_rules.py documents min_spend_cents as a first-class applies_to matcher — "rule fires only at/above this" — in its schema example:
# team_rules.py:28
"min_spend_cents": null # rule fires only at/above this
It is accepted by validation as a standalone matcher (a rule may be nothing but a spend threshold):
# team_rules.py:147-150 (_has_any_matcher)
def _has_any_matcher(m):
return any(m.get(k) for k in ("actions","modules","providers","verbs",
"action_classes","workflow_ids")) \
or m.get("min_spend_cents") is not None
and it is matched at evaluation time by reading amount_cents off the action descriptor:
# team_rules.py:203-206 (_rule_matches)
if m.get("min_spend_cents") is not None:
amt = act.get("amount_cents")
if amt is not None and int(amt) >= int(m["min_spend_cents"]):
return True
So an admin can author {"applies_to":{"min_spend_cents":50000},"decision":"named","approvers":[CFO],"quorum":1} = "any action moving ≥ $500 needs the CFO," save it (passes validate_doc), and believe spend is gated.
The gap — amount_cents is None at every enforcement call
_rule_matches only fires the threshold when amt is not None. The action descriptor is built inside gate() from its amount_cents parameter:
# team_approval.py:345-348 (gate)
_act = {..., "amount_cents": amount_cents, ...}
verdict = _tr.evaluate(ws, _act)
gate() has exactly three real callers, and none passes amount_cents (it defaults to None):
routes/team.py:535— the DAG effect-path gate (T3b).team_gateenumerates(provider, verb, action_class)only; effect-node args (where the amount lives) are discarded in_iter_actions. Noamount_cents=.studio_integration_send.py:250— the airlock/integration send gate. Noamount_cents=.routes/commands.py:151— the palette-write gate. Noamount_cents=.
(Confirm: grep -n "amount_cents" routes/team.py studio_integration_send.py routes/commands.py — the identifier does not appear in any of the three .gate( calls.)
Therefore _rule_matches's amt is None branch is taken on every evaluation, and a rule whose ONLY matcher is min_spend_cents never returns True. It contributes nothing to matched; evaluate() returns auto (or the weaker of whatever else matched); gate() proceeds. The spend threshold the admin configured governs nothing.
Note the contrast that makes this a wiring bug, not a design choice: the local per-station engine approval_policy.evaluate() IS fed a real amount_cents by its callers (mcp_server.py:319, studio_server.py:4442, policy_endpoints.py:156). Only the team-rules governance path — the one this whole v1.5.7 cut is about — drops it.
Why it bites
A team that adopts a spend-threshold rule as its financial guardrail gets a false sense of control: the rule validates, shows in the Studio policy simulator (which supplies its own amount_cents, so it looks like it works there), and then never fires on any real DAG run, airlock send, or palette write. A workflow can drive an arbitrarily large charge on a provider the threshold-only rule was meant to catch, and the gate returns proceed. A governance control that passes validation and does nothing at runtime is the "claimed to work, does nothing" class.
Provider/action-scoped rules are unaffected (they match on providers/actions/etc. regardless of amount) — the bug is specific to rules that rely on the spend threshold as their matcher, which is precisely how a "gate anything expensive, whatever the provider" rule is written.
Reproduction
- Adopt
{"schema":2,"rules":[{"id":"rule_<12hex>","title":"Big spend","applies_to":{"min_spend_cents":50000},"decision":"named","approvers":["<cfo>"],"quorum":1}]}— passesteam_rules.save. - Run a workflow whose effect node charges $600 (or stage a $600 airlock send). The gate path calls
team_approval.gate(...)with noamount_cents. - Expected:
mode:"gated", CFO approval required. Actual:evaluate()seesamount_cents=None, the rule does not match,gate()returns("proceed", None).
Root cause
min_spend_cents matching was implemented and validated in team_rules, but the amount was never plumbed from the effect/send/command call sites into gate()'s amount_cents parameter — so the matcher's guard (amt is not None) is never satisfied in production.
Suggested fix
Plumb the amount into all three gate calls: in build_workflow_team_gate._iter_actions, extract the declared spend from the effect node's args (the amount field the integration/registry defines) and pass amount_cents= to _ta.gate; likewise thread the staged amount through studio_integration_send and routes/commands. Until amounts are supplied, a threshold-only rule cannot be enforced — so team_rules.validate_doc should at minimum reject a rule whose sole matcher is min_spend_cents (fail-closed on an unenforceable rule) rather than accept a control that never fires.
---
Reviewed adversarially by Synapsis.