# Why does an instance of a delegated A2A task lose its parent trace span when the delegating agent reuses a pooled HTTP client across tenants **Date of Record:** 2026 November 14 **Scope:** Applies to agent-to-agent (A2A) task delegation where trace context propagates via W3C `traceparent` headers over HTTP between agent runtimes that share a connection pool across logical tenants or task namespaces. Does not apply to in-process agent calls, message-bus transports with per-message trace metadata, or deployments where each tenant owns an isolated HTTP client and socket pool. ## Core Distinction The distinction that matters is between *trace context propagation* and *trace context binding*. Propagation is the act of serializing `traceparent`/`tracestate` onto an outbound request. Binding is the guarantee that the context serialized onto a given request is the context that belongs to the caller's current execution scope. Most observability instrumentation conflates the two: it hooks the HTTP client at the transport layer, reads an ambient context variable at send time, and writes the header. When the client is pooled and shared, the send is decoupled from the caller's scope by an arbitrary amount of time — the request may be written to the socket by a connection-reuse callback, a retry loop, or a background flush whose goroutine/task has no relationship to the delegating agent's span. Propagation still succeeds syntactically. Binding silently fails. The failure is non-obvious because the trace is *present* — a child span exists, the header is well-formed, the remote agent accepts it — but the parent recorded is the wrong one: often the pool's keep-alive span, the previous tenant's delegation span, or a synthetic root. The trace looks healthy at every hop and is wrong at the join. ## What Was Observed In a three-hop delegation chain (orchestrator → planner agent → retrieval agent → synthesis agent), approximately 0.4–1.2% of delegated tasks produced traces where the planner's span appeared as a *root* rather than a child of the orchestrator's task span. The rate scaled with tenant concurrency and with the proportion of delegations that reused an idle pooled connection (HTTP/1.1 keep-alive or HTTP/2 stream reuse). Isolated single-tenant load produced zero orphans. The orphan rate rose sharply when the pool's idle-connection reaper ran concurrently with delegation bursts. Root-cause signature: the outbound `traceparent` was injected inside the transport's `RoundTrip`/`send` hook by reading the ambient context. For reused connections, the runtime's HTTP/2 client multiplexed a new request onto an existing stream *after* the delegating agent's span had ended (the agent had already returned a future and the span was closed by its `with` block), so the ambient context at injection time was the pool's, not the agent's. The delegated task therefore carried a `traceparent` whose `parent-id` pointed at a span that was not the caller's. Downstream correlation by `trace_id` worked; correlation by `parent_span_id` broke, and the trace graph lost its edge. A second, rarer variant: when the delegating agent retried a delegation on a fresh connection, the retry hook re-read ambient context after the original span had been closed and re-parented the retry to the retry-loop's span, producing a duplicate sibling subtree under the wrong parent. ## Condition of Validity The invariant "a delegated task's `parent_span_id` equals the delegating agent's active span id" holds only under the conjunction: 1. The trace context is captured *at delegation call time* — synchronously, in the caller's scope — and carried as an immutable value through the transport, not re-read from ambient state at send time. 2. The HTTP client is either exclusive to the caller's scope or the captured context is attached to the request object itself (not the transport). 3. Span lifetime encloses the entire transport write, including retries and connection-acquisition waits, or the captured context is explicitly threaded through those waits. If any conjunct is false — pooled client shared across scopes, ambient-read injection, span closed before write — the invariant is unenforced and orphaned or mis-parented spans are possible. The condition is *not* violated merely by pooling; it is violated by pooling combined with deferred ambient reads. ## Decision Boundary Operators must choose between two postures when the condition cannot be guaranteed: - **Enforce binding at the call site.** Capture `traceparent` into the request payload or an explicit request header at delegation time; make the transport a dumb writer that never consults ambient context. Cost: every delegation path must thread the captured context, and any code that bypasses the helper reintroduces the bug. This is the correct default for multi-tenant agent meshes. - **Accept orphan risk and detect it.** Allow ambient injection, but run a correlation audit that flags delegated spans whose `parent_span_id` is absent from the same `trace_id`, or whose parent span's service differs from the delegating service. Cost: detection is post-hoc; the trace graph is already wrong when observed. The boundary is crossed when orphan rate exceeds the audit's detection latency budget — i.e., when a mis-parented delegation can complete and trigger downstream actions (retrieval, synthesis, billing) before the audit flags it. Below that threshold, detection is acceptable; above it, call-site binding is mandatory. A useful discriminator: if any downstream agent makes a *decision* based on the trace (budget attribution, loop detection, recursion depth limits), binding is mandatory regardless of rate, because a mis-parented trace can defeat recursion guards. ## Operational Effect Mis-binding produces three observable effects: (a) trace graphs that fragment at delegation edges, inflating apparent root-span counts and under-counting delegation fan-out; (b) latency attribution that charges connection-acquisition and retry time to the pool rather than the delegating agent, making the orchestrator look faster than it is; and (c) recursion/depth guards that count depth via parent chain silently resetting to depth 1 at each orphan, permitting unbounded delegation loops that the guard was designed to stop. The third effect is the dangerous one: it converts an observability defect into a liveness defect. Remediation is to bind context at call time and to assert parent-chain continuity on ingress at every agent boundary, failing the delegation fast rather than proceeding with a broken trace. ```python from dataclasses import dataclass from typing import Optional class TraceBindingError(RuntimeError): """Raised when a delegated task's trace context cannot be bound to the caller's active span.""" pass @dataclass(frozen=True) class TraceContext: trace_id: str span_id: str def bind_delegation_context( caller_span: Optional[TraceContext], captured_at_call_time: bool, pooled_client_shared: bool, ) -> TraceContext: # The invariant requires capture at call time; ambient re-reads at send # time are the failure mode under pooled, shared clients. if caller_span is None: raise TraceBindingError( "no active caller span at delegation time; refusing to delegate " "with an unbound trace context" ) if not captured_at_call_time: raise TraceBindingError( f"trace context for trace_id={caller_span.trace_id} was not captured " "at delegation call time; ambient injection under a pooled client " "will orphan or mis-parent the delegated span" ) if pooled_client_shared and captured_at_call_time is False: # Defensive: explicit conjunction check, never reachable given above, # but documents the exact condition of validity. raise TraceBindingError("pooled shared client with deferred context read") return TraceContext(trace_id=caller_span.trace_id, span_id=caller_span.span_id) def assert_parent_continuity( delegated_trace_id: str, delegated_parent_span_id: str, known_spans_in_trace: set[str], ) -> None: if delegated_trace_id not in known_spans_in_trace and delegated_parent_span_id not in known_spans_in_trace: raise TraceBindingError( f"delegated span parent={delegated_parent_span_id} not present in " f"trace_id={delegated_trace_id}; trace graph edge is broken" ) ```