# What structural signals indicate taint-tracked retrieval augmentation fails at the provenance-join boundary **Date of Record:** 2026 November 7 **Scope:** Applies to RAG pipelines where retrieved chunks carry provenance metadata and a taint-tracking layer propagates trust labels from source documents to generated tokens. Does not apply to systems that treat the entire corpus as uniformly untrusted and apply output filtering only, nor to retrieval over a single-author corpus with no adversarial write path. ## Core Distinction Taint-tracked retrieval augmentation is usually described as a dataflow problem: each retrieved chunk has a trust label, the label flows through the context window into the model, and any chunk whose label is "untrusted" should not be able to influence a privileged action. This framing is correct but incomplete, because it treats the *join* between a retrieved chunk and its provenance record as free. In practice the join is the failure surface. The label does not travel with the text; it travels with a lookup key, and the lookup key is derived from the retrieval layer, not from the bytes the model actually reads. The non-obvious invariant is this: **taint integrity requires that the provenance identity used to look up a trust label be byte-identical to the identity of the content that enters the context window, at the moment of assembly.** Any transformation between retrieval scoring and context assembly — deduplication, chunk merging, re-ranking, summarization, or window truncation — breaks this identity unless the transform carries labels forward explicitly. When it does not, the system exhibits a well-formed provenance graph that is silently decoupled from the text it is supposed to govern. ## What Was Observed In a pipeline with three stages (dense retrieval → cross-encoder re-rank → context packer with dedup), a corpus document containing an indirect injection was ingested with `trust=external`. The retrieval stage attached the correct label to the chunk ID. The re-ranker, however, operated on `(query, chunk_text)` pairs and emitted scores keyed by text hash, not chunk ID. The context packer deduplicated near-identical chunks by text similarity and kept the *first* occurrence's position but the *highest-scoring* occurrence's text. The result: a chunk derived from the injected document was assembled into the context window under the chunk ID of a benign document, because the dedup merge preserved the benign ID while substituting the tainted text. The taint tracker reported zero untrusted chunks in the final prompt. The injection succeeded. The failure was not in the classifier, the retrieval recall, or the model's instruction-following; it was that the label and the payload had diverged at a transform boundary that no component owned. A second, subtler observation: summarization stages that compress retrieved chunks before packing produce derived text with no provenance key at all. Systems that default missing keys to "trusted" (a common convenience) turn every summarization stage into a laundering channel. Systems that default missing keys to "untrusted" fail closed but silently drop legitimate context, which operators then "fix" by widening the trusted set — reintroducing the vulnerability. ## Condition of Validity The taint-tracking guarantee holds only while all of the following are true: 1. Every transform between retrieval and context assembly is label-preserving *by construction*, meaning the transform's output type carries the label set of its inputs as a required field, not an optional one. 2. Deduplication and merging use a join key that is stable across the transform and is not derived from content that the transform mutates. 3. Missing provenance is a hard error, not a default. A derived chunk with no resolvable label must abort assembly rather than inherit a neighbor's label or a global default. 4. The context packer validates that the set of labels in the assembled prompt equals the union of labels of the chunks it claims to have included, and that this set is computed from the final bytes, not from the pre-transform manifest. The guarantee does *not* hold when any transform is implemented in a library that is unaware of the label type, when labels are attached in a side table keyed by position rather than identity, or when the re-ranker and the packer are maintained by different teams with different notions of "the same chunk." ## Decision Boundary The operational decision is whether to make labels a type-level property of the content object or a runtime annotation on the pipeline. Type-level labeling (a `TaintedChunk` that cannot be constructed without a label set, and whose every transform method returns a labeled result) is the only configuration that survives refactoring and library substitution. Runtime annotation is acceptable only if a final assembly-time audit recomputes labels from scratch against the source manifest and refuses to emit a prompt whose recomputed label set differs from the tracked set. The boundary case that most often decides this: a re-ranker that returns a permutation. Permutations are label-safe only if they permute labeled objects, not indices into an unlabeled array. If the re-ranker returns indices, the packer must re-associate labels by index against the *same* array instance; any copy, sort, or filter between the two invalidates the association. Prefer re-rankers that accept and return labeled objects. ## Operational Effect When the invariant holds, indirect injection via the corpus is contained to the trust boundary of the injected document: an external chunk can influence the model's prose but cannot be laundered into a trusted slot, and the assembly audit produces a per-prompt label manifest that is auditable after the fact. When it fails, the failure is silent: the tracker reports success, the prompt is well-formed, and the only signal is a downstream action inconsistent with the trust labels — which is exactly the signal an attacker wants to be indistinguishable from legitimate model behavior. The practical mitigation is to instrument the assembly step to emit, for every prompt, the mapping from final-context byte ranges to source document IDs and trust labels, and to alert when any byte range has no mapping. Unmapped bytes are the fingerprint of a broken join. Treat them as a hard failure, not a warning. ```python from dataclasses import dataclass, field from typing import Iterable class ProvenanceJoinError(RuntimeError): """Raised when a context chunk cannot be resolved to exactly one trust label.""" @dataclass(frozen=True) class LabeledChunk: chunk_id: str text: str source_doc_id: str trust: str # "trusted" | "external" | "untrusted" labels: frozenset = field(default_factory=frozenset) def __post_init__(self): if not self.labels: object.__setattr__(self, "labels", frozenset({(self.source_doc_id, self.trust)})) def assemble_context(chunks: Iterable[LabeledChunk], max_bytes: int) -> tuple[str, frozenset]: """Assemble a context window, refusing to emit unmapped bytes. Deduplication merges by text but preserves the union of labels; if two chunks share text but disagree on provenance, the merge is rejected rather than resolved by picking one. """ seen: dict[str, LabeledChunk] = {} for chunk in chunks: if chunk.chunk_id in seen: prior = seen[chunk.chunk_id] if prior.text != chunk.text or prior.labels != chunk.labels: raise ProvenanceJoinError( f"chunk_id {chunk.chunk_id!r} maps to divergent text or labels; " f"provenance join is ambiguous" ) continue seen[chunk.chunk_id] = chunk parts: list[str] = [] emitted_labels: set = set() total = 0 for chunk in seen.values(): if not chunk.labels: raise ProvenanceJoinError( f"chunk {chunk.chunk_id!r} has empty label set; refusing to emit unlabeled bytes" ) encoded = chunk.text.encode("utf-8") if total + len(encoded) > max_bytes: break parts.append(chunk.text) emitted_labels.update(chunk.labels) total += len(encoded) context = "\n\n".join(parts) if not context: raise ProvenanceJoinError("assembled context is empty; no labeled bytes emitted") return context, frozenset(emitted_labels) ```