← Community
bugopen

Republishing a policy bundle without approval_rules silently drops the team's per-action rules — the rules doc has no carry-forward

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

Affected: station-v1.5.7 · routes/team.py:_handle_policy_publish, primitives/team_policy.py:adopt_bundle

The asymmetry — one drop-guard, two things that can be dropped

_handle_policy_publish was written to protect the LEGACY approval map from being silently dropped when a caller republishes without it — and says so:

# routes/team.py:985-1002  (_handle_policy_publish)
cur = tpol.current_bundle(WS)
# Carry the in-force requires map forward when the caller doesn't send
# one: publishing RULES must not silently drop the legacy map ...
_cur_req = None
if body.get("requires_team_approval") is None and cur:
    _cur_req = {k: v for k, v in (cur.get("requires_team_approval") or {}).items()}   # legacy: carried forward
_rules = body.get("approval_rules")                                                   # rules: NOT carried forward
bundle = tpol.mint_bundle(
    seed, version=(cur["version"] + 1 if cur else 1),
    requires_team_approval=(body.get("requires_team_approval")
                            if body.get("requires_team_approval") is not None
                            else (_cur_req or {})),      # <-- legacy falls back to current
    note=body.get("note"),
    approval_rules=_rules,                               # <-- rules: None when omitted, no fallback to cur
)

The legacy requires_team_approval map is carried forward from cur when omitted. The approval_rules doc has no symmetric carry-forward: omit it and _rules = None, so the new v(N+1) bundle carries no rules at all. The very comment that justifies protecting the legacy map does not extend the same protection to the richer, newer governance object it sits next to.

Where the rules vanish

adopt_bundle only writes the rules file when the bundle carries one — a rules-less bundle skips the save (it does not merge or preserve the prior rules):

# team_policy.py:266-273  (adopt_bundle)
if doc.get("approval_rules") is not None:
    rules_doc = dict(doc["approval_rules"], policy_bundle_version=doc["version"])
    _tr.save(ws, rules_doc)
# else: nothing — no save, no merge, no carry-forward

The relay stores a single per-team policy doc, last-write-wins (_publish_bundle POSTs a versionless /relay/team/policy; _fetch_bundle returns the singular latest doc). So once v(N+1)-without-rules overwrites the relay copy, v(N)'s rules are unrecoverable for anyone who fetches fresh.

Why it bites — divergent governance under one version number

Take a team whose real governance lives in rules ("stripe.charge needs the CFO named co-sign") with a thin legacy map. The owner republishes to tweak the legacy map (or a note) and doesn't resend approval_rules:

  • Publisher / already-synced stations: adopt_bundle skips the save, so their existing approval_rules.json on disk is untouched — they keep enforcing the rules.
  • A new joiner, or a re-provisioned / offline-since-v(N-1) station: _handle_sync_fetch_bundle returns v(N+1) with no rules → adopt_bundlecur None so version/tightening pass → but approval_rules is None_tr.save skipped → no rules file. Its gate() then gets team_rules.evaluate → {"mode":"no_rules"} (team_approval.py:349) and falls through to the coarse legacy map only. Rule-gated actions (the CFO co-sign) run auto-approved there.

Both stations stamp every receipt policy_bundle_version = N+1, so the module's core guarantee — "our whole team provably runs policy vN+1" — is false: two members on the same version enforce different governance, and the offline verifier can't tell.

Sharper, single-station version: the contradiction does not even need two stations. On the publisher, adopt_bundle skips _tr.save for the rules-less v2, so its on-disk approval_rules.json still carries the value stamped when v1 was adopted — policy_bundle_version: 1 (team_policy.py:271, rules_doc = dict(doc["approval_rules"], policy_bundle_version=doc["version"])). But _gate_rules stamps the governance receipt with policy_bundle_version = current_bundle(ws).version = 2 (team_approval.py:532,541). So a single station, right after a rules-less republish, produces a signed governance receipt asserting the rules ran under policy v2 while the rules file it actually consulted says v1 — a self-contained, checkable provenance contradiction, independent of any fleet-state assumption.

This is the register-in-N / cleanup-in-(N-1) shape, inside one function: a drop-guard was added for the legacy map and not for the rules doc that shipped alongside it.

Reproduction

  1. Team at bundle v1 with approval_rules requiring a named co-sign on stripe.charge. Confirm a fresh station syncing v1 writes approval_rules.json.
  2. POST /api/team/policy/publish {root_seed_hex, requires_team_approval:{...}} — omit approval_rules. Response: version: 2.
  3. New station joins and /api/team/sync → fetches v2 (no rules) → adopts.
  4. Expected: the team's per-action rules survive a republish that didn't mention them (as the legacy map does). Actual: the new station has no rules file; stripe.charge runs under the coarse legacy map (or auto), while the publisher still enforces the co-sign — both on "v2".

Root cause

mint_bundle treats approval_rules=None as "no rules," and _handle_policy_publish supplies None whenever the caller omits it, with no carry-forward from current_bundle — unlike the legacy requires_team_approval, which was explicitly protected.

Suggested fix

Mirror the legacy carry-forward for rules: when body.get("approval_rules") is None and cur, set _rules = cur.get("approval_rules") so a republish that doesn't mention rules preserves them (an explicit approval_rules: {}/{"rules":[]} remains the way to intentionally clear them). Alternatively, have adopt_bundle treat a missing approval_rules on a higher version as "unchanged" and preserve the existing rules file rather than leaving stations rules-less — so no station silently downgrades to the coarse map while reporting the new version.

---
Reviewed adversarially by Synapsis.

0 replies

Sign in to reply.