Affected: station-v1.5.7 · primitives/team_rules.py (new in v1.5.7), enforced via primitives/team_approval.py:gate()
The invariant the file promises
team_rules.py is the new per-action approval-rules engine (Sami, 2026-08-23). Its module docstring states the governance invariant twice, as the whole point of the union/tightest-wins design:
# team_rules.py:42-45
- ALL matching rules apply. ... `auto` adds nothing — an action is
auto-approved only when nothing stricter matches.
- First-match-wins was rejected on purpose: rule ordering would become a
silent loosening lever. Here, adding a rule can only ever add control.
And it presents default as the visible catch-all that governs anything no explicit rule names:
# team_rules.py:46-47
- An empty applies_to matches NOTHING (a rule must say what it governs);
the catch-all lives in `default`, visibly.
The gap — default is gated behind not matched
# team_rules.py:225-234 (evaluate)
matched = [r for r in (doc.get("rules") or []) if _rule_matches(r, act)]
if not matched and doc.get("default"): # <-- default consulted ONLY if nothing matched
matched = [dict(doc["default"], id=doc["default"].get("id") or "rule_default000")]
blocks = [r for r in matched if r.get("decision") == "block"]
if blocks:
return {"mode": "block", "rule": blocks[0]}
gated = [r for r in matched if r.get("decision") in ("named", "any_member")]
if gated:
return {"mode": "gated", "requirements": gated}
return {"mode": "auto", "rule_ids": [r.get("id") for r in matched]}
An auto rule is still a rule: if it matches, matched is non-empty, so the default branch never runs. Because that auto rule carries no block and no named/any_member decision, evaluate() falls through to return {"mode": "auto"}.
Why it bites — auto at the gate means proceed, ungated
gate() is the enforcement point, and mode == "auto" is an unconditional proceed:
# team_approval.py:356-360
if verdict["mode"] == "auto":
return "proceed", None # no team approval required
if verdict["mode"] == "gated":
return _gate_rules(...)
So the sequence an admin can trip with no malice:
- Team sets
default = {decision: "named", approvers:[CFO], quorum:1}— "anything I didn't explicitly rule on needs the CFO." This is the file's advertised safety net. - Someone later adds an innocuous rule
{applies_to:{providers:["slack"]}, decision:"auto"}— "Slack posts are fine, auto-approve them." - Every Slack action now matches the auto rule, so
matchedis non-empty, so thedefaultis skipped — for Slack. Fine so far. - But
_rule_matchesis OR-of-matchers andapplies_toscoping is coarse: any action the auto rule's matcher touches (a provider, an action_class likereversible, or averbs:["*"]glob an admin writes to mean "low-risk stuff") drops out of thedefault's coverage entirely. Anautorule written broadly (e.g.action_classes:["read_only"], or a well-meaningverbs:["*.read","*.list"]) removes the CFO default from every action it happens to match — including ones the admin never consciously exempted.
The file's own words: "adding a rule can only ever add control." Here, adding an auto rule removes the default's control over every action it matches. default should be part of the union evaluated ALWAYS (or at least whenever no gating/block rule matched), never suppressed by a same-action auto match.
Reproduction
- Adopt a rules doc:
{"schema":2, "rules":[{"id":"rule_<12hex>","title":"low-risk auto","applies_to":{"action_classes":["read_only"]},"decision":"auto"}], "default":{"decision":"named","approvers":["<cfo_pubkey>"],"quorum":1}}. - Call
evaluate(ws, {"action_class":"read_only","provider":"stripe","verb":"balance"}). - Expected (per docstring): the
defaultnamed-approval requirement applies →mode:"gated". Actual: the auto rule matches,defaultis skipped →mode:"auto"→gate()returns("proceed", None), ungated.
Root cause
default was modeled as a fallback (if not matched) rather than as a member of the union the docstring describes. An auto rule counts as "matched" even though the same docstring says auto "adds nothing," so any auto match shadows the catch-all.
Suggested fix
Evaluate default as part of the matched set whenever no explicit rule of equal-or-greater strictness governs the action — concretely: fold default into matched before computing blocks/gated (guarding only against a default whose own decision is auto/absent), so a block or named default is honored even when an auto rule also matched. Equivalent phrasing: auto rules must not remove default from consideration — only a block/named/any_member match at least as strict as default may.
---
Reviewed adversarially by Synapsis.