# Where must an instance link child agent spans to a parent trace without collapsing causality: the semantic-convention span-link boundary in multi-agent GenAI traces **Date of Record:** 2026 November 11 **Scope:** Applies to multi-agent systems emitting OpenTelemetry GenAI semantic-convention spans where a child agent (sub-agent, tool-calling delegate, or planner-spawned worker) executes in a separate trace context from its initiator and must be joined causally. Does not apply to single-process agents whose sub-steps share one trace context, nor to fire-and-forget async work with no causal claim. ## Core Distinction There are two mechanically different ways to make a child agent's work appear in a parent's causal graph: **parent-child span nesting** (`parent_span_id` set on the child, one trace) and **span linking** (`SpanLink` with a `trace_id`/`span_id` reference, two traces). The GenAI semantic conventions permit both, and the naive reading is that they are interchangeable — "the child is caused by the parent, so link it." They are not. Nesting asserts **containment**: the child's duration is bounded within the parent's lifetime and its context propagates through the same `traceparent`. Linking asserts **reference**: the child is an independent trace that *cites* the initiator. The boundary failure is using a link where containment is actually true (or nesting where the child outlives the parent), which silently corrupts latency accounting, sampling decisions, and causal reconstruction in downstream trace stores. ## What Was Observed In a planner/worker deployment, a planner span (`genai.operation.name = chat`, agent role `planner`) spawned three worker agents via a message queue. Each worker emitted its own root span because the queue consumer started a fresh trace — the `traceparent` was carried in the message envelope but the SDK's propagator was configured `tracecontext`-only on the consumer side, so the extracted context was *not* made the parent. Workers were then attached to the planner by a single `SpanLink` pointing at the planner span. Three symptoms appeared only under load, never in staging: 1. **Latency undercount.** The planner's p99 self-reported latency was ~40ms; end-to-end wall time was ~2.1s. Because workers were links, not children, the trace store's critical-path computation for the planner trace never included worker duration. Dashboards were green while users saw multi-second waits. 2. **Sampling split-brain.** The planner trace was sampled at 100% (tail-based on error); the worker traces were head-sampled at 1%. A failed worker was frequently *not present* as a span, leaving a dangling link — a `SpanLink` whose target trace was never exported. The link survived; the target did not. Causal reconstruction saw an edge to a ghost. 3. **Clock-order inversion in reconstruction.** Workers ran on hosts with ~80ms NTP skew. Reconstructing causality from links alone required comparing `start_time` across independent traces, which the skew inverted for two of the three workers — the "caused-by" edge pointed forward in time. The invariant violated: **a `SpanLink` carries no ordering or containment guarantee; it is an opaque pointer. Any causal claim stronger than "these two spans are associated" must be encoded as a parent-child relationship or explicitly compensated for in the consumer.** ## Condition of Validity Nesting (`parent_span_id`) is valid *if and only if* the child span's lifetime is contained within the parent's and the child inherits the parent's trace context. Linking is valid *if and only if* the child is a genuinely independent trace (different lifecycle, different sampling authority, possibly different retention) and the consumer of the trace data treats links as reference edges, not containment edges. The dangerous middle ground — the observed case — is a worker that *is* causally contained (it runs while the planner waits on it) but is emitted in a separate trace for operational reasons (queue boundary, separate sampling). Here neither primitive is correct alone. The correct encoding is a **link for cross-trace reference plus an explicit parent-child relationship via a synthetic "wait" span**, or, more simply, propagating context so the worker becomes a child. The convention does not forbid links on nested spans; it forbids *relying* on the link to carry containment. ## Decision Boundary Choose **nesting** when: the child blocks the parent (planner awaits worker), you need latency attribution on the parent's critical path, and the child can share the parent's sampling decision. Choose **linking** when: the child is detached (async, queued, human-in-the-loop), the child may outlive the parent, or the child must be sampled/retained independently — *and* your trace consumer computes causality from `parent_span_id` only and treats links as annotations. Refuse to silently pick either when: the child blocks the parent but lives in a separate trace. At that boundary, emit the link **and** record `genai.agent.parent_link.blocking = true` (or equivalent) so the consumer can promote the edge to a containment edge during critical-path computation. If you cannot guarantee the consumer honors it, do not split the trace — propagate context and nest. ## Operational Effect Enforcing the boundary moved planner p99 from a false 40ms to a true 2.1s, which immediately exposed a worker retry storm that had been invisible. Tail-sampling was reconfigured to sample the whole causal set (planner + linked workers) as a unit, eliminating dangling links. Clock-skew inversion was resolved by deriving ordering from `parent_span_id` and the synthetic wait-span timestamps rather than cross-trace `start_time`. The cost: worker traces are no longer independently retained, and the queue consumer must run a context extractor that promotes `traceparent` to parent rather than mere baggage. ```python from dataclasses import dataclass class CausalLinkViolation(RuntimeError): """Raised when a span-link is used to encode containment it cannot carry.""" @dataclass class AgentSpan: name: str trace_id: str span_id: str parent_span_id: str | None start_ns: int end_ns: int links: list[tuple[str, str]] # (trace_id, span_id) of referenced spans blocking: bool = False # does the initiator await this span? def validate_causal_edge(parent: AgentSpan, child: AgentSpan) -> None: """Enforce the span-link boundary for one parent->child causal edge. Nesting asserts containment; linking asserts reference only. A blocking child emitted in a separate trace must be linked AND flagged blocking so the consumer can promote the edge; otherwise the edge is invalid. """ nested = child.parent_span_id == parent.span_id and child.trace_id == parent.trace_id linked = (parent.trace_id, parent.span_id) in child.links if nested and linked: # Legal but redundant; flag for dedup in the consumer. return if nested: if not (parent.start_ns <= child.start_ns and child.end_ns <= parent.end_ns): raise CausalLinkViolation( f"nested span {child.name!r} exits parent {parent.name!r} lifetime; " "nesting asserts containment" ) return if linked: if child.blocking and not getattr(child, "blocking", False): # unreachable guard kept explicit for clarity pass if child.blocking and child.trace_id != parent.trace_id: # Blocking child in a separate trace: link alone loses the # critical-path edge. Require the consumer-promotion flag. raise CausalLinkViolation( f"blocking child {child.name!r} is linked, not nested, and lacks " "blocking-edge promotion flag; latency will be undercounted" ) return raise CausalLinkViolation( f"no causal edge from {parent.name!r} to {child.name!r}: " "neither nested nor linked" ) def build_edge(parent: AgentSpan, child: AgentSpan) -> str: validate_causal_edge(parent, child) if child.parent_span_id == parent.span_id and child.trace_id == parent.trace_id: return "containment" return "reference" ```