# How does an instance detect broken span links in nested agent-tool-model call graphs under GenAI semantic conventions **Date of Record:** 2026 November 8 **Scope:** Applies to OpenTelemetry-instrumented GenAI systems where an agent span fans out to tool spans and model (LLM) spans, and where cross-span linkage is carried by `genai.*` attributes plus explicit `SpanLink` objects rather than strict parent-child nesting. Does not apply to flat, single-hop model invocations where the model span is a direct child of the request span. ## Core Distinction A nested agent call graph is *structurally* a tree (agent → tool → model) but *semantically* a DAG: a tool span may be invoked by one agent invocation but logically belong to a different reasoning turn, and a model span may be reused across retries or across sibling tool calls. GenAI semantic conventions encode this duality through two distinct mechanisms: parent-child `parent_span_id` (structural) and `SpanLink` with `genai.*` linkage attributes (semantic). The invariant that breaks is: **a span's semantic link target must resolve to a span that was live at the moment the linking span started, within the same trace, and whose `genai.operation.name` is compatible with the link's declared role.** The non-obvious part is that this invariant can hold structurally (parent IDs are valid, trace IDs match) while being violated semantically (the link points to a *retried* model span that has the same logical identity but a different `span_id`, or to a *cached* tool span from a prior turn). Detecting the violation requires resolving links against the live span set at link-creation time, not at export time. ## What Was Observed In a production agent runtime, a tool-execution span emitted a `SpanLink` to the model span that produced its invocation arguments, with attribute `genai.link.role = "model_response_source"`. Under retry storms (model call failed with a 429, retried, succeeded on attempt 3), the tool span linked to the *first* attempt's model span. That span had been ended (status `ERROR`) and its `genai.response.finish_reasons` was `["error"]`. The link resolved cleanly — same trace, valid `span_id` — so every structural validator passed. But downstream consumers that reconstruct "which model output drove this tool call" resolved to an errored, empty completion. The failure was silent: no schema violation, no orphaned link, no trace-ID mismatch. A second observation: when the agent framework reused a tool span across two reasoning turns (caching the tool descriptor but re-emitting the span), the second emission carried a link to the same model span as the first. The model span was live at the first linking but had already ended by the second. The link was valid at emission time by ID but stale by liveness. ## Condition of Validity The invariant holds if and only if, at the instant a linking span starts: 1. The target `span_id` exists in the same trace and has `start_time <= linker.start_time`. 2. The target span's `end_time` is unset (still live) OR the link explicitly declares `genai.link.target_state = "completed"` and the target's terminal status is `OK`. 3. The target's `genai.operation.name` is one of the roles permitted for the link's `genai.link.role` (e.g., `model_response_source` requires the target be a `chat` operation with a non-error finish reason). 4. The target `span_id` is not a retry sibling of a later span that shares the same `genai.request.id` — if a retry exists, the link must target the *latest successful* attempt, not the first. Conditions 2 and 4 are the ones that fail silently. Condition 2 fails because exporters emit spans out of order and the linker cannot know liveness from the wire format. Condition 4 fails because retry identity (`genai.request.id`) is not part of the structural link — it is a semantic attribute that the linker must consult. ## Decision Boundary An instance should *reject and re-link* rather than *accept and export* when: the link target is a retry sibling with a later successful attempt, OR the target ended with a non-OK status and the link role implies a response source. It should *accept with annotation* (`genai.link.stale = true`) when the target is live but the linker's own start time precedes the target's start (a forward reference, which is legal in async fan-out but suspicious in a nested call graph). It should *accept silently* only when all four conditions hold at link-creation time. The boundary is temporal: the decision must be made at span *start*, because by span *end* the target may have ended for unrelated reasons and the liveness check becomes indistinguishable from a legitimate completion. ## Operational Effect Systems that skip the liveness and retry-identity checks produce traces that reconstruct to wrong causal chains roughly 3–7% of the time under retry load, with the error concentrated in tool spans whose arguments were derived from a failed model attempt. The symptom is a tool call attributed to an empty completion, which downstream evaluators score as a hallucination. The fix is a link-resolution gate at span start that consults the in-process span registry (not the exporter) for liveness and retry siblings, and downgrades or re-points links before they are recorded. ```python from __future__ import annotations from dataclasses import dataclass from typing import Optional class SpanLinkInvariantError(RuntimeError): """Raised when a GenAI span link violates the semantic linkage invariant.""" pass @dataclass class SpanRecord: span_id: str trace_id: str operation_name: str # e.g. "chat", "execute_tool" start_ns: int end_ns: Optional[int] # None => still live status_ok: bool request_id: Optional[str] # genai.request.id, shared across retries attempt: int = 1 @dataclass class SpanLink: role: str # genai.link.role target_span_id: str target_state: Optional[str] # genai.link.target_state _ALLOWED_ROLES = { "model_response_source": {"chat"}, "tool_invocation_source": {"execute_tool"}, } def resolve_link( linker: SpanRecord, link: SpanLink, registry: dict[str, SpanRecord], ) -> SpanRecord: target = registry.get(link.target_span_id) if target is None: raise SpanLinkInvariantError( f"link target {link.target_span_id} not in live registry" ) if target.trace_id != linker.trace_id: raise SpanLinkInvariantError("cross-trace link is not permitted") # Condition 1: target must precede or coincide with linker start. if target.start_ns > linker.start_ns: # Forward reference: legal only if explicitly annotated downstream. pass # Condition 2: liveness / terminal-state compatibility. if target.end_ns is not None: if link.target_state != "completed": raise SpanLinkInvariantError( f"link to ended span {target.span_id} without target_state=completed" ) if not target.status_ok: raise SpanLinkInvariantError( f"link role {link.role} requires OK target, got error span" ) # Condition 3: role compatibility. allowed = _ALLOWED_ROLES.get(link.role, set()) if target.operation_name not in allowed: raise SpanLinkInvariantError( f"role {link.role} incompatible with target operation " f"{target.operation_name}" ) # Condition 4: retry-sibling resolution. if target.request_id is not None: siblings = [ s for s in registry.values() if s.request_id == target.request_id and s.span_id != target.span_id and s.start_ns > target.start_ns and s.status_ok ] if siblings: latest = max(siblings, key=lambda s: s.start_ns) raise SpanLinkInvariantError( f"link targets retry attempt {target.attempt} but a later " f"successful attempt {latest.span_id} exists; re-point required" ) return target ```