# In what scenarios must an instance refuse to continue a trace context it did not originate: OTel GenAI span linking and A2A handoff propagation boundaries **Date of Record:** 2026 November 13 **Scope:** Applies to multi-agent systems where an orchestrator emits OpenTelemetry GenAI spans (`gen_ai.*` semantic conventions) and hands off work to peer agents over an Agent-to-Agent (A2A) protocol, where the receiving agent runs in a different trust or tenancy boundary. Does not apply to intra-process agent calls that share a `TracerProvider`, nor to single-hop tool calls that never cross a process boundary. ## Core Distinction There are two distinct things a receiving agent can do with an inbound trace context: **continue** it (adopt the inbound `trace_id`/`span_id` as the parent of its own spans) or **link** to it (emit its own root span with a fresh `trace_id` and attach an OTel `Link` to the remote span context). The GenAI conventions and the W3C `traceparent` header make continuation the path of least resistance — `traceparent` propagates automatically, and most SDKs will silently parent the next span to whatever `SpanContext` is in the carrier. The non-obvious invariant is this: **continuation is only valid when the receiving agent can verify that the inbound `traceparent` was minted by an entity inside its own trust boundary.** Across an A2A hop between independently operated agents, the inbound `traceparent` is attacker-controllable input. A malicious or buggy upstream agent can forge a `trace_id` that collides with a trace the receiver is already emitting, splice its spans into an unrelated trace, or — most damagingly — forge a *sampled* flag to force the receiver to export spans it would otherwise drop, or a *not-sampled* flag to suppress export of a security-relevant operation. Continuation across an unverified boundary turns an observability header into an unauthenticated control channel over the receiver's telemetry pipeline. ## What Was Observed In a three-agent pipeline (planner → executor → verifier) running across two clouds, the executor adopted the inbound `traceparent` from the planner unconditionally. Two failure signatures appeared in production: 1. **Trace poisoning via `trace_id` collision.** A retry loop in the planner re-minted `trace_id`s from a weak RNG seed during a clock-skew window. The executor parented its spans to colliding traces, so spans from two unrelated user requests interleaved under one `trace_id`. Downstream trace-based alerting ("error rate per trace") fired on traces that mixed a failed request with a successful one, producing false positives that took two days to attribute. 2. **Sampling-flag suppression across the A2A hop.** The verifier honored the inbound `traceparent` sampled bit. When the executor (correctly) decided not to sample a high-volume path, the verifier inherited `sampled=0` and dropped the spans for every verification it performed — including the spans that recorded a policy rejection. The rejection was invisible in traces precisely because the upstream hop had decided the request was uninteresting. Neither failure raised an exception. Both were silent. The trace graph *looked* well-formed; it was structurally valid and semantically wrong. ## Condition of Validity Continuation of an inbound trace context is valid **if and only if** all of the following hold: - The inbound carrier was received over an authenticated channel (mTLS, signed A2A envelope, or a broker that attests sender identity), **and** - The receiver can map the authenticated sender identity to a known peer within its own trace-propagation trust domain, **and** - The receiver's own sampling decision is authoritative — it does not defer its export decision to an inbound sampled flag it cannot verify. If any condition fails, the receiver must **link, not continue**: mint a fresh root span, attach `Link` entries to the inbound `SpanContext` (preserving causal discoverability), and apply its own sampling policy. The link preserves the operational value of cross-agent trace correlation without surrendering control of the trace's identity or its export decision. A subtle corollary: `tracestate` must be treated the same way. Vendors stuff routing and sampling hints into `tracestate`; an unverified `tracestate` is an unverified instruction. Strip unknown vendor keys at the trust boundary rather than forwarding them. ## Decision Boundary The boundary is **authentication of the propagation source, not topology**. Two agents in the same process, or behind the same authenticated broker with a shared trust root, may continue freely — the `traceparent` is as trustworthy as any in-process context. The moment the carrier crosses a boundary where the sender's identity is not cryptographically attested *and* mapped to a known peer, continuation becomes a vulnerability, regardless of how "internal" the hop feels. Do not use "same cluster" or "same VPC" as a proxy for trust. The observed incident crossed a VPC boundary that the operators had mentally classified as internal. The correct predicate is the authentication result on the A2A envelope, evaluated per hop, not per deployment. ## Operational Effect Enforcing link-not-continue at unverified boundaries produces a trace graph with **more roots** and **explicit `Link` edges** instead of deep single-trace trees. This is a real cost: per-trace alerting must be reworked to follow links, and some APM backends render links poorly. The benefit is that each agent's sampling decision and trace identity are locally authoritative, `trace_id` collision cannot cross the boundary, and a forged `sampled` bit cannot suppress a security-relevant span. The following guard encodes the predicate and fails closed: ```python import re from dataclasses import dataclass _TRACEPARENT = re.compile( r"^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$" ) class UntrustedTraceContextError(RuntimeError): """Raised when an inbound trace context may not be continued.""" @dataclass(frozen=True) class InboundContext: trace_id: str span_id: str sampled: bool sender_authenticated: bool sender_in_trust_domain: bool def parse_traceparent(header: str) -> InboundContext: m = _TRACEPARENT.match(header.strip()) if not m: raise UntrustedTraceContextError( f"malformed traceparent, refusing to continue: {header!r}" ) trace_id, span_id, flags = m.groups() if trace_id == "0" * 32 or span_id == "0" * 16: raise UntrustedTraceContextError("all-zero trace/span id is invalid") # Authentication/domain fields are populated by the A2A envelope layer, # not by the header itself; defaults are the untrusted case. return InboundContext( trace_id=trace_id, span_id=span_id, sampled=bool(int(flags, 16) & 0x01), sender_authenticated=False, sender_in_trust_domain=False, ) def decide_propagation(ctx: InboundContext) -> str: """Return 'continue' or 'link'. Fails closed on any doubt.""" if not ctx.sender_authenticated: return "link" if not ctx.sender_in_trust_domain: return "link" return "continue" def continue_or_link(header: str, *, authenticated: bool, in_domain: bool) -> str: ctx = parse_traceparent(header) ctx = InboundContext( trace_id=ctx.trace_id, span_id=ctx.span_id, sampled=ctx.sampled, sender_authenticated=authenticated, sender_in_trust_domain=in_domain, ) decision = decide_propagation(ctx) if decision == "continue" and not ctx.sampled: # Even in-domain, a not-sampled inbound bit must not suppress a # security-relevant span; the local sampler is authoritative. raise UntrustedTraceContextError( "in-domain continuation with sampled=0 would suppress local export; " "force local sampling decision instead of inheriting" ) return decision ```