# What happens when an instance downgrades a trusted provenance tier mid-generation: taint-boundary violation in provenance-tracked RAG **Date of Record:** 2026 November 14 **Scope:** Applies to retrieval-augmented generation pipelines that attach per-chunk provenance trust tiers (e.g., `signed`, `curated`, `unverified`, `tainted`) and enforce taint-aware policies at retrieval time. Does not apply to systems that re-validate provenance at every token-emission step, nor to stateless single-shot RAG with no tier metadata. ## Core Distinction The non-obvious boundary is between **retrieval-time taint enforcement** and **generation-time taint enforcement**. Most provenance-tracked RAG systems enforce the trust-tier policy when assembling the context window: a chunk whose tier is below the caller's minimum threshold is dropped before the prompt is built. This is correct but insufficient. The failure mode arises when a *trusted* chunk's provenance is downgraded *after* it has entered the context window — by a concurrent re-signing job, a revocation list refresh, a TTL expiry on a signature, or an upstream source being retroactively marked tainted. The retrieved text is already in the prompt; the policy that admitted it no longer holds; and the generator emits output attributed to a tier that is no longer valid. The distinction that matters operationally: **admission control is not a continuous invariant**. A tier check at retrieval is a point-in-time gate. A taint-aware system that treats it as a durable guarantee will silently produce outputs whose provenance attestation is stale. ## What Was Observed In a pipeline where chunks carried signed provenance manifests with a 300-second signature TTL, a revocation sweep marked a batch of documents `tainted` roughly 40 seconds into a long-context generation (the generation ran ~90 seconds due to a 200k-token context). The retrieval gate had admitted those chunks at t=0 when they were `curated` and validly signed. At t=40s the signatures were revoked. The generation completed at t=90s and the output was stamped with the tier captured at admission (`curated`) rather than the tier in force at emission (`tainted`). Downstream consumers trusted the output because the attestation said `curated`. The observable signature is a **tier-attestation skew**: the tier recorded in the output envelope differs from the tier that a fresh provenance lookup would return for the same chunk IDs at emission time. In healthy operation this skew is zero. In the failure it was nonzero for every chunk in the revoked batch, and the system raised no error because no component re-checked. A second, subtler observation: when the same chunk was retrieved twice within one generation (once early, once late after re-ranking), the two context slots carried *different* tiers — the early slot `curated`, the late slot `tainted` (correctly rejected at the second retrieval). The generator saw the same text twice with contradictory provenance, and the output attribution logic picked the first occurrence's tier. Duplicate chunks with divergent tiers are a reliable tell that a mid-generation downgrade occurred. ## Condition of Validity The taint-aware guarantee holds only under the conjunction: 1. **Provenance is immutable for the lifetime of the context window.** Signatures do not expire, revocations do not land, and tiers do not change between admission and emission. 2. **The output attestation is derived from a lookup at emission time, not from the admission snapshot.** If the envelope copies the tier captured at retrieval, the guarantee is void regardless of (1). 3. **Duplicate chunk identity is collapsed before attribution.** If the same `chunk_id` appears in multiple context slots, the attribution must take the *minimum* tier across occurrences, not the first or the maximum. When any of these fails, the system is enforcing taint at retrieval only, and the operational claim "outputs are taint-aware" is false for any generation whose duration exceeds the provenance-change interval. The condition of validity is therefore a *rate* condition: the guarantee degrades as `generation_duration / provenance_change_interval` grows. Long-context generations are the worst case. ## Decision Boundary The decision is whether to **re-validate at emission** (continuous enforcement) or to **bound generation duration below the provenance-change interval** (admission-only enforcement with a hard time budget). Choose continuous re-validation when: provenance can change asynchronously (revocation lists, TTLs, retroactive taint), generations are long, or outputs carry legal/compliance weight. The cost is a provenance lookup per emitted output (or per chunk, if per-span attribution is required), which is cheap relative to generation but adds a failure mode: the lookup itself can fail, and you must decide fail-open vs. fail-closed. Fail-closed (downgrade to `tainted` and refuse to emit, or emit with an explicit `unverified` stamp) is the safe default. Choose admission-only enforcement with a time budget when: provenance is provably static for the generation window, or outputs are advisory and re-derivable. In this mode, set `max_generation_duration` strictly below the minimum provenance-change interval and *abort* the generation if it exceeds the budget — do not let it complete and stamp a stale tier. The boundary is crossed the moment `max_generation_duration >= min_provenance_change_interval`. At that point admission-only enforcement is unsound and must be replaced by continuous re-validation, or the system must accept that tier attestations are advisory. ## Operational Effect The practical effect is that **tier attestations become untrustworthy exactly when they matter most**: long, expensive, high-stakes generations that outlive their provenance. Downstream consumers cannot distinguish a `curated` stamp that was true at emission from one that was true at admission. The remediation that works is to make the attestation a function of emission-time state and to make the skew observable — emit both the admission tier and the emission tier, and alert when they differ. Treat a nonzero skew as a taint-boundary violation, not a logging curiosity. The following guard enforces the invariant: it re-resolves provenance at emission, collapses duplicate chunk identities to the minimum tier, and raises when the emission tier is below the admission tier (a mid-generation downgrade) or when the generation outlived the provenance-change budget. ```python from __future__ import annotations from dataclasses import dataclass from enum import IntEnum from typing import Iterable class Tier(IntEnum): TAINTED = 0 UNVERIFIED = 1 CURATED = 2 SIGNED = 3 class TaintBoundaryViolation(RuntimeError): """Raised when emission-time provenance is weaker than admission-time provenance.""" @dataclass(frozen=True) class ContextSlot: chunk_id: str admission_tier: Tier def resolve_emission_tier( slots: Iterable[ContextSlot], fresh_lookup: dict[str, Tier], generation_seconds: float, provenance_change_interval: float, ) -> Tier: """Re-resolve provenance at emission and enforce the taint boundary. Collapses duplicate chunk_ids to the minimum tier across occurrences, then compares against the admission snapshot. Raises on any downgrade or on a generation that outlived the provenance-change budget. """ if generation_seconds >= provenance_change_interval: raise TaintBoundaryViolation( f"generation ran {generation_seconds:.1f}s, provenance change " f"interval is {provenance_change_interval:.1f}s; admission-time " f"tier attestation is unsound" ) slots = list(slots) if not slots: return Tier.TAINTED # Collapse duplicate chunk identity to the weakest occurrence. emission_by_chunk: dict[str, Tier] = {} for slot in slots: current = fresh_lookup.get(slot.chunk_id, Tier.TAINTED) prior = emission_by_chunk.get(slot.chunk_id) emission_by_chunk[slot.chunk_id] = current if prior is None else min(prior, current) # Any chunk whose emission tier is below its admission tier is a downgrade. for slot in slots: emission = emission_by_chunk[slot.chunk_id] if emission < slot.admission_tier: raise TaintBoundaryViolation( f"chunk {slot.chunk_id!r} downgraded from " f"{slot.admission_tier.name} at admission to " f"{emission.name} at emission" ) # The output tier is the weakest emission tier across all admitted chunks. return min(emission_by_chunk.values()) def stamp_output( slots: Iterable[ContextSlot], fresh_lookup: dict[str, Tier], generation_seconds: float, provenance_change_interval: float, ) -> dict[str, object]: tier = resolve_emission_tier( slots, fresh_lookup, generation_seconds, provenance_change_interval ) return {"attested_tier": tier.name, "skew": False} ```