Story 2124: IB-1 β€” the Inbox substrate (the HITL rail's foundation)

WHY. Every place an agent must stop and ask a human β€” "Blake isn't sure this document should register", "the content reads like the opponent's side but you declared it ours", "this looks privileged" β€” is today a different surface: a status column nobody lists, a bespoke approval panel, a bespoke frame rail, a flag wired to nowhere. Scattered human-in-the-loop asks mean split audit trails, drift, and no way to count (or shrink) how often the machine interrupts the lawyer. The founder law is: ONE home for every human decision, and the product wins by keeping that queue short (machines decide what they're licensed to; only legal-consequence residue queues). IB-1 lays the foundation: the one table, the one write door, and the closed vocabulary of what may ever be asked β€” governed like the audit registry, so a new kind of ask is a reviewed decision, not an accident.

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

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.

✈️ Airplane β€” the waiting room, the one door, the guest list

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

πŸ”¬ Microscopic β€” what changed

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

Deliberately NOT in IB-1 (phasing honesty)

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.

Proof (system boundary, not component boundary)

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.

Recall in one sentence: the Inbox is authority given one home β€” one RLS queue, one write door, one closed guest list β€” where a human decision is a provenance-pinned deferred execution, deduped to one open row per decision and announced once on the backbone.