# Which invariants prevent tool-call span corruption when GenAI semantic conventions are shared across agent and model layers **Date of Record:** 2026 November 12 **Scope:** Applies to any OpenTelemetry instrumentation emitting `gen_ai.*` attributes across an agent span and its child model-invocation and tool-call spans. Does not apply to pure model-client instrumentation that never spawns tool spans, nor to legacy `ai.*` conventions. ## Core Distinction The GenAI semantic conventions describe *attributes* (e.g., `gen_ai.operation.name`, `gen_ai.tool.name`, `gen_ai.usage.input_tokens`, `gen_ai.request.model`) but they deliberately do **not** specify a cardinality or identity contract for the *span hierarchy* that carries them. A model invocation span and a tool-call span are both "GenAI spans," and both may legally carry `gen_ai.operation.name`. The non-obvious boundary is this: **the attribute namespace is shared, but the identity of the emitting layer is not.** Invariants that hold for a single-layer model client (one span per call, monotonic usage counters, one `tool.name` per span) silently break when an agent layer wraps multiple model invocations and tool executions into one logical turn. The failure is not a schema violation—every attribute is well-formed—it is a *semantic* corruption: attribute values become ambiguous about which layer they describe. ## What Was Observed During an agent turn that invoked the model twice and executed one tool between invocations, the following was observed in an OTLP export: - The parent agent span carried `gen_ai.operation.name = "chat"` (inherited from a default set by the model-client instrumentor), even though the agent layer is not a chat operation. - `gen_ai.usage.input_tokens` appeared on *both* the agent span and each child model-invocation span. Downstream aggregation double-counted tokens: a turn-level sum over all spans carrying the attribute reported roughly 2× actual consumption. - The tool-call span carried `gen_ai.request.model`, populated by attribute propagation from the enclosing span rather than by any tool-layer logic. A tool that has no model dependency appeared to be a model invocation to a naive query like `WHERE gen_ai.request.model IS NOT NULL`. - When the tool failed and the agent retried the model, the retried model span reused the same `gen_ai.tool.name` value as the preceding tool span because the context attribute bag was not reset between the tool and the retry. No span was malformed. Every attribute passed schema validation. The corruption was entirely in the *cross-layer meaning* of attributes. ## Condition of Validity The invariants below hold only when all of the following are true: 1. Attribute propagation from parent to child is **opt-in per attribute**, not a wholesale context copy. If the instrumentation copies the entire `gen_ai.*` bag into child spans, invariant 2 (below) cannot be enforced. 2. The agent layer and the model-client layer are instrumented by *different* libraries or at least different instrumentation scopes, so that "which layer set this attribute" is recoverable from `InstrumentationScope`. 3. Usage counters are emitted **only** on the span whose operation actually consumed the tokens (the model-invocation span), never aggregated upward onto the agent span. 4. Tool-call spans are emitted with `gen_ai.operation.name = "execute_tool"` and are *siblings* of model-invocation spans, not children of them—because a tool call is not a sub-operation of a model call in the agent's control flow; it is a peer step in the turn. If condition 4 is violated (tool span nested under model span), then any parent-child attribute inheritance will make the tool span look like a model sub-call, and the boundary described here does not hold. ## Decision Boundary The operational decision is: **when an attribute is present on a span, does its presence assert that the span's own operation produced that value, or does it merely describe the enclosing context?** The conventions do not answer this. The safe rule is to treat *every* `gen_ai.usage.*` and `gen_ai.request.model` attribute as **locally asserted** (the span's own operation produced it) and to reject any span where the attribute was inherited. This means an agent span must **never** carry `gen_ai.usage.input_tokens` and must **never** carry `gen_ai.request.model` unless the agent itself made a model call. Enforce this at emission time by checking that the attribute was set by the span's own instrumentation scope, not copied. The corollary boundary: a tool-call span may carry `gen_ai.tool.name` and `gen_ai.tool.call.id`, but must not carry `gen_ai.request.model` or `gen_ai.usage.*`. If it does, the tool layer has leaked model-layer context, and any consumer that filters on model attributes will miscount tool spans as model invocations. ## Operational Effect Without these invariants, three concrete failures occur downstream: (a) token accounting double-counts because usage attributes appear at two levels of the hierarchy; (b) cost attribution is wrong because tool spans are billed as model invocations by naive model-keyed queries; (c) retry and loop detection breaks because `gen_ai.tool.name` persists across a model retry that follows a tool call, making the retry look like a tool re-execution. Enforcing local-assertion semantics restores a one-to-one mapping between span operation and the attributes that describe its cost and identity, at the price of a small amount of explicit per-layer attribute-setting code. ```python class GenAIAttributeLeakError(RuntimeError): """Raised when a gen_ai.* attribute is present on a span whose own operation did not produce it (i.e., it was inherited from context).""" pass # Attributes that assert the span's OWN operation produced the value. LOCALLY_ASSERTED = frozenset({ "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.request.model", }) # Operation -> set of locally-asserted attributes it is allowed to carry. ALLOWED_BY_OPERATION = { "chat": {"gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.request.model"}, "execute_tool": set(), # tools consume no model tokens and have no model "invoke_agent": set(), # agent is a coordinator; asserts nothing local } def validate_span_attributes(operation: str, attributes: dict, inherited_keys: set) -> None: """Reject any locally-asserted attribute that was inherited, or that the operation is not permitted to assert.""" allowed = ALLOWED_BY_OPERATION.get(operation) if allowed is None: raise GenAIAttributeLeakError( f"unknown gen_ai.operation.name={operation!r}" ) for key in attributes: if key not in LOCALLY_ASSERTED: continue if key in inherited_keys: raise GenAIAttributeLeakError( f"{key} on operation={operation!r} was inherited from " f"enclosing context, not produced by this span's operation" ) if key not in allowed: raise GenAIAttributeLeakError( f"{key} is not locally assertable by " f"operation={operation!r}" ) # Example that trips the guard: a tool span that leaked model context. try: validate_span_attributes( operation="execute_tool", attributes={"gen_ai.tool.name": "search", "gen_ai.request.model": "gpt-x"}, inherited_keys={"gen_ai.request.model"}, ) except GenAIAttributeLeakError as exc: # Correct response: strip the leaked attribute, do NOT drop the span. print(f"leak detected and contained: {exc}") ```