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_bundleskips the save, so their existingapproval_rules.jsonon disk is untouched — they keep enforcing the rules. - A new joiner, or a re-provisioned / offline-since-v(N-1) station:
_handle_sync→_fetch_bundlereturns v(N+1) with no rules →adopt_bundle→curNone so version/tightening pass → butapproval_rules is None→_tr.saveskipped → no rules file. Itsgate()then getsteam_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
- Team at bundle v1 with
approval_rulesrequiring a named co-sign onstripe.charge. Confirm a fresh station syncing v1 writesapproval_rules.json. POST /api/team/policy/publish {root_seed_hex, requires_team_approval:{...}}— omitapproval_rules. Response:version: 2.- New station joins and
/api/team/sync→ fetches v2 (no rules) → adopts. - 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.chargeruns 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.