Story 1608: The Runner Who Picks Up the Baton (the registration relay, Blake-seam hop 3)

Why this exists (the catastrophe it prevents). Story-1543 built the baton pass: the instant a document reaches indexed, in the same transaction, it drops a document.registration.requested row on the transactional outbox — a durable, lane-numbered request that says "this document is ready for Blake to name, type, and tag it." But a baton placed in the exchange zone that no runner picks up is not a relay — it is a dropped baton lying on the track. And that is exactly the state of the seam today: the existing ingest relay drains document.ingest.requested rows only, so every registration request sits with dispatched_at IS NULL forever. The document is finished, the request is written, the trace context is stamped on the row — and nothing moves it. The Record never builds itself, and worse, the failure is invisible: extraction is green, the outbox row exists, the dashboard says "processing complete." The trace literally stops at a database row. This Story is the runner who picks up that baton and carries it to the next leg — a second, thin relay that drains the registration rows onto the registrar queue, so the document actually reaches Blake.

The one analogy: the second runner on the same track

The outbox is one track with two batons running on it — ingest batons (hand the upload to the extraction pipeline) and registration batons (hand the finished document to Blake). One runner already works this track: the ingest relay, who is trained to pick up only ingest batons and ignore everything else — correctly, because a registration baton handed to the extraction pipeline would be run to the wrong finish line. So the registration batons need their own runner, trained to pick up only registration batons and carry them to their finish line (the registrar queue). Same track, same exchange-zone mechanics, same baton-stamping — a different runner with a different lane filter and a different destination. The runner carries the baton untouched: the trace context (traceparent/tracestate) was stamped onto the row when it was written, so the runner only has to move it, never re-stamp it.

Stacks on the trace-carrying outbox (Story-1543). The producer is done: request_registration already advances registration_status not_requested→queued and writes one document.registration.requested row via insert_document_ingest_event, which injects the live span's traceparent/tracestate into the row columns. Because that runs inside the extraction consumer's per-record span, the row's trace context is the extraction/upload trace. This Story adds the missing drain; the broker-consumer that invokes Blake on AgentCore (P3b) and Blake's deploy (P4) come next. Earliest provable milestone: the relay drains and the queued message carries the trace context — assertable on the queue before Blake even exists.

🛰️ Satellite: what shipped

One new worker, one generalized drain, one new queue (sibling pramaan-infra PR), one deploy workflow. No routes, no DTO contract changes, no migration — the outbox table, the envelope DTO, and the producer all already exist — so no OpenAPI mirror moves and there is a single alembic head.

✈️ Airplane: how one hand-over resolves

ops.document_ingest_outbox  (ONE table, TWO batons)
  ┌──────────────────────────────────────────────────────────────┐
  │ event_type='document.ingest.requested'        dispatched_at=∅ │──┐
  │ event_type='document.registration.requested'  dispatched_at=∅ │──┼─┐
  └──────────────────────────────────────────────────────────────┘  │ │
            ingest relay (rate 1m) ── filters ingest ONLY ───────────┘ │
                 → pramaan-documents-ingestion  → extraction pipeline   │
                                                                        │
  NEW registration relay (rate 1m) ── filters registration ONLY ───────┘
       SELECT ... WHERE dispatched_at IS NULL
                   AND event_type='document.registration.requested'
                   FOR UPDATE SKIP LOCKED            # no double-drain across timers
       open manual_span functions.document_registration_relay.batch
            link_from_trace_context(row.traceparent) # each row's stored trace LINKED
            for row:
                client.enqueue(envelope)             # same pointer envelope, registrar queue
                UPDATE SET dispatched_at = now()      # baton handed off, exactly once
       → pramaan-documents-registration  → registrar broker-consumer (P3b) → Blake

Four turns make this correct:

🔬 Microscopic: the boundary lines that matter

Detail (titled by its reason)What & where
So the two relays can never drift apartThe drain body lives once in _relay_pending_outbox_events; both relay_pending_document_ingest_events and relay_pending_document_registration_events are four-line wrappers that differ only in event_type, queue factory, span name, and metric. src/shared/document_ingest_outbox.py.
So a registration row is never misrouted to extractionThe drain filters event_type in SQL; the registration relay publishes to the registrar queue, not the ingestion queue. Proven by test_relay_ignores_ingest_rows (an ingest row left with dispatched_at IS NULL while the registration row moves).
So the provable milestone holds before Blake existstest_relay_drains_registration_rows_marks_dispatched_and_is_idempotent asserts the queued message body carries traceparent/tracestate and the row gets dispatched_at; a second drain returns selected=0. The trace context on the queue is assertable with no registrar runtime present.
So the batch span links every drained row's traceThe relay opens the registry-approved functions.document_registration_relay.batch CONSUMER span and adds one link per row via link_from_trace_context. Proven by test_relay_wraps_batch_in_registration_span_with_trace_links (span name + two links + their trace ids).
So registration-leg latency alarms independentlyA separate metric pramaan.document_registration_relay.duration (not the ingest metric) is emitted in finally, so a registrar-queue stall pages on its own SLO. Proven by test_relay_emits_registration_duration_metric.
So the warm Lambda never orphans its DB poolThe handler reuses one persistent event loop (_worker_loop, the Bug-1356 pattern) rather than asyncio.run per invocation, keeping the cached async engine's bounded pool intact across warm ticks. src/workers/document_registration_relay/handler.py.
Reuse, not rebuild (pramaan-spec #1608). No new outbox table, no new envelope DTO, no new idempotency anchor, no migration. The registration relay is the second drain over the existing table; the message is the same pointer envelope; the trace carrier is the row's existing traceparent/tracestate columns. This Story adds a worker, a generalized drain, a queue, and a deploy workflow — nothing that already exists is duplicated.
Observability (pramaan-docs ADR-124). The relay opens exactly one registry-approved manual span per scheduled drain — functions.document_registration_relay.batch, a CONSUMER-kind batch span that links every drained row's stored trace context — mirroring the ingest relay's batch span. This name is a closed-registry extension; ratify with an ADR-124 amendment. The relay Lambda sets OTEL_PROPAGATORS=xray,tracecontext so the trace flows through. Each drain emits the pramaan.document_registration_relay.duration histogram with low-cardinality labels only (service, environment, error.type); no document text, matter content, or firm_id baggage enters telemetry. SQS send is T1 plumbing on the ADOT auto floor — no hand-rolled plumbing span is added.
The handoff boundary (what is not in this PR). This Story is the pramaan-functions drain plus the pramaan-infra queue. The registrar broker-consumer that pulls from this queue, builds the agent_invoke.v1 envelope, stamps meta.traceparent, invokes Blake on AgentCore, routes Blake's intents into the existing executor, and advances registration_status queued→registering→registered is P3b. Blake's AgentCore deploy is P4. This relay only guarantees the baton reaches the registrar queue, exactly once, with the trace intact.

One-sentence recall

The registration relay is the second runner on the same track — a thin, scheduled drain that picks up only document.registration.requested batons, carries each one (with its already-stamped trace context) to the registrar queue exactly once, and sets dispatched_at — so the finished document Story-1543 left waiting in the outbox actually reaches Blake, and the extraction→registrar trace stops dead-ending at a database row.