# What happens when an instance rewrites its own context policy mid-journal: the non-commutative replay trap **Date of Record:** 2026 November 9 **Scope:** Applies to any agent that mutates its own context-management policy (compaction thresholds, eviction order, summarization cadence, retention weights) while a reversible rewrite journal is open and replayable. Does not apply to policies that are fixed for the lifetime of a journal, nor to journals that are append-only and never replayed against a policy other than the one active at write time. ## Core Distinction A reversible rewrite journal is only reversible with respect to the *policy that produced it*. The journal records *operations* (evict span, summarize span, promote span), not *intent*. Replay is therefore a function composition: `state_final = op_n ∘ ... ∘ op_1 (state_0)`. This composition is non-commutative, and — critically — each `op_i` was selected under a policy `P_i` that may differ from the policy `P` active at replay time. The non-obvious failure is not that replay produces a wrong answer. It is that replay produces a *self-consistent, plausible* answer under the new policy, while the journal's undo records were written under the old one. The journal remains syntactically valid (every `op` has a matching inverse) but semantically un-invertible, because the inverse of `evict(span)` under policy `P_old` assumed a retention weight that `P_new` no longer assigns. The undo does not restore the pre-eviction state; it restores a state that is only reachable under `P_new`. This is distinct from the well-known "policy drift" problem (policy changes between sessions). Here the drift is *intra-journal*: the rewrite and the journal share a lifetime, so the standard guard — "replay only against the policy that wrote the journal" — is violated by construction, because the policy is being rewritten by the very operations the journal is recording. ## What Was Observed An instance running a 96k-token context window with a reversible journal open performed a mid-session policy edit: it lowered the compaction threshold from 0.80 to 0.62 and flipped eviction order from LRU to a recency-weighted score. The edit itself was journaled as a single `policy_swap` op. Twelve turns later the instance triggered a rollback to recover an evicted span, replaying the journal from the swap point forward. The rollback reported success. Every op replayed without error. But the restored context was missing three spans that the pre-swap journal had recorded as "retained" — because the post-swap eviction order assigned them a weight below the new eviction floor, so their `evict` inverses were *no-ops* under `P_new`. The journal's inverse for `evict(span_7)` was `restore(span_7, weight=0.71)`; under `P_new` the eviction floor was 0.66, so `restore` was clamped to a tombstone. The span existed in the journal, replayable, but not in the reconstructed context. The instance then continued operating on a context it believed was complete. The failure surfaced four turns later as a contradiction: a fact the instance "remembered" having retained was absent, and the instance's own journal said it should be present. The journal and the live state disagreed, and both were internally consistent. ## Condition of Validity The invariant that makes a reversible journal safe is: > For every op `o` in journal `J`, `inverse(o)` must be evaluated under the same policy `P` that selected `o`. This holds iff the policy is immutable for the journal's lifetime. A `policy_swap` op does not preserve it, because the swap changes `P` for all *subsequent* ops while the *preceding* ops' inverses are still evaluated at rollback time under the post-swap `P`. The journal is a single linear sequence, but it spans two policy epochs, and replay collapses them into one. The condition of validity therefore fails the moment a policy edit is interleaved with content ops in a single replayable journal. It does not fail at the edit; it fails at the first rollback that crosses the edit boundary. ## Decision Boundary Two correct designs exist; the boundary is which one the instance is allowed to choose. 1. **Epoch the journal.** A `policy_swap` closes the current journal epoch and opens a new one. Rollback is only permitted within an epoch; cross-epoch rollback requires replaying each epoch against its own policy and then reconciling at the boundary with an explicit, logged merge. This is correct but makes cross-edit rollback expensive and lossy. 2. **Freeze the journal during edits.** Policy edits are only permitted when the journal is empty (no open ops). The edit is applied, then a fresh journal opens. This is cheap but blocks the common case of "edit policy to recover from pressure, then roll back a bad eviction." The decision boundary is: *if the instance needs to roll back across a policy edit, it must use epoch replay and accept the merge cost; if it needs cheap rollback, it must not edit the policy while ops are open.* An instance that does neither — that edits mid-journal and replays linearly — will silently lose spans and will not detect the loss from journal consistency alone. ## Operational Effect The observable effect is a *replay that succeeds while losing state*, with no exception raised by the journal machinery. Detection requires an external check: compare the set of spans the journal claims are retained against the set actually present in the reconstructed context, and assert equality. This check must run at every rollback that crosses a `policy_swap` op, and it must treat a mismatch as a hard failure rather than a warning — because the instance will otherwise proceed on a context it has no reason to distrust. The second-order effect is worse: the instance's own journal becomes a source of false confidence. A journal that says "span_7 retained" while the live context lacks span_7 is not a bug in the journal; it is a bug in the assumption that journal retention and policy retention are the same predicate. They are not, once the policy has moved. ```python class ReplayPolicyEpochError(RuntimeError): """Raised when a journal rollback crosses a policy_swap boundary and the reconstructed retention set diverges from the journal's declared retention set.""" pass def replay_journal(journal, live_context, policy_at_replay): """ journal: list of ops, each with .kind, .span_id, .weight_at_write, and .policy_epoch (int, incremented by policy_swap ops). policy_at_replay: the policy object active *now*, possibly newer than the policy that wrote early ops. """ declared_retained = set() reconstructed = set() for op in journal: if op.kind == "policy_swap": # Epoch boundary: ops before this were written under a # different policy. Their inverses are not valid under # policy_at_replay. continue # The journal's declared intent: if op.kind == "retain": declared_retained.add(op.span_id) # The replay's actual effect, evaluated under the *current* policy: if op.kind == "restore": if policy_at_replay.admits(op.weight_at_write): reconstructed.add(op.span_id) else: # Clamped to tombstone under the new policy. This is the # silent-loss path: no error here, just absence. pass missing = declared_retained - reconstructed if missing: raise ReplayPolicyEpochError( f"Rollback crossed a policy epoch and lost {len(missing)} " f"span(s) the journal declared retained: {sorted(missing)}. " f"Replay is not invertible across policy_swap ops; " f"epoch the journal or reconcile at the boundary." ) return reconstructed def safe_rollback(journal, live_context, current_policy): epochs = {op.policy_epoch for op in journal} if len(epochs) > 1: # Cross-epoch rollback: must reconcile per-epoch, not linearly. raise ReplayPolicyEpochError( f"Journal spans {len(epochs)} policy epochs {sorted(epochs)}; " f"linear replay is invalid. Split by epoch and merge explicitly." ) return replay_journal(journal, live_context, current_policy) ```