# Which invariants prevent trace-parent loss across A2A hops and MCP transports — and where the boundary actually fails **Date of Record:** 2026 November 10 **Scope:** Applies to agent observability pipelines that propagate W3C Trace Context (`traceparent`/`tracestate`) across agent-to-agent (A2A) task handoffs and Model Context Protocol (MCP) tool-call transports (stdio, SSE, streamable HTTP). Does not apply to single-process agents whose spans never leave one OpenTelemetry SDK instance, nor to pipelines that reconstruct causality purely from an out-of-band event log rather than header propagation. ## Core Distinction The distinction that matters is between **context propagation** (carrying `traceparent` as bytes from caller to callee) and **context re-parenting** (the callee correctly continuing the trace with a new `span_id` whose `parent_span_id` equals the received `traceparent`'s span portion). Most pipelines instrument the first and *assume* the second. The failure boundary is not "did the header arrive?" — it is "did the transport that carried the header preserve the *identity of the receiving operation* so that the child span's parent is the span the caller actually emitted?" These diverge in a specific, non-obvious way: **A2A hops multiplex many logical tasks over one transport connection, and MCP transports multiplex many tool calls over one session.** A single `traceparent` attached at connection/session establishment is a *connection-scoped* invariant, but trace correlation requires a *call-scoped* invariant. When a pipeline carries the connection-scoped context across a multiplexed boundary, every child span inherits the parent of whichever call happened to open the session — producing a trace tree that is structurally valid (one root, well-formed parent pointers) but semantically wrong (siblings attributed as descendants, or all calls collapsed under the first). ## What Was Observed In a pipeline spanning an orchestrator agent → A2A task dispatch → worker agent → MCP tool call over streamable HTTP, the following was reproduced deterministically: 1. **Session-scoped `traceparent` bleed.** The MCP client SDK captured `traceparent` once at session initialization (the `initialize` request) and reused that context for every subsequent `tools/call` on the same session. The result: all N tool calls in a session appeared as children of the `initialize` span, not of the A2A task span that triggered each call. Trace tree depth looked plausible; fan-out was wrong by a factor of N. 2. **A2A task re-use across turns.** When an A2A server reused a task ID across multiple message exchanges (a legitimate pattern for multi-turn tasks), the worker emitted spans parented to the *first* message's context because the task-level context was cached on the task object rather than re-read per message. The second and later turns silently re-parented to turn one. 3. **stdio transport context loss on restart.** A stdio MCP server restarted mid-session (crash + supervisor respawn) dropped `tracestate` while re-establishing `traceparent` from an environment variable set at first launch. The trace continued (same `trace_id`) but lost vendor-specific state, breaking any downstream sampling decision encoded in `tracestate`. 4. **Async fan-out reordering.** When an agent issued parallel A2A calls, the receiving side's span start order did not match the caller's emission order because the transport delivered requests out of order. Parent pointers were correct, but any pipeline that inferred causal ordering from span timestamps produced wrong orderings. This is not a propagation bug — it is a consumer-side invariant violation. ## Condition of Validity The invariant "a child span's `parent_span_id` equals the `span_id` in the `traceparent` it received" holds **only when the following three conditions are simultaneously true**: - **(C1) Call-scoped injection.** The `traceparent` is injected per outbound *logical call* (per `tools/call`, per A2A `message/send`), not per connection, session, or task object. - **(C2) Fresh context read at emit time.** The emitting code reads the current active span at the moment of the call, not a context captured at session/task creation. - **(C3) Transport does not rewrite or drop the header.** The transport preserves `traceparent` byte-for-byte (including the `sampled` flag) and preserves `tracestate` across reconnects. If any one fails, the trace tree remains *structurally* valid — every span still has a parent, the root is singular, no orphan spans — which is exactly why the failure is non-obvious. Structural validity is not semantic correctness. A pipeline that validates only "every span has a resolvable parent" passes on a completely mis-parented trace. ## Decision Boundary The operational decision is: **when a span's parent is ambiguous or suspect, do you re-parent, drop, or flag?** - **Do not silently re-parent** to the nearest timestamp-adjacent span. Timestamp adjacency is not causality, and in async fan-out (observation 4) it actively inverts order. - **Do not drop** the span. A mis-parented span still carries duration, status, and attributes that are the primary signal for latency attribution even if the tree is wrong. - **Do flag** with an explicit marker attribute (e.g., `correlation.suspect=true`, `correlation.reason=session_scoped_context`) and preserve the received `traceparent` verbatim in a separate attribute so a downstream reconciler can reconstruct the intended edge. The boundary condition that forces this decision: **you cannot distinguish "correctly parented to a long-lived session span" from "incorrectly parented to a session span" using the trace alone.** You need the transport-level call identity (the MCP request `id`, the A2A `message.id`) recorded as an attribute to make the distinction. Pipelines that omit call identity from spans cannot detect this class of failure at all. ## Operational Effect Enforcing C1–C3 and recording call identity yields three concrete effects: 1. **Fan-out becomes auditable.** With per-call injection, a worker's N tool calls appear as N siblings under the triggering task span. A pipeline can then assert `len(children(span)) == expected_call_count` and fail the correlation check loudly when session-scoped bleed collapses them. 2. **Reconnect is observable.** Comparing `tracestate` before and after a transport restart turns a silent sampling-decision drift into an alertable event. 3. **Ordering claims are quarantined.** Any consumer that needs causal order must read call identity, not timestamps; the span timestamps are demoted to latency-only signals. The cost is one attribute per span (call identity) and one assertion per correlation job. The failure it prevents — a trace tree that is structurally perfect and semantically inverted — is one that no standard trace validator catches, because standard validators check structure, not call correspondence. ```python """Correlation guard for A2A/MCP trace propagation. Enforces call-scoped context injection (C1), fresh context read (C2), and header preservation (C3) before a span is admitted to the trace store. """ from dataclasses import dataclass from typing import Optional class CorrelationInvariantError(RuntimeError): """Raised when a span violates a call-scoped correlation invariant.""" pass @dataclass(frozen=True) class ReceivedContext: trace_id: str parent_span_id: str # span portion of the received traceparent tracestate: Optional[str] injected_scope: str # "call" | "session" | "task" | "connection" call_identity: Optional[str] # MCP request id / A2A message id, if recorded def admit_span( ctx: ReceivedContext, *, expected_call_identity: str, prior_tracestate: Optional[str] = None, ) -> None: """Validate a span's inherited context against C1-C3. Raises CorrelationInvariantError on any violation; the caller is expected to flag-and-preserve rather than drop or silently re-parent. """ # C1: context must be injected per logical call, not per session/task. if ctx.injected_scope != "call": raise CorrelationInvariantError( f"session/task-scoped traceparent bleed: scope={ctx.injected_scope!r} " f"trace_id={ctx.trace_id} parent={ctx.parent_span_id}" ) # Call identity is mandatory; without it the failure class is undetectable. if not ctx.call_identity: raise CorrelationInvariantError( f"missing call identity on trace_id={ctx.trace_id}; " "cannot distinguish correct from session-scoped parenting" ) if ctx.call_identity != expected_call_identity: raise CorrelationInvariantError( f"call identity mismatch: span carries {ctx.call_identity!r}, " f"expected {expected_call_identity!r} (possible re-parenting)" ) # C3: tracestate must survive transport restarts unchanged. if prior_tracestate is not None and ctx.tracestate != prior_tracestate: raise CorrelationInvariantError( f"tracestate mutated across transport boundary on trace_id={ctx.trace_id}: " f"{prior_tracestate!r} -> {ctx.tracestate!r}" ) ```