Story 1453: ErrorResponse.correlation_id → trace_id (R7a)

Why this exists (the catastrophe it prevents). When a domain error reaches a client, the body carries one field whose entire job is “paste this into support and they will find your request.” Until now that field was correlation_id — a per-request UUID minted by PRAMAAN’s own correlation layer. But the system PRAMAAN actually stores traces in is X-Ray/CloudWatch, keyed by the OpenTelemetry trace id, not by that UUID. So an operator handed a correlation_id had to first reverse-map it to a trace before they could see anything — and off the connected graph there was no clean mapping at all. The error body advertised a lookup key that did not open the lock. EPIC-1400 Phase-2 (roster id R7a) renames the published field to trace_id and sources it from the live trace, so the value in the body is the exact trace a human can open in X-Ray. pramaan-docs ADR-124 governs the trace identity this rides on.

The #1471 lesson, applied. The trap this Story deliberately avoids: renaming the field but leaving it carrying the old correlation UUID. A field called trace_id that still holds a cid value is worse than honest mislabelling — it looks correct and fails silently. The fix below sources the value from trace.get_current_span(), never from the legacy correlation value.

The Fix: One Field, Sourced From the Real Trace

The published ErrorResponse field is renamed, and every place that writes that field now reads the active span’s trace id through one helper. Nothing about how the error envelope is shaped (nested error object, codes, messages, details) changed.

any error response (PramaanError envelope, 422, 404, framework 500)
  -> trace_id = get_current_trace_id()          <- NEW single source
       span_context = trace.get_current_span().get_span_context()
       return format_trace_id(span_context.trace_id) if span_context.is_valid else None

ErrorResponse  ==  { "error": { code, message, details }, "trace_id": "0af7651916cd43dd8448eb211c80319c" }

Why the Middleware Body Field Had To Move Too

It would have been smaller to touch only the DTO and the PramaanError handler. That fails a repo invariant: the published DTO must round-trip the real wire body. CorrelationIdMiddleware backfills a correlation surface onto every ≥400 JSON body. Leaving that backfill on correlation_id would have shipped { error, trace_id, correlation_id } — an undocumented third field that ErrorResponse cannot round-trip, i.e. first-class contract drift. Repointing only the response body field keeps the envelope honest across the full error surface.

Legacy correlation_id is not gone — only the body field moved. Inbound x-pramaan-correlation-id resolution, the request contextvar, the route-span correlation_id attribute, and the x-pramaan-correlation-id response header are all unchanged. The correlation id still rides the header; only the response body field is renamed here. Retiring the cid plumbing itself is a separate ticket (RIP #1456). HealthResponse keeps its own correlation surface and is out of scope for this rename.

Where the Field Is Written

Error pathWho writes trace_id
PramaanError domain envelope (401/403/404/409/422/429/5xx)http_handler.py via get_current_trace_id()
FastAPI request-validation 422 ({detail: ...})middleware.py _with_error_trace_id backfill
Starlette 404/405 ({detail: ...})middleware.py _with_error_trace_id backfill
Unhandled / framework 500middleware.py catch-all envelope via get_current_trace_id()

The backfill skips any body that already self-publishes a correlation surface (its own trace_id, or a DTO field like HealthResponse.correlation_id), so the envelope is stamped exactly once and out-of-scope DTOs are left untouched.

Acceptance Evidence

Acceptance criterionTest / check
Forced PramaanError body returns trace_id equal to the active span’s trace idtest_domain_error_body_is_nested_envelope (asserts body["trace_id"] == TRACE_ID_HEX)
The body value is the trace id, not the correlation header value (#1471)test_domain_error_body_is_nested_envelope (asserts body["trace_id"] != VALID_V4)
Published ErrorResponse round-trips the real handler bodytest_error_response_dto_round_trips_handler_body
422 / 500 error bodies (ASGI + Lambda) backfill trace_id; header still echoes the correlation idtest_422_path_echoes_header_and_backfills_trace_id, test_500_path_echoes_header_and_backfills_trace_id, test_lambda_500_response_echoes_header_and_backfills_trace_id
Both OpenAPI snapshots advertise trace_id as the required top-level fieldtest_openapi_error_response_is_nested_envelope + committed openapi/{app,admin}-api.json
HealthResponse correlation surface unchanged (out of scope)test_health_returns_503_when_db_unreachable
Walkthrough doc includedthis document

Full suite green against the isolated Docker test DB (977 passed); ruff clean, mypy clean on changed source, and make openapi regeneration is idempotent.

Review gate — can the body ship a trace_id that is not the request’s real trace? No. Every writer of the field sources it from get_current_trace_id(), which reads trace.get_current_span().get_span_context().trace_id and formats it as W3C/X-Ray hex, returning None only when there is no valid span. The legacy correlation value is never assigned to trace_id; it survives solely on the x-pramaan-correlation-id header and the route-span attribute, untouched by this change.

Recall: Story 1453 renames the published error field correlation_id → trace_id and sources it from the active span, so the value in every error body is the exact trace a human can open in X-Ray — while the legacy correlation id keeps riding the request/response header until RIP #1456.