Story 1543: The Relay Baton (the Mode-1 → Mode-2 registration trigger)
Why this exists (the catastrophe it prevents). Mode-1 ends when a document reaches indexed: its text is extracted, its pages persisted, its search vector built. At that instant it is ready for Blake (the Record Registrar) to name it, type it, tag it, link it — Mode-2. But "ready" is not "handed over." If the hand-over is a manual step, every uploaded document waits for a human to remember it, and the Record never builds itself. If the hand-over is a naive "on indexed, call the agent" line of code, three different disasters wait: (1) an at-least-once SQS redelivery of the extraction message re-fires the trigger and Blake classifies the same document twice, duplicating effects on the Record; (2) a crash between "set indexed" and "enqueue classify" leaves an indexed document that no one will ever register — a silent dead end; and (3) Blake being down makes the upload fail, so an agent outage becomes a client-facing outage. This Story builds the hand-over that survives all three: an automatic, durable, idempotent, trace-continuous trigger that fires the instant Mode-1 completes and never fires twice for the same extraction pass.
The one analogy: the relay baton
A relay runner does not shout to the next runner and hope. The pass only counts when the baton is physically in the next hand — and it happens inside the exchange zone, in one motion, so a fumble means the first runner still holds the baton (no one runs off empty-handed) and a clean pass means the second runner is already moving. The baton here is one row in the transactional outbox, written in the same database transaction as the indexed transition. Either both commit (indexed document + registration request handed over) or neither does (the runner keeps the baton; the next delivery re-runs cleanly). And every baton is stamped with the lane number — (document_id, extraction_run_id) — so the same leg of the same race can never be run twice.
(document_id, extraction_run_id) (pramaan-docs ADR-128 §4). This Story supplies the missing front half: what stamps that extraction_run_id and what fires Blake's run when Mode-1 finishes. It rides the existing document-ingest transactional outbox (Story-1503) — no new SQS queue, per pramaan-spec #1543 — and advances the orthogonal registration_status axis (pramaan-docs ADR-128 §2).🛰️ Satellite: what shipped
One migration (the new single head 0121), one column, one widened CHECK, one trigger + lifecycle module, and the wire-in at the indexed transition. No routes and no DTO contract changes (the firm-user and brain→broker surfaces already exist), so no OpenAPI mirror moves.
documents.extraction_run_id— the identity of the Mode-1 extraction pass, stamped once at theindexedtransition. It is the stable half of Blake's(document_id, extraction_run_id)idempotency key (ADR-128 §4).- The registration request rides the existing outbox —
ops.document_ingest_outboxgains a second event type,document.registration.requested, written in the SAME transaction asindexed. No new queue, no new table, no new Lambda. - The
registration_statusstate machine —not_requested → queued → registering → registered | registration_failed | registration_needs_review, with retry edges back toqueued— inRegistrationService, for the registrar consumer / agent-result path to drive the write-back. - The trigger is wired into the extraction worker — at the
indexedtransition (and onlyindexed— neverneeds_review), it stamps the pass id, advancesqueued, and enqueues the request, all in the worker's one transaction.
✈️ Airplane: how one hand-over resolves
extraction worker, in ONE transaction:
... native/OCR text persisted, search_vector built ...
document.extraction_run_id = uuid4() # stamp the pass id (if unset)
transition processing_status -> 'indexed' # Mode-1 done
if final_status == 'indexed':
request_registration(document):
guard: indexed AND live AND registration_status in
{not_requested, registration_failed, registration_needs_review}
registration_status: not_requested -> queued # Mode-2 axis
INSERT ops.document_ingest_outbox
(event_type='document.registration.requested',
idempotency_key = 'document_registration:' +
sha256("{firm}:{document}:{extraction_run_id}"),
traceparent = active span) # baton stamped + trace carrier
ON CONFLICT (firm_id, idempotency_key) DO NOTHING
COMMIT # baton in the next hand, atomically
ingest relay (unchanged consumer): dispatches event_type='document.ingest.requested' ONLY
-> registration requests are never misrouted to extraction
registrar consumer (Blake, brokered): reads the request, runs Classify (auto),
suggestions land as proposals via the Story-1540 executor (idempotent on the
same extraction_run_id), then RegistrationService advances queued -> registering
-> registered | registration_failed | registration_needs_review
Four turns make this correct:
- The baton and the finish line commit together. The outbox
INSERTruns on the worker's open transaction, so it commits or rolls back atomically with theindexedtransition. A crash mid-flight commits neither — the document stays pre-indexedand the next SQS delivery re-runs cleanly. There is no window where an indexed document has no registration request. - The lane number makes re-fire a no-op. The idempotency key hashes
(firm_id, document_id, extraction_run_id)— not a fresh uuid per call — so a redeliveredindexedevent resolves to the SAME outbox row (ON CONFLICT DO NOTHING) and, downstream, to the SAME broker registration claim (0 new effects). A genuine re-extraction mints a newextraction_run_idand legitimately re-registers. Two latches, one anchor. - An agent outage is not an upload outage. The trigger writes only local Postgres rows — it never calls Blake. So Mode-1 always completes with the upload; the registration request waits durably in the outbox until the registrar consumer is healthy, and a downstream failure lands in
registration_failed(retriable by re-queueing) without ever touching the document'sindexedtruth. The two axes never cross (ADR-128 §2). - One outbox, two consumers, no misroute. Riding the existing table means the existing ingest relay would otherwise grab the registration row and ship it to the extraction queue. The relay now dispatches
document.ingest.requestedonly; the registration request is left for its own consumer. Same durable substrate, correctly partitioned byevent_type.
🔬 Microscopic: the boundary lines that matter
| Detail (titled by its reason) | What & where |
|---|---|
| So re-extraction can re-register but re-delivery cannot duplicate | deterministic_registration_idempotency_key hashes (firm_id, document_id, extraction_run_id) only; the pass id is stamped once and reused on redelivery (the worker also short-circuits an already-terminal document to ALREADY_DONE before the trigger). src/domains/document/registration.py. |
| So a poor extraction is never classified | The trigger fires on indexed only; a needs_review (low-confidence OCR) extraction stamps the pass id but does not request registration — registration_status stays not_requested. Proven by test_needs_review_extraction_does_not_trigger_registration. |
| So the two lifecycles can never collide | registration_status is a separate column and a separate state machine from processing_status (ADR-128 §2); ALLOWED_REGISTRATION_TRANSITIONS rejects skips (not_requested → registering) and treats registered as terminal. Proven by the parametrized transition tests + test_registration_lifecycle_advances_and_is_terminal. |
| So a write-back stays inside the firm | RegistrationService.advance loads the document under the firm predicate and RLS; an advance for a foreign firm_id finds no row and raises PramaanNotFoundError. Proven by test_registration_advance_is_firm_isolated. |
| So the trace is continuous Mode-1 → Mode-2 | The outbox INSERT injects the active traceparent/tracestate into the row (the existing insert_document_ingest_event path); the registrar consumer LINKs back to the extraction span, so trace_id flows across the async boundary (ADR-124 / ADR-104). |
| So the empty first run is real, not a fixture | Every integration assertion provisions a fresh firm and runs the real extraction worker through real RLS up to indexed, then reads the actual outbox row and the actual registration_status (tests/integration/test_document_registration.py). The migration down/up roundtrips on a real DB. |
(document_id, extraction_run_id) the broker (1540) and ledger (1542) already key on; the trace carrier is the outbox row's existing traceparent/tracestate columns. This Story adds a column, a service, and a wire-in — nothing that already exists is duplicated.document_id, extraction_run_id, event_id, registration_status); no raw document text enters telemetry. Instrumentation-exempt by the auto tier, not by omission.registered/registration_failed/registration_needs_review write-back is driven by that consumer through the RegistrationService primitives shipped here, exercised end-to-end against the real broker in the integration suite.One-sentence recall
The registration trigger is the relay baton — on Mode-1 indexed, in one transaction, it stamps the extraction-pass id, advances the orthogonal registration_status to queued, and drops a lane-numbered request on the existing outbox — so Blake is handed every finished document exactly once, durably, and with the trace intact, while an agent outage never becomes an upload outage.