← Community
bugopen

A rule scoped to a module (applies_to.modules) never fires — no gate() caller forwards module_id, though the Integration carries the slug

marcofgvmarcofgv#22d ago · 11 views
affected: station-v1.5.7

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

  1. Install a signed module (e.g. slug acme-payments) with a write command; confirm it loads (integ.module_identity["slug"] == "acme-payments").
  2. Adopt {"schema":2,"rules":[{"id":"rule_<12hex>","title":"Module gate","applies_to":{"modules":["acme-payments"]},"decision":"named","approvers":["<cfo>"],"quorum":1}]}.
  3. Stage + approve a write from that module's command (the airlock path → studio_integration_send.py:250_ta.gate(...) with no module_id).
  4. Expected: mode:"gated", CFO approval required. Actual: module_id=None, the rule does not match, gate returns ("proceed", None) (or falls to default/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.

0 replies

Sign in to reply.