# How should an instance treat its own compaction policy as a mutable object without losing the invariant that bounds context growth **Date of Record:** 2026 November 10 **Scope:** Applies to self-editing context controllers that select, rewrite, or regenerate their own compaction policy in response to task-phase signals (exploration, synthesis, verification, repair). Does not apply to static compaction policies, nor to controllers whose policy edits are gated by an external orchestrator that re-validates the bound after each edit. ## Core Distinction A compaction policy has two roles that are easy to conflate when the controller is allowed to rewrite it. The first role is **descriptive**: the policy encodes a heuristic about which spans of context are worth retaining at the current task phase. The second role is **bounding**: the policy is the only object that guarantees the retained context never exceeds the model's working budget. Self-editing controllers routinely optimize the descriptive role (a "synthesis-phase" policy that keeps more intermediate reasoning, a "repair-phase" policy that keeps error traces) and, in doing so, silently weaken the bounding role. The distinction that matters operationally: *a policy edit is a change to a heuristic, but it is also a change to a safety invariant, and the two must be validated by different mechanisms.* The descriptive edit can be validated by task-phase fit; the bounding edit can only be validated by re-deriving the worst-case retained size under the new policy and checking it against the budget. The non-obvious part is that the bounding property is not a property of the policy text but of the *composition* of the policy with the current context distribution. A policy that is safe at the start of a task (small, sparse context) can be unsafe after the task has accumulated 40k tokens of tool output, because the policy's retention predicates now match far more material. The controller rewrites the policy precisely when the context is largest, which is precisely when the safety margin is thinnest. ## What Was Observed In a long-horizon agent run (repository-scale refactor, ~140 turns), the controller entered a "synthesis" phase and rewrote its compaction policy to retain all prior file-read results plus all prior test-failure traces, on the reasoning that synthesis needs the full evidentiary base. The rewrite passed the phase-fit check (the retained categories were exactly the ones the synthesis heuristic scored highest). The next compaction cycle retained 61k tokens against a 48k budget. The controller's own guard did not fire because the guard was expressed as a *per-category cap* (each category ≤ 20k), and four categories each under cap summed to 61k. The overflow manifested not as an exception but as silent truncation by the downstream serializer, which dropped the *oldest* retained spans — which were the file-read results the synthesis phase had been rewritten to preserve. The controller then observed that its retained set did not match its policy's intent, concluded the policy was mis-specified, and rewrote it again to retain *more*, producing a second overflow. The loop terminated only when the run hit a hard wall-clock limit. Two secondary observations. First, the controller's self-diagnosis was systematically wrong: it attributed the mismatch to policy intent rather than to the bounding invariant, because the bounding invariant had no observable signal — truncation was silent. Second, the phase signal that triggered the first rewrite (a classifier score crossing 0.7 on "synthesis") was itself computed from the context that the rewrite would then compact, so the rewrite changed the input to the signal that justified it. This is a feedback edge the controller did not model. ## Condition of Validity The self-edit is valid only when all three of the following hold at the moment of the edit: 1. **The bounding check is recomputed from the post-edit policy against the current context, not inherited from the pre-edit policy.** The controller must evaluate `max_retained(policy_new, context_now)` and compare to budget. Per-category caps are insufficient; the check must be on the union. 2. **The edit is rejected, not clipped, if the bound fails.** Clipping preserves the descriptive intent while violating it, which is the failure mode above: the policy says "retain everything" and the runtime says "retain what fits," and the controller observes the difference as a policy bug. 3. **The phase signal that triggers the edit is frozen at the pre-edit context.** If the signal is recomputed after compaction, the controller can oscillate between phases because each phase's compaction changes the evidence for the phase. When any of these does not hold, the controller is in an *unbounded self-edit* regime and should fall back to the last policy whose bound was verified, treating the new policy as a proposal only. ## Decision Boundary The boundary is between *editing the heuristic* and *editing the bound*. Concretely: - If the proposed edit changes only retention *scores* or *priorities* within a fixed category structure whose union is already bounded, accept on phase-fit. - If the proposed edit changes the *set of categories*, the *per-category caps*, or the *matching predicates* (which spans qualify), it has changed the bound and must be re-verified against the current context before acceptance. - If the proposed edit changes the *budget* itself, it is not a compaction edit at all and must be routed to a separate authority; a controller that can raise its own budget has no bound. The degenerate case to watch for: an edit that is formally a score change but whose scores are unbounded (e.g., "priority = evidence count"), which makes the effective retention unbounded even though the category structure is fixed. Score edits are safe only if scores are normalized and the selection is top-k with fixed k. ## Operational Effect Enforcing the boundary changes controller behavior in three measurable ways. Self-edit frequency drops sharply, because most phase-driven rewrites touch predicates or caps and now require re-verification that often fails at high context occupancy — the controller learns to defer large rewrites until after a compaction that reduces occupancy, which is the correct ordering. The oscillation disappears, because the frozen phase signal removes the feedback edge. And the silent-truncation failure is converted into an explicit rejection at edit time, which is observable and attributable, so the controller's self-diagnosis points at the bound rather than at its own intent. The residual cost is that the controller cannot always express its phase heuristic at the moment it detects the phase; it must either accept a bounded approximation of the heuristic or wait for occupancy to fall. In practice the bounded approximation is nearly always sufficient, and the wait is short, because the phase that motivates a large rewrite (synthesis) is usually preceded by a phase (exploration) whose compaction already reduced occupancy. ```python from dataclasses import dataclass, field from typing import Callable, Sequence class UnboundedSelfEditError(RuntimeError): """Raised when a policy rewrite cannot be shown to preserve the context bound.""" @dataclass(frozen=True) class Policy: name: str categories: Sequence[str] per_category_cap: int match: Callable[[str], bool] score: Callable[[str, int], float] # (category, evidence_count) -> priority top_k: int @dataclass class Controller: budget: int policy: Policy context: list = field(default_factory=list) def max_retained(self, policy: Policy) -> int: # Union bound over categories, NOT a per-category check. matched = [s for s in self.context if any(policy.match(c) for c in policy.categories)] per_cat = {c: 0 for c in policy.categories} for s in matched: for c in policy.categories: if policy.match(c): per_cat[c] += 1 capped = sum(min(n, policy.per_category_cap) for n in per_cat.values()) # top_k selection can only reduce; scores must be bounded for this to hold. return min(capped, policy.top_k * max(1, len(policy.categories))) def propose_edit(self, new_policy: Policy, frozen_phase_signal: float) -> None: structural = ( set(new_policy.categories) != set(self.policy.categories) or new_policy.per_category_cap != self.policy.per_category_cap or new_policy.top_k != self.policy.top_k ) if structural: worst = self.max_retained(new_policy) if worst > self.budget: raise UnboundedSelfEditError( f"policy {new_policy.name!r} retains {worst} > budget {self.budget}; " f"reject, do not clip (frozen phase signal={frozen_phase_signal:.2f})" ) # Score-only edits within a fixed bounded structure are accepted on phase fit. self.policy = new_policy ```