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.
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.
- The drain is generalized, not duplicated —
relay_pending_document_ingest_eventsis refactored into a shared_relay_pending_outbox_eventswhose only knobs are theevent_typefilter, the target queue, the batch span name, and the duration metric. The ingest entrypoint keeps its exact signature and behavior;relay_pending_document_registration_eventsis its sibling. The two relays cannot drift apart. - A new worker —
src/workers/document_registration_relay/handler.pymirrors the ingest relay byte-for-byte (warm persistent event loop per the Bug-1356 pattern,SET LOCAL app.is_staff, statusCode 200), swapping only the drain call. - The same envelope, a different queue —
DocumentIngestQueueClient.from_registration_settingsaims the existing pointer envelope at the registrar queue (DOCUMENT_REGISTRATION_QUEUE_URL). No new envelope shape; the row already carries everything the message needs. - The registrar queue + DLQ (pramaan-infra) — a dedicated
pramaan-documents-registration-stagingqueue mirroring the ingestion queue (visibility 1320s, redrive→DLQ at maxReceiveCount 3), wired into the depth/oldest-age and DLQ-depth alarms. - The deploy workflow — mirrors
deploy-document-ingest-relay.yml: an in-VPC Lambda on an EventBridgerate(1 minute)timer withOTEL_PROPAGATORS=xray,tracecontext, plus the idempotent IAM-ensure, queue-resolve-or-fail, artifact-validate, and smoke-test steps.
✈️ 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:
- Only registration batons move. The drain's
WHERE event_type = :event_typeclause is the lane filter. The registration relay passes'document.registration.requested'; the ingest relay passes'document.ingest.requested'. The mirror-image mistake the ingest relay must avoid — grabbing a registration row — is the exact mistake this relay must avoid in reverse, and a dedicated test proves the registration relay leaves ingest rows untouched. - The baton is carried, never re-stamped. The trace context was injected onto the row at write time (Story-1543), so the relay only reads
traceparent/tracestateoff the row, links the batch span back to each row's stored trace, and serializes them into the message body. Zero new observability primitives: the continuoustrace_idfinalize→indexed→registered comes for free from carrying what is already there. - Hand-off is exactly once and idempotent. The drain selects
FOR UPDATE SKIP LOCKED(concurrent timer ticks never double-drain a row), enqueues, then setsdispatched_at = now()in the same transaction. A re-drain after dispatch selects zero rows — a no-op. An enqueue failure leavesdispatched_atnull and recordslast_dispatch_error, so the next tick retries; nothing is silently lost. - The relay stays thin. It moves the message and nothing else — no AgentCore invoke, no Blake call, no
registration_statusadvance. That control plane belongs to the broker-consumer (P3b), per pramaan-docs ADR-110 ("the broker owns the control plane exactly once"). A collapsed "relay invokes Blake directly" variant was rejected: it would lose the SQS visibility-timeout redelivery that the failure-isolation guarantee depends on.
🔬 Microscopic: the boundary lines that matter
| Detail (titled by its reason) | What & where |
|---|---|
| So the two relays can never drift apart | The 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 extraction | The 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 exists | test_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 trace | The 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 independently | A 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 pool | The 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. |
traceparent/tracestate columns. This Story adds a worker, a generalized drain, a queue, and a deploy workflow — nothing that already exists is duplicated.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.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.