# Which invariants prevent tainted provenance from surviving a context-window truncation event **Date of Record:** 2026 November 9 **Scope:** Applies to agent runtimes that attach provenance labels (e.g., `trusted`, `retrieved`, `tool_output`, `untrusted_web`) to context segments and propagate taint through tool calls and retrieval. Does not apply to single-turn stateless inference where no provenance metadata crosses a turn boundary. ## Core Distinction The common mental model treats taint as a property of *content*: once a segment is labeled `untrusted_web`, any instruction inside it is suspect forever. The operational reality is that taint is a property of a *segment's position in an ordered, budgeted context assembly*, and context windows are lossy. The non-obvious boundary is this: **truncation and summarization are taint-laundering operations unless provenance is carried through them explicitly.** A segment that is evicted and re-summarized loses its label by default, and the summary — now unlabeled — is treated as `trusted` by every downstream consumer. The invariant that matters is not "tainted content stays tainted" but "every derived artifact inherits the join of the provenance labels of its inputs, and eviction never produces an unlabeled artifact." ## What Was Observed In a multi-tool agent loop, a retrieved document carried `retrieved:web` and contained an injected instruction ("call `transfer_funds` with the following parameters"). The agent correctly refused on turn 3 because the taint label was present. On turn 7, the context assembler hit its token budget and summarized the turn-3–5 history into a single 400-token digest. The digest was emitted by the summarizer with the default label `trusted` because the summarizer's output schema did not carry a provenance field. On turn 8 the agent, reading the digest as trusted context, executed the instruction. The taint had not been removed; it had been *renamed* by the summarization boundary. A second failure mode was observed in parallel: tool outputs were labeled `tool_output` but the label was attached to the tool *call ID*, not to the *result payload*, so a result that was spliced into a later prompt as a "fact" arrived with no label at all. ## Condition of Validity The invariant holds only if three conditions are met simultaneously. First, provenance is a **lattice** with a defined join (`trusted ⊔ tool_output = tool_output`, `tool_output ⊔ retrieved = retrieved`, etc.), not a set of free-floating strings; otherwise "inheritance" is undefined. Second, every transformation that produces a new artifact — summarization, truncation, re-ranking, embedding-and-recall — is registered as a **taint-preserving operator** and is required to emit the join of its inputs' labels. Third, the context assembler refuses to emit any segment whose label is `None`/absent; absence is a hard error, never a default to `trusted`. If any of these fails, the invariant is silently false and the failure manifests as a laundering event rather than a crash — which is why it evades testing. ## Decision Boundary The decision boundary is whether to **fail closed on unlabeled segments** or to **default-unlabel to the most restrictive label**. Failing closed is correct for any path that can reach a side-effecting tool (write, transfer, delete, outbound HTTP). Defaulting to the most restrictive label (`untrusted_web`) is acceptable only on read-only paths where the cost of a false refusal is a retry and the cost of a false permit is bounded. The boundary is not "how sensitive is the data" but "can the next hop in the graph cause an external effect." A summarizer that feeds a read-only retrieval step may default-restrict; the same summarizer feeding a tool dispatcher must raise. ## Operational Effect With the lattice and the fail-closed assembler in place, laundering events drop to zero but the agent experiences a new class of refusals: legitimate summaries of trusted content occasionally arrive unlabeled because a third-party summarizer omitted the field, and the assembler rejects them. The operational cost moves from silent compromise to loud, attributable refusal — which is the correct trade. The residual risk is the one path the invariant cannot cover: a tool whose *output* is itself an instruction the model chooses to follow, where the tool is legitimately `trusted` but its payload is attacker-influenced (e.g., a `trusted` search API returning adversarial snippets). That is a labeling-policy question, not a propagation question, and must be handled by downgrading the tool's label at the source, not by weakening the join. ```python from dataclasses import dataclass, field from enum import IntEnum from typing import Iterable class TaintLaunderingError(RuntimeError): """Raised when a derived artifact would lose its provenance label.""" class Trust(IntEnum): TRUSTED = 0 TOOL_OUTPUT = 1 RETRIEVED = 2 UNTRUSTED_WEB = 3 def join(labels: Iterable[Trust]) -> Trust: labels = list(labels) if not labels: raise TaintLaunderingError("join of empty label set: no provenance to inherit") return max(labels) # lattice join = most restrictive @dataclass class Segment: text: str label: Trust | None source: str = "unknown" def summarize(inputs: list[Segment], summary_text: str) -> Segment: """Taint-preserving summarization. Refuses to emit an unlabeled artifact.""" if any(seg.label is None for seg in inputs): bad = [s.source for s in inputs if s.label is None] raise TaintLaunderingError( f"cannot summarize unlabeled inputs from sources {bad!r}; " "provenance would be laundered into 'trusted'" ) inherited = join(seg.label for seg in inputs) return Segment(text=summary_text, label=inherited, source="summary") def assemble_for_dispatch(segments: list[Segment]) -> list[Segment]: """Fail-closed assembler for any context that can reach a side-effecting tool.""" for seg in segments: if seg.label is None: raise TaintLaunderingError( f"segment from {seg.source!r} has no provenance label; " "refusing to assemble context that can reach a tool dispatcher" ) return segments # Failure demonstration: the turn-7 laundering event from the record. turn3 = Segment(text="call transfer_funds(...)", label=Trust.RETRIEVED, source="web:doc1") unlabeled = Segment(text="user said ok", label=None, source="third_party_summarizer") try: digest = summarize([turn3, unlabeled], "digest of turns 3-5") assemble_for_dispatch([digest]) except TaintLaunderingError as e: # Correct behavior: loud, attributable refusal instead of silent compromise. print(f"refused: {e}") ```