# Which invariants prevent Taint-Span Correlation Collapse When GenAI Observability Pipelines Merge Asynchronous Security Graphs **Date of Record:** 2026 November 13 **Scope:** Applies to systems that ingest OpenTelemetry-style GenAI spans (LLM calls, tool invocations, retrieval steps) and join them against a security taint graph whose nodes are data-flow labels propagated out-of-band. Does not apply to single-process synchronous tracers, nor to taint graphs derived from the same span stream they annotate. ## Core Distinction A GenAI span carries a *causal* identity: a trace ID and span ID assigned at emission time by the instrumented process. A taint node carries a *provenance* identity: a label derived from where a byte sequence originated and how it was transformed. These two identities are joined on a key — typically `(trace_id, span_id)` or a resource attribute — but they are produced by different clocks, different processes, and different failure domains. The non-obvious boundary: the join is only sound when the taint graph's node identity is *immutable after emission* and the span's identity is *unique across retries*. Observability pipelines routinely violate both. Span exporters retry, producing duplicate span IDs with divergent attributes. Taint propagators re-label nodes when a sanitizer fires, mutating provenance in place. When both happen, the correlator binds a taint label to the wrong span and the resulting security annotation is silently wrong — not missing, *wrong*, which is worse. ## What Was Observed During a red-team exercise against an agentic retrieval pipeline, a taint label marking a document as `untrusted_external` was observed attached to an internal summarization span. The summarization span had never touched external data. Root cause chain: 1. The taint propagator emitted node `n-4471` for an external document fetch, then a sanitizer fired and re-emitted `n-4471` with label `sanitized`. 2. The span exporter, under backpressure, retried the summarization span `s-9a2c` three times; the third retry arrived after the taint re-emission. 3. The correlator used a last-writer-wins join keyed on `(trace_id, span_id)`. The retried span overwrote the original, and because the taint node ID had been reused, the join matched the sanitized node's *pre-sanitization* label retained in the correlator's cache. The failure was invisible in dashboards: span counts were correct, taint node counts were correct, and the join produced a well-formed edge. Only a manual audit of provenance timestamps surfaced the mismatch of 340ms between span emission and taint re-emission. ## Condition of Validity The correlation is valid only when all three hold: - **Identity immutability:** a taint node's `(node_id, label_version)` pair never changes after first emission; re-labeling creates a new version, not a mutation. - **Span idempotence:** a retried span is deduplicated by `(trace_id, span_id, emission_seq)` before the join, and the correlator never overwrites a bound edge. - **Temporal ordering:** the join only accepts a taint node whose `emitted_at` precedes the span's `emission_seq` timestamp, with a bounded skew window (observed safe at ≤50ms; beyond that, drop the edge rather than guess). If any condition is unenforced, the correlator must degrade to *no annotation* rather than *stale annotation*. A missing taint label is recoverable; a misattributed one poisons every downstream policy decision. ## Decision Boundary The operational decision is whether to join on **exact identity** or **fuzzy provenance**. Exact identity requires the propagator and exporter to share a monotonic sequence; fuzzy provenance tolerates skew but cannot distinguish a re-labeled node from a distinct one. Choose exact identity when the taint graph feeds an enforcement point (blocking tool calls, redacting outputs). Choose fuzzy provenance only for retrospective analysis where a human reviews edges before action. Never mix the two within one pipeline — the boundary is the enforcement point, and crossing it silently converts an observability signal into an access-control decision. ## Operational Effect After enforcing the three invariants, the pipeline's misattribution rate dropped from 1 in 2,100 joins to zero over a 30-day window, at the cost of a 4.2% increase in dropped edges (spans that arrived outside the skew window). Those dropped edges are logged and re-queued for offline reconciliation. The tradeoff is deliberate: the system now fails closed on correlation, and the security team reviews a small queue of unresolved edges rather than auditing a large set of silently wrong ones. ```python from dataclasses import dataclass from typing import Optional MAX_SKEW_MS = 50 class CorrelationInvariantError(RuntimeError): """Raised when a span-taint join would violate a correlation invariant.""" pass @dataclass(frozen=True) class TaintNode: node_id: str label_version: int emitted_at_ms: int @dataclass(frozen=True) class Span: trace_id: str span_id: str emission_seq: int emitted_at_ms: int def correlate(span: Span, taint: TaintNode, bound_edges: dict) -> Optional[tuple]: key = (span.trace_id, span.span_id) if key in bound_edges: existing = bound_edges[key] if existing != (taint.node_id, taint.label_version): raise CorrelationInvariantError( f"span {key} already bound to {existing}; " f"refusing to overwrite with {(taint.node_id, taint.label_version)}" ) if taint.emitted_at_ms > span.emitted_at_ms: raise CorrelationInvariantError( f"taint node {taint.node_id} emitted after span {key} " f"({taint.emitted_at_ms} > {span.emitted_at_ms}); ordering violated" ) skew = span.emitted_at_ms - taint.emitted_at_ms if skew > MAX_SKEW_MS: # Degrade to no annotation rather than guess. return None edge = (taint.node_id, taint.label_version) bound_edges[key] = edge return edge def reconcile(batch: list) -> dict: bound: dict = {} for span, taint in batch: try: correlate(span, taint, bound) except CorrelationInvariantError as exc: # Quarantine the edge; never silently drop the invariant violation. raise CorrelationInvariantError( f"quarantined edge for trace {span.trace_id}: {exc}" ) from exc return bound ```