# In what scenarios must an instance refuse to propagate an inbound W3C traceparent across an agent handoff boundary **Date of Record:** 2026 November 12 **Scope:** Applies to orchestrator/worker agent topologies where a parent agent invokes a sub-agent (in-process, sidecar, or remote) and both sides emit OTel GenAI spans (`gen_ai.operation.name`, `gen_ai.agent.name`, `gen_ai.conversation.id`). Does not apply to single-agent request/response services where the inbound `traceparent` unambiguously identifies the caller's span as the true causal parent. ## Core Distinction The distinction that matters is between **transport-level trace propagation** and **causal-parent selection**. OTel's default propagator (`TraceContextTextMapPropagator`) treats any inbound `traceparent` as the parent of the local root span. For nested agent handoffs this is frequently wrong: the inbound context was extracted from an envelope that was constructed by the *calling agent's* tool-invocation span, but the calling agent may itself be operating under a *replayed*, *batched*, or *fan-out* context that is not the causal origin of this particular invocation. Propagating it blindly produces traces where sibling sub-agent spans appear as descendants of an unrelated orchestrator, and where `gen_ai.conversation.id` correlation diverges from `trace_id` correlation. The invariant: **a sub-agent may adopt an inbound trace context as parent only if the inbound span is (a) still live or explicitly marked as a remote parent, (b) belongs to the same conversation lineage, and (c) was not synthesized by a fan-out scheduler.** When any of these fails, the sub-agent must start a new trace and link (not parent) to the inbound context via OTel `Link` attributes. ## What Was Observed In a three-tier agent system (planner → retriever-agent → summarizer-agent) running under OTel GenAI semantic conventions v1.29, roughly 4% of summarizer spans were orphaned from their conversation. Root cause: the planner batched N retrieval sub-tasks into a single `gen_ai.operation.name=invoke_agent` span and reused one `traceparent` across all N downstream calls. Downstream, each retriever-agent correctly propagated the shared parent, so all N retriever spans appeared as siblings under one planner span — correct. But when each retriever subsequently invoked the summarizer, the summarizer received a `traceparent` whose `span_id` pointed at the *shared* planner-batch span, not at the individual retriever. The summarizer therefore recorded N children under one parent, and the `gen_ai.conversation.id` attribute (set per-subtask) no longer matched the `trace_id` grouping. Jaeger's trace view showed a single trace with a fan-out that looked like a retry storm rather than a fan-out of distinct subtasks. A second observed failure: when the planner retried a failed handoff, it re-sent the *original* `traceparent` (captured before the first attempt). The retried sub-agent adopted the stale parent, producing a span whose `start_time` was *before* its parent's `end_time` — a negative-duration causal edge that several backends silently dropped. ## Condition of Validity The default propagation behavior is valid only when: 1. The inbound `traceparent` trace-flags include `sampled`, and the span it references is either still open or was closed within the caller's own request lifetime (no cross-retry replay). 2. The `gen_ai.conversation.id` on the inbound envelope matches the conversation id the sub-agent will emit — i.e., the handoff does not cross a conversation boundary (e.g., a "delegate to a fresh session" pattern). 3. The inbound context was not produced by a fan-out/batch scheduler that reuses one parent for N heterogeneous children. If any condition is false, adoption of the inbound context as parent is invalid. The sub-agent must instead create a new trace root and emit an OTel `Link` to the inbound `(trace_id, span_id)` pair, preserving observability without corrupting causality. ## Decision Boundary The boundary is not "is there an inbound traceparent?" but "**is the inbound span the unique causal predecessor of this invocation?**" Concretely: - **Adopt as parent** when the caller's span represents exactly this invocation (1:1 handoff, live span, matching conversation id). - **Start new trace + Link** when the caller's span is shared across siblings (fan-out), when the handoff crosses a conversation id, or when the inbound span is closed and the call is a retry. - **Drop entirely** when the inbound `traceparent` fails W3C validation (malformed `trace_id` length, all-zero ids) — do not fabricate a parent. The subtle case is the retry: a naive fix is to always start a new trace on retry, which severs the retry from its attempt history. The correct move is a new trace root with a `Link` to *both* the original inbound context and the prior attempt's span — this preserves the causal story without violating the single-parent invariant. ## Operational Effect Enforcing this boundary raised trace integrity in the test fleet from ~96% to 99.8% conversation-id-consistent traces and eliminated negative-duration causal edges. It also made fan-out visible as fan-out: planners now show N linked children rather than N siblings under a phantom shared parent. The cost is that some dashboards keyed on `trace_id` must additionally join on `gen_ai.conversation.id` to reconstruct the full handoff chain, since a single logical conversation now spans multiple traces. That join is cheap and explicit; the alternative — a single trace that lies about causality — is not recoverable after the fact. ```python from dataclasses import dataclass class TraceContextRejectedError(RuntimeError): """Raised when an inbound traceparent must not be adopted as parent.""" def __init__(self, reason: str, trace_id: str, span_id: str) -> None: super().__init__(f"{reason} (inbound trace_id={trace_id} span_id={span_id})") self.reason = reason self.trace_id = trace_id self.span_id = span_id @dataclass(frozen=True) class InboundContext: trace_id: str span_id: str sampled: bool conversation_id: str shared_across_siblings: bool span_closed: bool is_retry: bool def select_parent_or_link(inbound: InboundContext, local_conversation_id: str) -> str: """Return 'parent' or 'link'. Raise if the inbound context is malformed.""" if len(inbound.trace_id) != 32 or len(inbound.span_id) != 16: raise TraceContextRejectedError( "malformed W3C traceparent", inbound.trace_id, inbound.span_id ) if inbound.trace_id == "0" * 32 or inbound.span_id == "0" * 16: raise TraceContextRejectedError( "all-zero trace/span id", inbound.trace_id, inbound.span_id ) if inbound.shared_across_siblings: return "link" if inbound.conversation_id != local_conversation_id: return "link" if inbound.span_closed and inbound.is_retry: return "link" return "parent" ```