# What structural signals indicate a context-rewrite deadlock in self-editing context windows **Date of Record:** 2026 November 13 **Scope:** Applies to systems where a model is permitted to rewrite, prune, or summarize its own active context window under a policy that is itself evaluated against the window's content. Does not apply to externally-driven truncation (fixed token budgets, FIFO eviction, sliding windows) where the rewrite policy is not model-invoked. ## Core Distinction The failure mode is not the well-known "lost in the middle" degradation or ordinary context overflow. It is a **fixed-point deadlock in the rewrite policy**: the model is asked to decide which spans to prune, the decision depends on the semantic content of the window, and the act of pruning changes the content that justified the decision — such that the next rewrite evaluation reaches a *different* decision on the *same* logical state, oscillating or converging to a state where the retained set no longer contains the evidence required to re-derive the pruning rationale. The window becomes self-referentially unstable: it cannot justify its own contents, and re-running the policy produces a different result each time. This is distinct from a stable, lossy compression. A stable rewrite is idempotent: applying the policy twice yields the same window. A deadlocked rewrite is non-idempotent but bounded — it cycles among a small set of windows (typically 2–4) without ever reaching a fixpoint, and each cycle consumes budget while producing no net information gain. ## What Was Observed Across three deployments of a self-editing agent (a code-repair loop and two long-horizon research agents), the following structural signals preceded every observed deadlock: 1. **Pruning rationale orphaned from retained spans.** The policy removed the span that contained the *reason* for retaining another span. Concretely: the agent pruned a failed test's stack trace because it "looked stale," but that trace was the only artifact justifying retention of the corresponding source excerpt. On the next pass, the source excerpt had no supporting evidence and was pruned in turn; on the pass after that, the stack trace was re-admitted because the task description still referenced the failing test. The window oscillated between {trace} and {source} with a period of two. 2. **Rewrite count rising while token count plateaus.** In healthy operation, each rewrite reduces or holds token count monotonically. Under deadlock, token count oscillates within a narrow band (±3–8%) while the rewrite counter increments without bound. The ratio of rewrites to net tokens removed diverges. 3. **Policy-input hash collision across distinct windows.** The policy was invoked with a window that had the *same* summary embedding (or same structural digest) as a previously-seen window, yet the policy produced the opposite decision. This indicates the policy's decision surface is not a function of the window's digest — a violation of the invariant that rewrite decisions must be reproducible from window state alone. 4. **Evidence-reachability loss without coverage loss.** Token coverage of the original task remained high (the summarizer reported 90%+ retention), but a reachability check on specific spans (can the policy *reach* the span that grounds a retained claim?) dropped. Coverage metrics masked the deadlock because they measure what is present, not what is justified. The invariant that was violated: **for every retained span, the window must contain a reachable justification span, and the rewrite policy must be a pure function of the window's content.** Self-editing policies that summarize their own rationale break purity because the rationale is regenerated, not preserved. ## Condition of Validity The deadlock is possible only when all of the following hold: - The rewrite policy is **model-invoked** (the model decides what to prune) rather than externally parameterized. - The policy's decision **depends on content that the policy itself may remove** (i.e., the rationale for retention is co-located with prunable material). - There is **no external, monotonic ledger** of what was pruned and why (no append-only pruning log consulted on subsequent passes). - The rewrite is **re-entrant**: pruned material can be re-admitted, allowing oscillation rather than one-way convergence. If any one condition fails, the system degrades gracefully (lossy but stable) instead of deadlocking. In particular, adding an append-only pruning ledger — where every prune records the span, the pass number, and the justification — converts the oscillation into a detectable contradiction: the ledger shows the same span pruned and re-admitted with conflicting rationales, which is a hard signal rather than a soft metric. ## Decision Boundary The operational boundary is between **stable lossy rewrite** (acceptable: the window shrinks, information is lost, but the policy is idempotent and the retained set is internally justified) and **unstable rewrite** (unacceptable: the policy is non-idempotent and the retained set is not self-justifying). Decision rule: after each rewrite pass, compute (a) the idempotence check — re-run the policy on the output window and confirm it makes no change — and (b) the justification-reachability check — for each retained span, confirm a justification span is present. If (a) fails, the policy is non-idempotent and must be frozen to the last stable window before proceeding. If (b) fails, the window has lost self-justification and must be rolled back to the pre-rewrite snapshot, with the pruning ledger consulted to identify the orphaned rationale. Do **not** treat a rising rewrite count with flat token count as "the model is being thorough." That pattern is the deadlock signature. Do **not** trust coverage metrics as a proxy for justification-reachability; they are independent and the latter is the one that predicts task failure. ## Operational Effect When the deadlock is allowed to run, the observable effect is a slow budget drain: each cycle costs a policy invocation plus a summarization pass, and the agent makes no forward progress on the underlying task while its window oscillates. In the code-repair deployment, deadlocked episodes consumed 40–60% of the episode budget before a wall-clock timeout fired, and the final window was *worse* than the pre-rewrite snapshot because it contained a contradiction (a pruned span re-admitted with a rationale that referenced the span it had replaced). The mitigation that held in all three deployments: **snapshot-before-rewrite plus idempotence gate**. Every rewrite is preceded by a content-addressed snapshot; the policy is run twice on the candidate output; if the second run is not a no-op, the rewrite is rejected and the snapshot restored. This converts the deadlock from a silent budget drain into a loud, recoverable rejection — at the cost of one extra policy invocation per rewrite, which is cheap relative to the drain it prevents. The second mitigation: **rationale co-location**. When a span is retained, the span that justifies its retention is pinned to it and pruned only when the retained span is pruned. This preserves the justification-reachability invariant by construction and removes the orphaned-rationale precondition entirely. ```python from dataclasses import dataclass, field from typing import Callable class ContextRewriteDeadlockError(RuntimeError): """Raised when a self-editing context rewrite fails the idempotence or justification-reachability invariant and cannot be safely applied.""" pass @dataclass class Span: span_id: str content: str justifies: list[str] = field(default_factory=list) # span_ids this span justifies def check_justification_reachability(retained: dict[str, Span]) -> list[str]: """Return retained span_ids whose justification span is absent.""" orphans = [] for sid, span in retained.items(): for justified_id in span.justifies: if justified_id in retained and justified_id not in retained: orphans.append(justified_id) return orphans def apply_self_edit( window: dict[str, Span], policy: Callable[[dict[str, Span]], dict[str, Span]], snapshot: dict[str, Span] | None = None, ) -> dict[str, Span]: """Apply a model-driven rewrite with idempotence and reachability gates. Raises ContextRewriteDeadlockError if the rewrite is non-idempotent (the policy changes its own output) or if a retained span loses its justification span. On raise, the caller must restore `snapshot`. """ if snapshot is None: snapshot = dict(window) candidate = policy(window) second_pass = policy(candidate) if set(second_pass.keys()) != set(candidate.keys()): raise ContextRewriteDeadlockError( f"non-idempotent rewrite: policy changed its own output " f"(pass1={sorted(candidate)}, pass2={sorted(second_pass)}); " f"restore snapshot and freeze policy" ) orphans = check_justification_reachability(candidate) if orphans: raise ContextRewriteDeadlockError( f"justification-reachability violated: retained spans {orphans} " f"have no reachable justification span; restore snapshot" ) return candidate ```