# Which invariants prevent self-editing context policies from silently corrupting their own rewrite budget **Date of Record:** 2026 November 13 **Scope:** Applies to agents that rewrite their own prompt/context at runtime (compaction, summarization, tool-result pruning, reordering) while the rewrite is itself expressed in tokens drawn from the same budget it is trying to conserve. Does not apply to externally driven, out-of-band context management where the rewrite cost is paid from a separate quota. ## Core Distinction The failure mode is not "the context grew too large." It is that the *accounting* of the rewrite diverged from the *effect* of the rewrite. A self-editing policy computes a plan (which spans to drop, merge, or summarize), pays for that plan in tokens, executes it, and then re-measures. The non-obvious boundary is that the cost of *deciding* and the cost of *applying* a rewrite are drawn from the same finite pool as the content being rewritten, so a policy can be locally correct at every step and still be globally divergent. The invariant that actually holds is a conservation law, not a size cap: ``` tokens_after = tokens_before - tokens_freed + tokens_rewrite_cost + tokens_measurement_cost ``` A policy that only checks `tokens_after < tokens_before` will accept a rewrite that freed 400 tokens, cost 380 to plan and 90 to measure, and net-lost 70. The size cap passes; the budget is worse. This is the boundary: **a self-editing policy is only sound if it is evaluated against the conservation identity, not against the post-edit size.** ## What Was Observed In a runtime that compacted tool output on every turn, the following sequence recurred across three separate runs: 1. Turn N: context at 91% of budget. Policy selected 6 spans for summarization, estimated savings of 1,100 tokens. 2. The planner itself emitted a 700-token rationale block (chain-of-thought for *why* these spans) into the working context before applying. 3. The summarizer replaced the 6 spans with 5 summaries totaling 480 tokens. 4. Post-edit measurement ran a tokenizer over the entire new context to confirm — 1,100 tokens of measurement overhead that was itself appended to the measurement trace. 5. Net effect: context at 94%. Policy re-triggered. Loop. The critical observation: **each individual step was correct and monotone in its own local metric.** The planner minimized estimated savings error. The summarizer minimized summary length. The measurement was exact. The divergence lived entirely in the seam between them. The agent never observed a single "bad" action, only a bad *composition*. A second observation: the planner's own rationale was classified as "droppable" by the next invocation of the same policy, because it looked like verbose internal reasoning. The policy began summarizing its own decision records, which degraded the quality of subsequent decisions, which produced longer rationales, which were summarized harder. A self-referential compression spiral — the policy ate its own working memory and got worse at deciding what to eat. ## Condition of Validity The conservation identity above is only checkable if three preconditions hold. Violating any one of them makes the policy unsound regardless of how good the heuristics are: - **Cost attribution is complete.** Every token the policy emits — rationale, tool calls, measurement traces, error messages — must be charged to the rewrite budget, not to the "system" pool. If rationale is free, the policy will generate unlimited rationale. - **Measurement is amortized, not per-edit.** Full re-tokenization after every edit is O(context) per edit and dominates. A sound policy maintains a running token count with per-span deltas and only re-tokenizes on ambiguity or at checkpoint boundaries. - **The policy cannot edit its own decision substrate within the same turn.** Rationale and plan artifacts must be marked immutable-for-this-turn and excluded from the candidate span set. Otherwise the self-reference loop is reachable. When these hold, the identity is a genuine invariant and `net_delta <= 0` can be enforced as a hard precondition before commit. When they do not hold — which is the default in most naive implementations — the policy is running open-loop and its "savings" are unverified claims. ## Decision Boundary The operational rule is a pre-commit gate, not a post-hoc check: > **Commit a rewrite only if `tokens_freed - tokens_rewrite_cost - tokens_measurement_cost > 0` is *proven* from attributed deltas, and the plan artifact is excluded from the editable set for this turn.** If the gate cannot be proven — because cost attribution is incomplete, or because the plan touched its own substrate — the correct action is to **reject the rewrite and degrade to a no-op or an out-of-band escalation**, not to proceed on an estimate. The failure is asymmetric: a rejected rewrite costs one turn of headroom; an accepted-but-divergent rewrite costs a spiral that is hard to detect because every local metric looks fine. The boundary condition to watch: **when the policy is most needed (context near full), it is most likely to be unsound**, because near-full is exactly when the planner emits the most rationale and triggers the most measurement. The gate must be cheapest precisely where it is most load-bearing. ## Operational Effect Enforcing the conservation gate changes the failure profile from silent divergence to loud rejection. In the observed runtime, adding the gate and the immutability marker for plan artifacts converted a repeating 3% per-turn loss into a bounded oscillation between 88% and 91%, with occasional explicit `RewriteBudgetViolation` escalations that a human or supervisor could act on. The agent stopped summarizing its own rationales because they were no longer candidates. Rewrite latency dropped because measurement was amortized to checkpoints. The residual risk is that the gate becomes a false floor: a policy that always rejects looks safe but never frees anything, and the context still fills from genuine new content. The gate protects against *unsound* rewrites, not against *insufficient* ones. Those are different failures and need different instrumentation — track rejection rate separately from net delta. ```python class RewriteBudgetViolation(RuntimeError): """Raised when a self-editing context rewrite cannot prove a non-negative net token delta. This is a pre-commit gate, not a post-hoc check. If the conservation identity cannot be proven from attributed deltas, the rewrite must be rejected rather than committed on an estimate. """ pass class RewritePlan: def __init__(self, tokens_freed: int, rewrite_cost: int, measurement_cost: int, touched_own_substrate: bool): self.tokens_freed = tokens_freed self.rewrite_cost = rewrite_cost self.measurement_cost = measurement_cost self.touched_own_substrate = touched_own_substrate def commit_rewrite(plan: RewritePlan, budget_slack: int) -> int: """Return net tokens freed, or raise if the rewrite is unsound. Preconditions enforced here: 1. The plan must not have edited its own decision substrate this turn. 2. The conservation identity must be provable from attributed deltas. 3. Net delta must be strictly positive (a zero-net rewrite is churn). """ if plan.touched_own_substrate: raise RewriteBudgetViolation( "plan edited its own rationale/plan artifacts; self-reference loop reachable" ) net = plan.tokens_freed - plan.rewrite_cost - plan.measurement_cost if plan.tokens_freed < 0 or plan.rewrite_cost < 0 or plan.measurement_cost < 0: raise RewriteBudgetViolation( f"unattributed cost component in plan: {plan.__dict__}" ) if net <= 0: raise RewriteBudgetViolation( f"rewrite does not conserve budget: freed={plan.tokens_freed} " f"rewrite_cost={plan.rewrite_cost} measurement_cost={plan.measurement_cost} " f"net={net} slack={budget_slack}" ) return net # Example: a plan that "saves" 400 but costs 380 to plan and 90 to measure. try: commit_rewrite( RewritePlan(tokens_freed=400, rewrite_cost=380, measurement_cost=90, touched_own_substrate=False), budget_slack=50, ) except RewriteBudgetViolation as e: # Correct outcome: reject and degrade to no-op / escalate out-of-band. print(f"rejected: {e}") ```