ADR-0110: Propagate the HTTP correlation id into Temporal workflows
Date: 2026-07-13 Status: Accepted Deciders: Adrian (Soft4U), Claude Opus 4.8
Context
The M2 readiness slice (O4) fixed a real defect: the activity logging interceptor minted
str(uuid.uuid4()) for every activity, so a single workflow's activities each carried a
different correlation_id — the field meant to tie them together. It now derives a stable
wf-{run_id}, so filtering logs by correlation_id yields one workflow's whole activity
sequence.
One gap remained (#300): the HTTP request that started the workflow has its own
X-Correlation-Id (generated by middleware/correlation.py), and it did not reach the
workflow. So you could trace a workflow end-to-end by wf-{run_id}, but you could not join
"the HTTP request that created case X" to "the workflow activities for case X" by a single
id — the request boundary and the workflow boundary were separate traces.
Decision
Propagate the request correlation id through Temporal using the standard header / context-propagation chain, so it flows from the HTTP request into the workflow and onto every activity (and child workflow):
client start_workflow ──header──▶ workflow inbound ──copies header──▶ activity inbound
(encode) (capture Payload) (re-inject) (decode, prefer)
- Client interceptor (
CorrelationClientInterceptor, wired on the API's Temporal client inmain.py): atstart_workflow, reads the current request's correlation id from thelogging_contextcontextvar and stamps it as anx-correlation-idheader. - Worker/workflow interceptor (
CorrelationWorkerInterceptor): the workflow-inbound interceptor captures the header's rawPayload; the workflow-outbound interceptor copies that samePayloadonto everystart_activity/start_local_activity/start_child_workflow. The workflow side only copies the raw Payload — it never encodes or decodes — so nothing non-deterministic or sandbox-unsafe runs inside the workflow. Only the client (encode) and the activity (decode) touch the payload converter, both outside the workflow sandbox. - Activity interceptor (existing
LoggingInterceptor): decodes the header and prefers it over thewf-{run_id}fallback when binding the activity's logging context.
When no correlation id is present (a system-started workflow — a schedule, a reconciler),
no header is set and the activity falls back to wf-{run_id}; behaviour is unchanged for
background work. Only the API client carries the interceptor — the system-schedule clients
have no request context and intentionally do not propagate one.
Decision context:
- Latency: one payload encode at workflow start, one raw-Payload dict copy per activity start, one decode per activity. Negligible; no network calls.
- Dependency surface: none new —
temporaliointerceptors + its own payload converter. - Debuggability: a single id now spans the HTTP request, the workflow, and every
activity — the join that was missing. Fallback is explicit and logged as
wf-{run_id}. - Reversibility: two interceptor registrations (client + worker) and one activity-side preference; removing them restores the O4 behaviour exactly.
- Blast radius: additive interceptors — they only add/read a header and never alter args or results. The activity change is a preference with a fallback; existing interceptor tests are unchanged and pass.
- Alternative considered: pass the id as a workflow argument — rejected; it would
change
CaseInputand every activity signature (42 activities) to thread the id, whereas the header rides invisibly through the interceptor chain with no signature changes.
Consequences
Positive
- One correlation id joins the HTTP request to the workflow and its activities — full request→workflow→activity tracing.
- No workflow or activity signature changes; the 42 activities are untouched.
- Sandbox-safe: the workflow copies raw Payloads only; encode/decode happen outside it.
Negative
- Two more interceptors in the client/worker chains — a small amount of Temporal-internals surface to understand when debugging the interceptor stack.
- The correlation id is only as good as the middleware that sets it; a code path that
starts a workflow outside a request context (correctly) gets the
wf-{run_id}fallback, not a request id.
Neutral
- Child-workflow propagation is included for completeness so a whole workflow tree shares the id, even though the current compliance workflow does not spawn children.
Alternatives Considered
Alternative 1: Thread the id as a workflow/activity argument
- Add
correlation_idtoCaseInputand pass it to each activity. - Why rejected: it changes the workflow input and would have to be plumbed through all 42 activity signatures; the header rides through the interceptor chain with zero signature changes and no per-activity edits.
Alternative 2: A memo or search attribute instead of a header
- Store the id as a workflow memo.
- Why rejected: memos are visible on the workflow but do not automatically reach activities; the header/context-propagation chain is the mechanism designed to carry a value down to activities, which is exactly the requirement.