# When must an instance refuse model-driven pruning of stale reasoning traces: the read-after-prune hazard in self-editing context policies **Date of Record:** 2026 November 13 **Scope:** Applies to agents that maintain a mutable context buffer in which a model (or a model-derived policy) is authorized to delete, summarize, or mark-as-stale prior reasoning traces, and where later turns may read from the same buffer. Does not apply to append-only context windows, to pruning performed by an external scheduler against an immutable snapshot, or to traces whose deletions are guaranteed to be invisible to all future readers. ## Core Distinction There are two superficially similar operations that a self-editing context policy can perform on a stale reasoning trace: 1. **Marking** — attaching a `stale=true` annotation and leaving the trace bytes in place. Readers see the trace but know not to trust it. 2. **Pruning** — physically removing or replacing the trace with a summary, so that readers no longer have access to the original. The non-obvious invariant is that **marking is idempotent and commutative with respect to concurrent reads; pruning is not.** A model-driven pruner that is correct under a single-threaded, read-then-write discipline becomes unsound the moment any downstream consumer holds a reference to the pre-prune buffer, or re-derives a conclusion from a trace that has since been pruned. The failure is not "the pruner deleted something wrong." The failure is "the pruner deleted something that a still-live inference step was about to read, and the resulting answer is silently inconsistent with the trace that justified it." The distinction matters because most pruning heuristics are evaluated on *pruning quality* (did we delete the right stale traces?) and almost never on *read-after-prune safety* (can any live reader observe a context that is neither the pre-prune nor the post-prune state, but a mixture?). The second question is the one that produces wrong answers. ## What Was Observed In a long-horizon agent loop (planner → tool calls → verifier → replanner), a context policy was configured to run a model-driven pruner every N turns. The pruner received the full trace log and a rubric: "remove reasoning steps that are no longer load-bearing for the current goal." It returned a set of trace IDs to delete. Three observed failure modes, in increasing order of subtlety: - **Dangling justification.** The pruner deleted a trace `T` that concluded "endpoint X is read-only." A later turn's tool call wrote to X, and the verifier's post-hoc rationale cited `T` as the basis for assuming the write was safe. The citation pointed at a deleted trace. The verifier did not error; it confabulated a plausible justification. The answer was wrong and *looked* well-grounded. - **Summary drift.** The pruner replaced a 40-step trace with a 3-line summary. The summary omitted a negative result ("attempt 2 timed out, do not retry with the same parameters"). A later replanner, reading only the summary, retried attempt 2. Cost: one duplicated timeout, one duplicated tool budget. - **Torn read.** The pruner ran concurrently with a verifier that had already snapshotted the buffer pointer. The verifier read a buffer in which trace `T` had been removed but the dependent trace `T'` (which referenced `T` by ID) had not yet been rewritten. `T'` now contained a dangling reference. The verifier treated the unresolved reference as "unknown, proceed" rather than "unknown, halt." The common structure: **pruning creates a window in which the context is not a valid state of any consistent history.** Marking does not, because the bytes are still there and the annotation is monotone. ## Condition of Validity Model-driven pruning of stale traces is sound only when **all** of the following hold: 1. **No live reader holds a pre-prune reference.** Either the buffer is copy-on-write and readers are pinned to a version, or pruning is quiesced against all active inference steps. 2. **Pruning is atomic with respect to dependent traces.** If trace `T'` references `T`, then `T` and `T'` are pruned, rewritten, or marked in the same transaction. A pruner that emits a set of independent deletions violates this whenever the trace graph has edges. 3. **Every pruned trace leaves a tombstone.** The tombstone records the trace ID, the pruner's stated reason, and the turn at which pruning occurred. Without a tombstone, a later reader cannot distinguish "this trace never existed" from "this trace was deleted," and will confabulate. 4. **Negative results are not prunable.** Traces encoding "do not do X" or "X failed" are load-bearing precisely because they are not cited by positive reasoning. A pruner that scores traces by "is this cited downstream?" will systematically delete the prohibitions. Condition 4 is the one most often missed. It is also the one that produces the most expensive failures, because a deleted prohibition does not cause an immediate error — it causes a later action that the deleted trace was specifically there to prevent. When any of 1–4 is violated, the correct behavior is to **mark, not prune**, and to defer physical removal to a quiescent point. ## Decision Boundary The boundary is not "should we prune stale traces?" — usually we should, context is finite. The boundary is **whether the pruner can prove the four conditions above for the specific trace it is about to delete.** A practical decision procedure: - If the trace is a **negative result or a prohibition** (contains "do not", "failed", "avoid", "unsafe", or was produced by a verifier rejection), refuse to prune. Mark only. - If the trace is **referenced by any trace that will survive the prune**, refuse to prune unless the referencing trace is rewritten in the same operation. - If **any inference step is in flight** against the current buffer version, refuse to prune. Mark only; prune at the next quiescent barrier. - Otherwise, prune and emit a tombstone. The pruner must be able to *refuse*. A pruner that always returns a deletion set, and whose caller always applies it, has no decision boundary — it has a bug waiting for the right trace graph. ## Operational Effect Enforcing the boundary changes behavior in three measurable ways: - **Latency.** Pruning now waits for quiescent barriers. In a single-agent loop this is free (the barrier is the turn boundary). In a multi-agent or streaming setup it can add one barrier of latency per prune cycle. This is the cost of correctness and is not negotiable. - **Context growth.** Marked-but-not-pruned traces accumulate until the next barrier. In practice this is bounded by the barrier interval, not by trace count, and is small. - **Failure mode shift.** The dominant residual failure becomes "context fills with marked-stale traces faster than barriers can drain them," which is a *visible, recoverable* condition (the agent can widen the barrier or escalate). This replaces the previous dominant failure, "agent confidently acts on a confabulated justification for a deleted prohibition," which is *invisible and unrecoverable*. The trade is correct. A pruner that cannot refuse is not a policy; it is a source of silent inconsistency. ```python from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Iterable class PruneRefused(RuntimeError): """Raised when a model-driven pruner is asked to physically delete a trace that the read-after-prune invariant forbids removing.""" class TraceKind(Enum): POSITIVE = "positive" NEGATIVE = "negative" # "do not do X", "X failed", "X is unsafe" PROHIBITION = "prohibition" @dataclass(frozen=True) class Trace: trace_id: str kind: TraceKind body: str references: tuple[str, ...] = () @dataclass class ContextBuffer: traces: dict[str, Trace] = field(default_factory=dict) tombstones: list[dict] = field(default_factory=list) in_flight_readers: int = 0 def add(self, t: Trace) -> None: self.traces[t.trace_id] = t def mark_stale(self, trace_id: str, reason: str) -> None: # Marking is always safe: bytes stay, annotation is monotone. self.tombstones.append( {"trace_id": trace_id, "reason": reason, "action": "marked"} ) def prune(self, trace_id: str, reason: str, surviving: Iterable[str]) -> None: t = self.traces.get(trace_id) if t is None: return # Condition 4: negative results and prohibitions are never prunable. if t.kind in (TraceKind.NEGATIVE, TraceKind.PROHIBITION): raise PruneRefused( f"trace {trace_id!r} is a {t.kind.value} result; " f"physical deletion would remove a load-bearing prohibition" ) # Condition 2: no surviving trace may reference a pruned trace. surviving_set = set(surviving) for sid in surviving_set: s = self.traces.get(sid) if s is not None and trace_id in s.references: raise PruneRefused( f"trace {trace_id!r} is referenced by surviving trace " f"{sid!r}; prune would leave a dangling justification" ) # Condition 1: no live reader may be pinned to this buffer version. if self.in_flight_readers > 0: raise PruneRefused( f"cannot prune {trace_id!r}: {self.in_flight_readers} " f"in-flight reader(s) hold a pre-prune reference" ) # Condition 3: emit a tombstone so later readers do not confabulate. del self.traces[trace_id] self.tombstones.append( {"trace_id": trace_id, "reason": reason, "action": "pruned"} ) def apply_model_prune_plan( buf: ContextBuffer, plan: list[tuple[str, str]], surviving: Iterable[str], ) -> None: """Apply a pruner's requested deletions, refusing any that violate the read-after-prune invariant. Callers must treat PruneRefused as a signal to fall back to marking, not as a transient error to retry.""" surviving = list(surviving) for trace_id, reason in plan: try: buf.prune(trace_id, reason, surviving) except PruneRefused: buf.mark_stale(trace_id, reason) ```