Story 1436: Projection Outbox Trace Links

The projection outbox is a batch boundary. One relay run may drain rows created by different upstream requests, so the relay must not pretend one row is the parent of the whole batch. The durable shape is: store each row's W3C trace carrier at insert time, then link the relay batch span back to every stored context.

Data Path

caller transaction
  -> functions.projection_outbox.enqueue span
  -> ops.projection_outbox(traceparent, tracestate)
  -> projection relay batch
  -> functions.projection_relay.batch span links
  -> SQS envelope with traceparent/tracestate

What Changed

SurfaceBehavior
ops.projection_outboxNew nullable traceparent and tracestate columns, added by migration 0104.
ProjectionOutboxEnvelopeCarries the same trace fields into the deterministic SQS body for future projection consumers.
Insert pathUses the shared observability helper to open functions.projection_outbox.enqueue and persist the active W3C carrier before the source span ends.
Relay pathRehydrates stored carriers as OTel Link objects and passes them to functions.projection_relay.batch.

Instrumentation Contract

T1 remains library-owned by ADOT and SQL/SQS instrumentations. T2 product attributes stay on the helper allowlist. T3 uses only the registry spans functions.projection_outbox.enqueue and functions.projection_relay.batch; no product code constructs a provider or calls raw tracer APIs.

Semconv check: the batch span emits standard messaging.batch.message_count. PRAMAAN custom dimensions used here are firm_id and matter_id. firm_id is a span attribute only, never baggage.

Verification

uv run pytest tests/unit/test_projection_outbox.py tests/unit/test_projection_outbox_migration.py tests/unit/shared/observability/test_manual_span.py -q
uv run ruff check <touched files>
uv run mypy <touched modules> --follow-imports=skip
.github/scripts/observability-guardrail-check.sh
isolated alembic downgrade e1f2a3b4c5d6 && upgrade head