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.
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.
| Surface | Change |
|---|---|
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. |
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.
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.