A firm/matter-RLS queue business.inbox_items where server-side producers
materialize deferred human decisions through one helper
(insert_inbox_item) that composes the dedup key, UPSERTs so one open
decision is one open row, validates the payload against a closed seven-type
registry, and announces inbox_item.created on the event backbone β
with a CI grep-gate making that helper the only door, exactly like the domain-event
outbox.
Picture a hospital waiting room with a single intake desk. Nobody wanders into the
ward directly β every case comes through the desk (the insert_inbox_item
door), which stamps a ticket number computed from who the patient is (the
dedup key), so the same patient arriving twice doesn't become two tickets β the second
arrival just refreshes the first (UPSERT on the open-partial unique index). The desk
only admits cases on a fixed guest list β seven kinds, no walk-in
categories invented at runtime (the closed registry, I-2). Once a case is discharged
(resolved), its ticket number is free again β the same patient can legitimately return
on new evidence. And the moment a ticket is opened, the desk pages the floor
(inbox_item.created on the backbone) β but only on first open, never on a
refresh. IB-1 builds the desk, the ticketing rule, and the guest list; who calls the
desk (registration consumer, vocabulary govern, memory interceptor) and how a case is
worked (the resolve routes) are the next stories (IB-2, IB-3, IB-4).
| Surface | Change |
|---|---|
Migration 0154_inbox_items |
Creates business.inbox_items: firm/matter columns, closed
item_type CHECK (7 values incl. reserved import_triage),
helper-composed dedup_key, provenance columns (agent_key /
agent_run_id / span_refs β NULL for non-agent producers),
base_ref (the race guard, I-12), the status machine
(open|resolved|dismissed|expired|stale), and urgency /
severity snapshots. Hand-written RLS (firm isolation +
staff-with-grant, FORCE), the firm-leading open-partial unique
uq_inbox_items_open_dedup β¦ WHERE status='open' (I-5), the Triage/sweep/
mirror indexes, jsonb + vocabulary CHECKs, and REVOKE DELETE (retention:
never hard-deleted, Β§9). up/down/up + full base roundtrip + alembic check
clean. |
src/shared/inbox/registry.py (new β the guest list) |
The closed seven-type registry: each InboxItemTypeSpec carries its
server-side producer, typed payload_model, resolution actions (each
naming the existing effect it will dispatch, I-7), urgency/severity,
auto-expiry policy, the written junior-test justification (admission
evidence, I-2), and declared stacking escalations (I-3). ITEM_TYPES
mirrors the migration CHECK β a cross-check test fails on drift. |
src/shared/inbox/payloads.py (new) |
Seven frozen-shape (extra="forbid") ContractModel
payloads β the decision context "beyond the envelope" (Β§4.2). A malformed or
wrong-type payload cannot construct, so it aborts the producing transaction rather
than landing a junk row. |
src/shared/inbox/producer.py (new β the one door) |
insert_inbox_item(session, β¦): runs in the caller's transaction
(commits/aborts WITH the state change it queues on); composes
dedup_key = <item_type>:<firm_id>:<subject_id> itself
(never caller-supplied); UPSERTs on the open-partial index
(ON CONFLICT β¦ WHERE status='open' DO UPDATE β latest evidence wins,
the decision identity does not fork); validates the payload against the registry
model; and emits inbox_item.created via the existing outbox helper
on first-open only (system actor β the brain never writes, I-4).
Returns (item_id, created). |
src/domains/inbox/entity.py (new β metadata parity) |
A SQLModel mirror of the table. business is an ORM-owned schema
(env.py owned_schemas), so without this alembic check would
report a false-positive drop for the whole table. Registered in alembic/env.py
and src/shared/db/entities.py. It is NOT the write path β all writes go
through the gated helper. |
Audit registry (src/shared/audit/registry.py) |
New subject inbox_item ("Inbox Item", matter area, no scope_field β
matter scope rides the emit Scope, the exhibit_marking precedent) and new
lifecycle verb expired. Together with the existing
created/approved/rejected verbs this makes the
inbox_item.* detail types resolve (Β§5.3) β one vocabulary for audit rows
and domain events. |
CI gate (.github/scripts/inbox-producer-gate.sh + workflow) |
A grep gate cloned from the domain-events outbox gate: a direct
INSERT INTO [business.]inbox_items anywhere outside
src/shared/inbox/ fails CI. The early-warning layer of the
one-producer-door rule (I-4/I-5) β a bypass would fork the decision identity, skip
payload validation, and mint a silent (un-announced) ask. |
Teardown (tests/factories.py) |
cleanup_seeded_data now clears inbox rows before matters/firm
(RESTRICT FKs); the app role has no DELETE, so admin teardown drops them. |
import_triage ships as a registry entry only β its producer files with the
Door-3 email-in RFD. The read/summary/resolve routes (with CAS + stale
check + effect dispatch) are IB-2; the registration/vocabulary/memory/frame producers are
IB-3/4/7/8; the dedicated functions.inbox.item_insert manual span + HITL
budget dashboard are IB-10. This substrate is truthful by construction (the taxonomy is
complete) without pretending the wiring exists yet.
tests/integration/test_inbox_producer.py drives insert_inbox_item
against a real RLS database, including the empty first-run firm: a
double-insert on the same decision key collapses to ONE open row with the
latest evidence and the same item_id (first created=True, second
False); cross-firm reads return zero rows (firm B sees
nothing); a malformed payload aborts the whole producing transaction (the
valid sibling insert rolls back too β atomicity). Unit suites pin the registryβmigration
vocabulary parity, the seven types' admission evidence, the audit additions, that every
inbox_item.* detail type resolves, and that the grep gate goes red on
a seeded direct-write fixture and green on the producer package. Ran locally:
22 inbox tests + audit-registry + route-coverage green; ruff clean on changed
files; migration base roundtrip + alembic check no drift; no OpenAPI drift
(IB-1 adds no routes); the inbox / domain-events / observability gates all pass.