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" }
src/shared/dtos/errors.py— the publishedErrorResponse.correlation_idfield becomestrace_id. This is the canonical contract; app #1454 and admin #1455 regenerate their clients against it.src/shared/observability/trace_context.py(new) —get_current_trace_id()returns the active span’s trace id as 32-char W3C/X-Ray hex, orNoneoff a valid span. It is the single home for the rawtrace.get_current_span()read, so the tracer access stays inside the observability package (pramaan-docs ADR-104, pramaan-docs ADR-124); callers in other packages import the helper instead of touching the tracer.src/shared/errors/http_handler.py— thePramaanErrorhandler stampstrace_idfromget_current_trace_id()instead of the correlation value.src/shared/observability/middleware.py— the error-body backfill (FastAPI 422 validation, Starlette 404/405, framework 500) and the catch-all 500 also publishtrace_id, so the whole error surface — not just thePramaanErrorenvelope — round-trips the publishedErrorResponse.openapi/app-api.json,openapi/admin-api.json— regenerated viamake openapi; both snapshots reflecttrace_id.
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 path | Who 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 500 | middleware.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 criterion | Test / check |
|---|---|
Forced PramaanError body returns trace_id equal to the active span’s trace id | test_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 body | test_error_response_dto_round_trips_handler_body |
422 / 500 error bodies (ASGI + Lambda) backfill trace_id; header still echoes the correlation id | test_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 field | test_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 included | this 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.