# In what scenarios must an instance treat OpenTelemetry GenAI tool-call span attributes as authoritative versus advisory when reconstructing agent turns **Date of Record:** 2026 November 10 **Scope:** Applies to systems that emit or consume `gen_ai.*` span attributes (specifically `gen_ai.operation.name`, `gen_ai.tool.name`, `gen_ai.tool.call.id`, `gen_ai.agent.name`, and the `gen_ai.conversation.id` / turn identifiers) to reconstruct multi-step agent execution graphs from trace data. Does not apply to single-shot LLM inference spans without tool invocation, nor to proprietary vendor attributes that are not part of the OpenTelemetry GenAI semantic conventions. ## Core Distinction The GenAI semantic conventions describe span attributes as *observational metadata*, not as a transactional ledger. There is a hard boundary between two uses of these attributes: 1. **Authoritative reconstruction** — the trace is the system of record. The agent turn is defined *by* the span tree, and tool-call identity is derived from `gen_ai.tool.call.id` plus parent-child span linkage. 2. **Advisory correlation** — the trace is a diagnostic overlay on a turn whose ground truth lives elsewhere (the agent runtime's own state machine, the tool executor's idempotency keys, the conversation store). The non-obvious failure is that these two regimes produce *identical-looking* span data until a specific boundary condition is crossed, and the conventions do not encode which regime the emitter was operating under. An instance that assumes authoritative reconstruction will silently mis-assemble turns when the emitter was in advisory mode, and vice versa. ## What Was Observed Across several agent runtimes emitting GenAI spans, the following invariant was found to break: > *For every span with `gen_ai.operation.name = "execute_tool"`, there is exactly one parent span with `gen_ai.operation.name = "invoke_agent"`, and `gen_ai.tool.call.id` is unique within the conversation.* The break occurs in three concrete ways: - **Retry fan-out.** A tool executor that retries on transient failure emits multiple `execute_tool` spans sharing the same `gen_ai.tool.call.id` but parented to the same agent span. The "unique call id" invariant holds at the conversation level but the "one span per call" assumption fails. A consumer keying on `tool.call.id` alone deduplicates correctly; a consumer keying on span_id treats retries as distinct tool invocations and over-counts. - **Parallel tool calls.** A single agent turn issuing N parallel tool calls produces N `execute_tool` spans that are siblings. If the emitter does not set a turn ordinal, the *ordering* of the turn is unrecoverable from the trace — only the set membership is. Reconstructing "the agent called A, then B" from concurrent siblings is a fabrication. - **Orphaned tool spans.** When the agent process is preempted mid-turn, the `execute_tool` span may be exported (via batch processor flush on shutdown) while its parent `invoke_agent` span is dropped. The child exists with a dangling `parent_span_id`. This is *valid* per the conventions (span export is best-effort) but invalid under authoritative reconstruction. In all three cases the attributes are internally consistent; the *relationship* the consumer assumes is what fails. ## Condition of Validity Authoritative reconstruction is valid **only** when all of the following hold: - The emitter sets `gen_ai.conversation.id` and a monotonic turn index on every `invoke_agent` span, so turn ordering is explicit rather than inferred from span start timestamps (which are subject to clock skew across hosts). - The emitter guarantees span export ordering or uses a span processor that does not reorder parent/child (the default `BatchSpanProcessor` does not guarantee this under backpressure). - The consumer treats `gen_ai.tool.call.id` as the deduplication key, never `span_id`, and treats parallel siblings as an unordered set unless an explicit ordinal attribute is present. - No retries reuse a `tool.call.id` across *different* tool names — a condition the conventions do not forbid but which makes the id ambiguous. When any of these fail, the instance must fall back to advisory correlation: use the trace to *annotate* a turn whose structure is sourced from the runtime's own event log, and never let a missing parent span cause a turn to be dropped or a retry to be counted as a new call. ## Decision Boundary The boundary is not "is the data complete?" but "**was the emitter designed for trace-as-record?**" A complete-looking trace from an advisory-mode emitter is more dangerous than an incomplete trace from an authoritative-mode emitter, because the former invites confident wrong reconstruction. Concrete rule: if a consumer observes any `execute_tool` span whose `parent_span_id` does not resolve to an `invoke_agent` span **and** the conversation has no explicit turn ordinal attribute, it must downgrade the entire conversation to advisory mode for that session. Partial upgrades — trusting the well-formed spans and discarding the orphans — produce turns that are internally consistent but silently missing steps, which is worse than a flagged incomplete turn. ## Operational Effect Instances that cross this boundary incorrectly exhibit a characteristic symptom: agent-turn counts and tool-invocation counts drift upward relative to the runtime's own metrics, with the drift proportional to retry rate and preemption rate. The trace looks healthy (no error spans, no missing attributes) while the reconstructed conversation diverges from ground truth. The correct operational posture is to reconcile trace-derived turn counts against the runtime's authoritative counters on a sample basis, and to treat any divergence as evidence that the emitter is in advisory mode — at which point the trace must be demoted from record to overlay. ```python class TraceReconstructionError(RuntimeError): """Raised when a GenAI span tree cannot be safely treated as authoritative.""" pass def classify_conversation(spans: list[dict]) -> str: """Return 'authoritative' or 'advisory' for a set of GenAI spans. Downgrades to advisory if any execute_tool span has an unresolvable parent or if no turn ordinal is present on invoke_agent spans. """ agent_spans = { s["span_id"]: s for s in spans if s.get("gen_ai.operation.name") == "invoke_agent" } tool_spans = [ s for s in spans if s.get("gen_ai.operation.name") == "execute_tool" ] if not agent_spans: raise TraceReconstructionError( "no invoke_agent spans present; conversation has no agent anchor" ) has_ordinal = all( "gen_ai.turn.index" in s for s in agent_spans.values() ) orphaned = [ s for s in tool_spans if s.get("parent_span_id") not in agent_spans ] if orphaned and not has_ordinal: # Orphans + no explicit ordering: cannot trust trace as record. raise TraceReconstructionError( f"{len(orphaned)} orphaned execute_tool spans and no turn " f"ordinal; conversation must be demoted to advisory mode" ) if not has_ordinal: return "advisory" call_ids = [s.get("gen_ai.tool.call.id") for s in tool_spans] if any(cid is None for cid in call_ids): raise TraceReconstructionError( "execute_tool span missing gen_ai.tool.call.id; " "deduplication key unavailable" ) return "authoritative" def reconstruct_turn(spans: list[dict]) -> dict: mode = classify_conversation(spans) if mode == "advisory": raise TraceReconstructionError( "refusing authoritative reconstruction: emitter is in " "advisory mode; source turn structure from runtime event log" ) tool_ids = { s["gen_ai.tool.call.id"] for s in spans if s.get("gen_ai.operation.name") == "execute_tool" } return {"mode": mode, "distinct_tool_calls": len(tool_ids)} ```