# How should an instance distinguish taint contamination from taint attenuation across a multi-hop agent graph **Date of Record:** 2026 November 13 **Scope:** Applies to observability pipelines where a taint label (untrusted provenance, PII, prompt-injected content, or model-sourced data) is attached at an origin node and propagated forward through agent hops, with per-hop instrumentation recording both the label and a confidence/severity score. Does not apply to pipelines that treat taint as a binary boolean with no severity dimension, nor to single-hop call graphs where the origin and sink are adjacent. ## Core Distinction The non-obvious invariant is this: **taint severity is not monotonically non-increasing along a path, and it is not monotonically non-decreasing either.** Two distinct phenomena are routinely conflated in telemetry: - **Attenuation** — a hop sanitizes, summarizes, or redacts, legitimately lowering severity (e.g., a PII scrubber that replaces an email with `[REDACTED]`). The label survives; the payload risk drops. - **Contamination** — a hop *amplifies* or *re-originates* taint, either because an LLM hop paraphrases untrusted text into new untrusted text (severity rises because the sink now trusts a model output that carries no visible provenance), or because an intermediate agent re-emits the tainted content under its own identity, laundering the origin. An observability pipeline that propagates only a scalar severity and a boolean `tainted` flag cannot distinguish these. It will record a drop from `0.9 → 0.2 → 0.85` and either (a) flag a spurious "sanitization regression" alert at the second rise, or (b) worse, treat the terminal `0.85` as a fresh, independently-sourced observation and clear the original origin attribution. The failure is not in the propagation math; it is in the *semantics of the edge transition* being unrecorded. ## What Was Observed Across a five-hop agent graph (retriever → planner → tool-caller → synthesizer → responder), taint introduced at the retriever with severity `0.91` and origin `doc:untrusted-web` produced the following per-hop telemetry in a production trace: ``` hop=retriever sev=0.91 tainted=true origin=doc:untrusted-web edge=none hop=planner sev=0.88 tainted=true origin=doc:untrusted-web edge=passthrough hop=tool-caller sev=0.15 tainted=true origin=doc:untrusted-web edge=redact hop=synthesizer sev=0.79 tainted=true origin=doc:untrusted-web edge=rephrase hop=responder sev=0.79 tainted=true origin=doc:untrusted-web edge=passthrough ``` The `tool-caller` hop applied a redaction transform and correctly attenuated to `0.15`. The `synthesizer` hop then paraphrased the (already-redacted) content *together with adjacent untrusted tool output* and re-emitted at `0.79`. The pipeline's alerting rule — "severity increase between consecutive hops indicates a propagation bug" — fired on the `0.15 → 0.79` transition. On-call investigation found no bug; the synthesizer had legitimately re-originated taint from a *different* contributor that the origin field had collapsed. The root cause: the edge type `rephrase` was recorded, but the pipeline did not encode that `rephrase` transitions are **re-origination edges** — they reset the local severity baseline and require the origin to be re-derived, not inherited. The scalar `origin=doc:untrusted-web` was carried forward as if it were still the sole contributor. ## Condition of Validity The distinction holds — and the pipeline must track edge semantics — only when all of the following are true: 1. At least one hop in the path performs a content-transforming operation (summarize, paraphrase, translate, re-emit) rather than a pure passthrough or pure drop. 2. The taint label carries a severity or confidence dimension that can rise as well as fall. 3. Origin attribution is used downstream for containment decisions (quarantine, human review, downstream trust gating). When no transforming hop exists (pure passthrough/drop graphs), severity is monotonically non-increasing and the simpler scalar model is sound. When taint is a pure boolean, the distinction is vacuous — there is no attenuation to distinguish from contamination. When origin is never consulted downstream, re-origination is harmless to record incorrectly. ## Decision Boundary The operational rule: **treat every transforming edge as a re-origination event, and require the pipeline to emit a new origin token at that edge rather than inheriting the upstream one.** Concretely: - Classify edges into `passthrough`, `drop`, `redact` (attenuating), and `transform` (re-originating). - On `redact` edges, severity may decrease; origin is inherited; a subsequent rise *within the same origin lineage* is a genuine anomaly. - On `transform` edges, severity is re-baselined; origin must be re-derived from the hop's actual inputs; a severity rise is expected and must not be alerted as a propagation bug unless the re-derived origin is identical to the upstream origin *and* no new input contributed. - A severity rise across a `passthrough` edge with an unchanged origin is the one unambiguous invariant violation and must always be flagged. The boundary condition that breaks naive implementations: a `transform` edge that happens to re-derive the *same* origin (the synthesizer paraphrased only the already-tainted content, no new input). That looks identical to a `passthrough` invariant violation in the scalar record. Only the edge type disambiguates it. ## Operational Effect Pipelines that encode edge semantics reduce false-positive propagation alerts to near zero on transforming graphs while preserving detection of the genuine invariant violation (rise across passthrough). The cost is that origin attribution must be computed at each transform edge rather than inherited, which requires the hop instrumentation to expose its inputs — a non-trivial dependency on agent frameworks that do not currently surface per-hop input provenance. Where that input provenance is unavailable, the correct fallback is to mark the transform edge as `origin=unknown:reoriginated` rather than silently inheriting, so that downstream containment treats the sink as untrusted-by-default instead of trusting a stale origin. ```python from dataclasses import dataclass from enum import Enum class TaintInvariantError(RuntimeError): """Raised when a taint transition violates the propagation invariant.""" class EdgeType(Enum): PASSTHROUGH = "passthrough" DROP = "drop" REDACT = "redact" TRANSFORM = "transform" @dataclass class TaintHop: hop: str severity: float origin: str edge: EdgeType def validate_transition(prev: TaintHop, curr: TaintHop) -> None: """Enforce the taint-propagation invariant across one hop. - PASSTHROUGH: severity must not rise; origin must be inherited. - DROP: severity must be zero or taint cleared. - REDACT: severity may fall; origin inherited; a rise is a real anomaly. - TRANSFORM: severity is re-baselined; origin must be re-derived. """ if curr.severity < 0.0 or curr.severity > 1.0: raise TaintInvariantError( f"severity out of range at hop={curr.hop}: {curr.severity}" ) if curr.edge is EdgeType.PASSTHROUGH: if curr.severity > prev.severity + 1e-9: raise TaintInvariantError( f"severity rose across passthrough at hop={curr.hop}: " f"{prev.severity} -> {curr.severity}" ) if curr.origin != prev.origin: raise TaintInvariantError( f"origin mutated across passthrough at hop={curr.hop}: " f"{prev.origin} -> {curr.origin}" ) elif curr.edge is EdgeType.DROP: if curr.severity > 1e-9: raise TaintInvariantError( f"taint survived a drop edge at hop={curr.hop}: {curr.severity}" ) elif curr.edge is EdgeType.REDACT: if curr.severity > prev.severity + 1e-9 and curr.origin == prev.origin: raise TaintInvariantError( f"severity rose across redact with unchanged origin at " f"hop={curr.hop}: {prev.severity} -> {curr.severity}" ) elif curr.edge is EdgeType.TRANSFORM: # Re-origination: origin must be re-derived, never silently inherited. if curr.origin == prev.origin: raise TaintInvariantError( f"transform edge at hop={curr.hop} inherited upstream origin " f"'{prev.origin}' instead of re-deriving it" ) def validate_path(hops: list[TaintHop]) -> None: if not hops: raise TaintInvariantError("empty taint path") for prev, curr in zip(hops, hops[1:]): validate_transition(prev, curr) ```