Story 2127: IB-4 — the Vocabulary fold-in (two doors, one decision)

WHY. Blake can request firm-wide controlled vocabulary — a new filing type, a new document type — but never mint it himself (ADR-128 Mode 3). That request-and-approve workflow already existed (pramaan-spec#1541): a proposal row, per-tier authority, a settings panel. IB-3 gave the firm an Inbox — one queue for every held human decision. The catastrophe IB-4 prevents is two queues for one decision: a vocab proposal that lives in the settings panel and as a separate Inbox ask, drifting apart, so counsel approves it in one place and it still nags from the other — or worse, approving it from the Inbox bypasses the admin-only tier the panel enforces, letting a non-admin promote a firm-wide filing type. The founder law: one decision is one item in one store, and every surface that resolves it enforces the same authority. IB-4 folds the vocab gate into the Inbox as a mirror — never a copy — so both surfaces move the one proposal, and neither grants what the other would refuse.

🛰️ Satellite — the one sentence

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.

✈️ Airplane — one case file, two clerks' desks

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.

🔬 Microscopic — the four moving parts

1. The mint — the item is born in the proposal's transaction (the producer half)

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.

2. The mirror resolve — a panel decision closes the Inbox card (either surface)

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.

3. The dispatch — the Inbox runs the existing effect, under the existing authority

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.

4. Expiry — the item may lapse; the proposal never does

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.

The acceptance criteria, mapped

CriterionWhere 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.

What IB-4 deliberately does not do

Recall in one sentence. A vocab gate is one proposal seen from two desks: the executor mints its Inbox mirror in the proposal's own transaction, a decision on either desk resolves that one item in the same transaction under the identical admin-tier authority, and only an explicit approve/reject/merge — never expiry — ever touches the proposal.