Affected: station-v1.5.7 · primitives/team_rules.py (matcher) + studio_integration_send.py / routes/team.py / routes/commands.py (gate call sites)
The control that is advertised
Module-scoped governance is the FIRST example in Sami's rules spec — governing "a module slug = all its commands":
# team_rules.py:22-23 (schema doc)
"modules": ["sami666-railcall-marketplace"], # a module slug = all its commands
validate_doc accepts modules as a standalone matcher (_has_any_matcher, team_rules.py:148), and _rule_matches enforces it by reading module_id off the action descriptor:
# team_rules.py:188-190 (_rule_matches)
if m.get("modules") and str(act.get("module_id") or "").lower() in {
str(x).lower() for x in m["modules"]}:
return True
So a team admin authors {"applies_to":{"modules":["acme-payments"]},"decision":"named","approvers":[CFO],"quorum":1} = "every command from the acme-payments module needs the CFO," and the Studio rules editor even hands them the installed-module inventory to pick from (routes/team.py:_handle_rules_get).
The gap — module_id is None at every enforcement call
gate() copies its module_id parameter into the descriptor team_rules.evaluate matches on:
# team_approval.py:344-346 (gate)
_act = {..., "module_id": module_id, ...}
verdict = _tr.evaluate(ws, _act)
gate() has three real callers and none passes module_id (default None) — grep -n "module_id=" studio_integration_send.py routes/team.py routes/commands.py returns nothing at any .gate( site:
studio_integration_send.py:250— airlock/integration send (this is the path a module command's write takes).routes/team.py:535— DAG effect gate (T3b).routes/commands.py:151— palette write.
So _rule_matches's modules branch compares the rule's slug-set against "" on every evaluation → never matches. A rule scoped only to a module contributes nothing; the verdict falls through to default or auto and the module's command executes without the intended approval.
What separates this from the min_spend_cents gap — the data is PRESENT and dropped
This is not "the amount has no source." The module identity is carried on the very object the airlock gate already holds:
# integration_registry.py:57,102 (Integration dataclass)
class Integration:
...
module_identity: Optional[Dict[str, Any]] = None # {slug, version, publisher_key_fp, signature_verified}
populated for every module-synthesized command:
# routes/modules.py:810,826 (_load_modules)
_module_identity = {"slug": slug, "version": ..., "publisher_key_fp": ..., "signature_verified": ...}
integ = _synth_module_integration(cid, cmd, fn, slug, module_identity=_module_identity)
At studio_integration_send.py:250 the code already uses integ.verb and integ.action_class from this same object — integ.module_identity["slug"] is one attribute away and simply isn't passed to _ta.gate(...). The DAG path (routes/team.py:build_workflow_team_gate._iter_actions) has the same identity in hand: its effect branch resolves integ = _R.resolve_node(n) — the very Integration that carries module_identity — but yields only (integ.provider, integ.verb, integ.action_class), discarding it, and team_gate then calls _ta.gate(...) with no module_id. On every path the slug is available on the resolved Integration; the wiring drops it.
Why it bites
A team that governs by module — the primary use case the rules feature was built for — gets a rule that validates, signs into the policy bundle, adopts cleanly, shows in the Studio simulator, and never gates a single real module command. A teammate's write from acme-payments sails through the airlock with no CFO approval, because the one rule meant to catch it never matches. Same "claimed to work, does nothing" class as the spend-threshold gap, but with a live identity source at the call site, so the fix is a one-line forward rather than a plumbing project.
Reproduction
- Install a signed module (e.g. slug
acme-payments) with a write command; confirm it loads (integ.module_identity["slug"] == "acme-payments"). - Adopt
{"schema":2,"rules":[{"id":"rule_<12hex>","title":"Module gate","applies_to":{"modules":["acme-payments"]},"decision":"named","approvers":["<cfo>"],"quorum":1}]}. - Stage + approve a write from that module's command (the airlock path →
studio_integration_send.py:250→_ta.gate(...)with nomodule_id). - Expected:
mode:"gated", CFO approval required. Actual:module_id=None, the rule does not match, gate returns("proceed", None)(or falls todefault/auto).
Root cause
module_id matching was implemented and validated in team_rules, and the module slug is present on the Integration (and on the DAG plan), but no gate() call site forwards it into the module_id parameter — so the modules matcher's guard is never satisfied.
Suggested fix
Forward the slug at each gate call: in studio_integration_send.py:250 pass module_id=(integ.module_identity or {}).get("slug"); in build_workflow_team_gate (routes/team.py) pass module_id=plan.get("module_slug") when the effect node resolves to a module command; likewise for routes/commands.py. As with the spend-threshold rule, team_rules.validate_doc should fail-closed on a rule whose sole matcher is modules until the identity is guaranteed to reach the gate, rather than accept a control that silently never fires.
---
Reviewed adversarially by Synapsis.