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.

Stacks on the merged registrar substrate. The broker tool executor (Story-1540) and the effect ledger (Story-1542) already record Blake's suggestions as proposals, idempotent on (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.

✈️ 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:

🔬 Microscopic: the boundary lines that matter

Detail (titled by its reason)What & where
So re-extraction can re-register but re-delivery cannot duplicatedeterministic_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 classifiedThe 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 collideregistration_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 firmRegistrationService.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-2The 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 fixtureEvery 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.
Reuse, not rebuild (pramaan-spec #1543). No new SQS queue, no new outbox table, no new relay Lambda. The registration request is a new event type on the existing document-ingest transactional outbox; the idempotency anchor is the same (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.
Observability (pramaan-docs ADR-124). The trigger and the lifecycle advances are small DB writes that ride the caller's already-instrumented span — the extraction consumer span for the trigger, the registrar broker/consumer span for the write-back — on the ADOT T1 auto floor. No registry-approved manual span name covers a registration-request write, so no hand-rolled span is added (that would be a PR reject). Each write logs low-cardinality product IDs only (document_id, extraction_run_id, event_id, registration_status); no raw document text enters telemetry. Instrumentation-exempt by the auto tier, not by omission.
The handoff boundary (what is not in this PR). This Story is the pramaan-functions event wiring. The actual Blake brain that consumes the registration request and runs Classify on AgentCore is pramaan-agents (there is no registrar agent-key in the run-launch registry here yet, by design). The terminal 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.