Story 2125: IB-2 β€” the Inbox read + resolve routes (working the queue, safely)

WHY. IB-1 built the waiting room: a queue of deferred human decisions, one write door, a closed guest list. But a waiting room nobody can read and no doctor can discharge is a room where cases pile up and rot. IB-2 is the doctor's side of the desk. It has to survive two things that quietly corrupt any human-in-the-loop system: two people acting on the same case at once (multi-counsel β€” a confirm that runs twice writes the world twice), and a case that sat while the world moved (the document got deleted, the side got changed, the proposal got decided elsewhere β€” a confirmed intent executed against a base that no longer exists is a silent corruption). The founder law is never execute a stale or duplicate decision. IB-2 makes both structurally impossible before it ever dispatches an effect β€” and it does so without inventing a single new way to mutate the domain.

πŸ›°οΈ Satellite β€” the one sentence

Three routes on the app Lambda β€” GET .../inbox (the Triage list), GET .../inbox/summary (the badge counts), and POST /app/inbox/items/{id}/resolve β€” where resolve runs a compare-and-swap status flip (a double-confirm can't double-execute), a check-on-resolve stale floor (a moved base flips the item to stale and 409s before any effect runs), and then dispatches the existing server effect, writing the audit row and the backbone event in the same transaction as the status flip.

✈️ Airplane β€” the discharge desk with a two-key lock

Back at the hospital desk. A doctor picks a waiting case and says "discharge β€” send to radiology" (an action). Before anything happens, the desk turns two keys at once. Key one: it re-reads the case chart against the world as it is right now β€” if the patient was already moved to another ward since the case was logged (the base_ref the item was born against no longer matches the live document / proposal), the desk refuses, stamps the case stale, and hands back the current chart so the floor can re-log a fresh case. Nothing is sent to radiology. Key two is the discharge stamp itself: a single atomic "if still waiting β†’ mark discharged". If a second doctor already turned that key, this one turns nothing (zero rows), and the desk says "already handled by Dr. X at 10:42" β€” the order to radiology is never sent twice. Only when both keys turn does the desk actually send the order β€” and crucially, it doesn't invent a new way to send orders: it dials the same radiology extension the rest of the hospital already uses (the existing declared-side PATCH, the registrar approve, the frame ratify). The discharge stamp, the order, and the logbook entry are one motion β€” all or nothing.

πŸ”¬ Microscopic β€” what changed

SurfaceChange
src/app_api/router_inbox.py (new β€” the seam) Three routes under the inbox tag, gated by matters:read. Full contract hygiene: clean camelCase operation IDs (appListMatterInbox / appGetMatterInboxSummary / appResolveInboxItem), response_model= and canonical ErrorResponse responses= on every route. The resolve route is the one subtlety: on a stale outcome it returns a JSONResponse(409) rather than raising β€” because the stale flip is a committed state change, and raising would roll it back with the request transaction (see below).
src/app_api/inbox_service.py (new β€” CAS + stale + dispatch) The state machine. List is ONE bounded SELECT ordered blocking-first β†’ severity β†’ age; the per-row available actions ride the in-memory registry (no child query β€” DR-1). Summary is ONE grouped query over open items. Resolve, all in the request transaction: load the item (RLS firm-scoped, 404 if unseen) β†’ validate the action against the type's registry spec and its params schema (closed by default β€” never a free-form effect payload) β†’ if already terminal, 409 with the winner's attribution β†’ check-on-resolve re-reads the live base and, on mismatch, flips openβ†’stale + 409 with the current base, no effect β†’ else CAS UPDATE … WHERE status='open' (zero rows β‡’ 409 AlreadyResolved) β†’ dispatch the effect β†’ in-txn audit row (inbox_item.approved/.rejected, type + action + prior status only) + insert_domain_event.
src/app_api/inbox_dtos.py (new) *Request/*View separation: InboxItemView (carries the registry availableActions so the Triage tab renders one-tap buttons without hardcoding the vocabulary), InboxListResponse, InboxSummaryResponse (openTotal / blocking / advisory β€” the badge), and InboxResolveRequest ({action, params?}).
src/shared/inbox/registry.py (Β§7 params constraint) ActionSpec gains an optional params_model. Every action ships closed by default (None β‡’ params rejected); an action that needs a constrained input (the vocabulary merge-target) declares its model with the producer story that wires the effect (IB-4). This is how "params constrained to the action's declared schema, never a free-form effect payload" (Β§7) is enforced.
The stale flip commits, the request 409s The app dependency runs the whole request in one session.begin(): clean return commits, an exception rolls back. The stale flip is a real, wanted state change β€” so InboxItemStaleError is returned as a 409 response by the route (matching ErrorResponse shape), letting the transaction commit the flip. AlreadyResolved / NotFound / validation stay ordinary raises β€” they persist nothing, so rollback is correct.
src/app_api/app.py Registers app_inbox_router. App surface only β€” no admin route; the admin OpenAPI snapshot is untouched.
OpenAPI (openapi/app-api.json) Regenerated via make openapi β€” the three inbox paths + DTO schemas land in the app mirror the Triage tab (IB-5) generates from. Admin mirror unchanged.

The dispatch seam (I-7, phasing honesty)

Resolution never invents a new write path β€” it dispatches an effect that already exists, each already carrying its own require_permission, RLS, and audit. IB-2 ships the in-transaction dispatch seam (_dispatch_effect) and the generic recorded resolution (the status flip + audit verb + backbone event); each producer story extends the seam with its type's named server effect β€” the declared-side PATCH and FN-PRIVILEGE (IB-3), the registrar approve/reject/merge (IB-4), the ratify service (IB-7), the ME accept/reject (IB-8). Because the producers that mint those items land in the same wave, no domain-write action is user-reachable before its real effect is wired. Likewise the document base reader ships now (the majority of the taxonomy and the types exercisable today); the matter_frame / proposal readers land with IB-7/4/8. The dedicated functions.inbox.item_resolve manual span is IB-10.

Proof (system boundary, not component boundary)

tests/integration/test_app_inbox_routes.py drives the full ASGI pipeline against a real RLS database, including the empty first-run (a fresh matter returns [] and zero counts β€” the door isn't welded shut): every action of every one of the seven registry types is resolved over HTTP and lands the right terminal status + attribution; a double-confirm returns 409 carrying the winner and does not re-execute; a moved base returns 409 + flips the item to stale (verified via a follow-up list) with the effect not dispatched; invalid action and forbidden params are 422; and the list query count is O(1) β€” the 1-item and 5-item lists issue the same number of queries (DR-1). Ran locally: 27 inbox tests green (5 route + IB-1 producer/registry/migration/gate suites) three times over; ruff clean; new modules mypy-clean; make openapi committed with no admin drift.

Recall in one sentence: resolving an inbox item is a two-key lock β€” re-read the live base (stale β‡’ flip + 409, no effect) then compare-and-swap the status (lost race β‡’ 409, no double-execute) β€” after which the existing effect, the audit row, and the backbone event commit as one, so a held decision is executed exactly once and never against a world that moved.