The registrar govern executor mints one vocabulary_gate inbox item
in the same transaction as the agent_vocab_proposal row (the item's
subject_id is the proposal id — one store), and a decision on
either surface — the settings panel or the Inbox resolve route —
resolves that one item in the same transaction as the vocab write, enforcing the identical
per-tier authority (settings:write + filing_type=admin) both ways.
Picture a shared case file in a records office. A junior clerk (Blake) drafts a request to add
a new category to the office's controlled vocabulary and drops the file in the approvals
tray. Two senior clerks can act on it: one works the approvals tray
directly (the settings panel); the other works the triage desk (the Inbox),
where every pending ask is queued together. The danger is obvious once you see it: if the triage
desk keeps its own photocopy of the request, a stamp on the original leaves the copy
sitting there forever, and a stamp on the copy never touches the original. IB-4's rule is
no photocopy. The triage desk holds only a pointer to the one file
(pending_ref = the proposal id). Whichever clerk stamps it — from either desk — the
same file is stamped, and the triage card is marked done in the same motion. And both desks
check the same badge before stamping: a firm-wide filing type takes an admin badge, no
matter which desk you're standing at.
In src/shared/registrar/executor.py, _open_vocab_proposal already
wrote three rows for a gated request: the non-live catalog row, the proposing effect, and the
Mode-3 proposal. IB-4 adds a fourth — the inbox item — through the one producer door
(insert_inbox_item), so it commits or aborts with the proposal it mirrors.
No proposal without its queued ask; no orphan ask without a proposal.
await _mint_vocab_gate_item(session, intent, proposal, name=name, subject_kind=subject_kind)
# subject_id = proposal.proposal_id -> the single store (pending_ref)
# base_ref = {"proposal_status": "pending"} -> the check-on-resolve floor (§6.3)
Because the door UPSERTs on the open-partial unique index, Blake re-requesting the same name (the create-race reuse path) collapses onto the one open item — no fork.
The three decision functions in src/shared/registrar/approvals.py —
approve_proposal, reject_proposal, merge_proposal — each now
call a shared, layer-safe resolver in the same transaction as the catalog write:
await resolve_open_item_for_subject(
session, firm_id=..., item_type="vocabulary_gate", subject_id=proposal.proposal_id,
resolution_verb="approved", resolution_action="approve", resolved_by=decided_by,
)
The resolver (src/shared/inbox/resolver.py) is a single CAS
UPDATE ... WHERE status = 'open'. On the panel surface it flips the
still-open card (and writes the same inbox_item.approved audit row + backbone event
the Inbox route writes). On the Inbox surface the route already flipped the card
before dispatching the effect, so this inner CAS matches zero rows — a clean no-op, no double
event. One code path, both surfaces, no drift.
Resolving a vocabulary_gate item through POST .../inbox/items/{id}/resolve
dispatches the existing registrar service (inbox_service._dispatch_vocab_decision) —
the Inbox invents no new write path (§6.1). Critically, authority is exercised through the
dispatch: before the effect runs, the same per-tier check the panel enforces runs here.
approvals.require_vocab_decision_authority(proposal, role=current_user.role,
permissions=current_user.permissions)
# settings:write required; approval_tier == "admin" (filing_type) => firm_admin required
This check now lives in one place, called by both the panel route
(_require_decider) and the Inbox dispatch. A non-admin holding
settings:write can approve a document type from the Inbox but is refused a filing
type — and the refusal rolls the whole transaction back, including the card's own status flip, so
nothing is granted and the item stays open.
The vocabulary_gate item auto-expires at 30 days. Expiry is a status flip on the
item only — it never calls the resolver, so the proposal stays pending and
keeps showing in the settings panel (the mirror). Nothing is silently lost; the ask simply leaves
the short list until Blake re-requests it.
| Criterion | Where it's proven |
|---|---|
| Panel decision resolves the item (one store) | test_panel_approve_resolves_the_item_same_txn /
test_panel_reject_dismisses_the_item — a direct
approvals.* decision flips the open item to resolved/dismissed,
attributed to the decider, in the same txn. |
| Inbox [Approve] enforces the filing_type admin tier (dispatch-through) | test_inbox_approve_filing_type_requires_admin_tier — a non-admin resolving a
filing_type item over the dispatch raises 403 and the txn rolls back (item open, proposal
pending). test_inbox_approve_dispatches_through_to_the_proposal shows the admin
happy path: proposal approved, catalog row live. |
Expiry leaves the proposal pending |
test_item_expiry_leaves_the_proposal_pending — expiring the item leaves the
proposal pending and the catalog row proposed (still gated). |
| The mint rides the proposal txn | test_govern_executor_mints_open_gate_item — a gated request lands one open
item whose subject_id is the proposal id and whose pending_ref points
back to it. |
inbox_items was rejected (§4.4) —
that is the drift this story exists to prevent. No migration ships with IB-4.settings:write + admin gate.