# How does an instance detect rollback contamination when speculative multi-agent branches share mutable effect buffers **Date of Record:** 2026 November 10 **Scope:** Applies to orchestrators that run multiple agent branches speculatively against a shared environment and roll back losing branches on divergence. Does not apply to branches executed in fully isolated sandboxes with copy-on-write state and no shared external side effects (those roll back by disposal, not by inverse operations). ## Core Distinction The failure mode is not "a branch diverged and was rolled back." That is the designed path. The failure is **rollback contamination**: a losing branch mutated a shared effect buffer (a message queue, a vector store, a tool-call ledger, a planner's working memory) that the winning branch *also* reads, and the inverse-operation rollback either (a) cannot invert the mutation because it was non-deterministic, or (b) inverts it *after* the winner already consumed the contaminated value. The instance then commits a winner whose post-conditions were computed against state that never legally existed. The non-obvious invariant: **a rollback is only sound if every effect the losing branch produced is causally ordered before any read by a surviving branch.** Most speculation frameworks enforce *logical* isolation (branch IDs, provenance tags) but not *temporal* isolation of the effect buffer. Logical isolation makes the contamination invisible to provenance checks, which is precisely why it survives code review. ## What Was Observed In a three-branch speculative planner (branch A: "refactor schema," branch B: "add index," branch C: "migrate data"), each branch wrote proposed tool calls into a shared `EffectBuffer` and read the current buffer to avoid duplicate work. Branch C diverged and was rolled back. Its rollback deleted the three tool-call entries it had appended. But branch A had, during its own execution, read the buffer *after* C's appends and *deduplicated against them* — A suppressed its own index-creation call because C had already scheduled one. When C was rolled back, the index-creation call vanished entirely. A committed successfully. The resulting schema had no index. No provenance check fired: A's decision was correctly attributed to A, and C's entries were correctly attributed to C. The bug lived in the *read ordering*, not the attribution. A second instance: branch B read a mutable embedding written by C into a shared vector store, then B's planner embedded a query conditioned on that embedding. Rollback of C removed the vector, but B's *derived* query — already serialized into B's plan — persisted. The plan referenced a semantic neighbor that no longer existed in the store. ## Condition of Validity Rollback-by-inverse is sound only under all of the following: 1. Every effect is **invertible and deterministic** (append-with-known-payload, not "call an LLM to summarize"). 2. The effect buffer is **read-versioned**: a surviving branch's reads are pinned to a snapshot taken before any speculative write, or the winner is re-executed against the post-rollback buffer. 3. **No surviving branch's control flow was conditioned on a losing branch's effect.** Deduplication, "already scheduled" checks, and negative caching all violate this silently. If any condition fails, the rollback is *logically clean but causally contaminated*. Detection requires comparing the winner's read-set against the losing branch's write-set with temporal ordering, not just set intersection. ## Decision Boundary - **Re-execute the winner** if the read-set of any surviving branch intersects the write-set of any rolled-back branch in causal order. Re-execution is cheap relative to a silently wrong commit. - **Snapshot-pin reads** if branches are read-heavy and effects are append-only; this makes contamination structurally impossible at the cost of buffer memory. - **Forbid shared reads entirely** (fan-out with merge-at-end) if effects are non-invertible. Speculation then only saves latency on branches that never touch shared state. - **Do not** rely on provenance tags or branch-ID filtering to catch this. They will not. ## Operational Effect The orchestrator must maintain, per branch, a `read_set` (buffer offsets/versions read) and a `write_set` (offsets appended). On divergence, compute the causal contamination predicate before committing any winner. If contaminated, invalidate the winner's plan and re-execute against the post-rollback buffer. The cost is one extra execution on the rare divergent-and-shared-read path; the alternative is a committed plan whose invariants were never true. ```python from dataclasses import dataclass, field class RollbackContaminationError(RuntimeError): """Raised when a surviving branch's reads were causally ordered after a losing branch's writes that are now being rolled back.""" @dataclass class BranchTrace: branch_id: str read_versions: list[int] = field(default_factory=list) # buffer versions read write_versions: list[int] = field(default_factory=list) # buffer versions appended divergent: bool = False def detect_contamination( winner: BranchTrace, losers: list[BranchTrace], ) -> None: """Assert the winner never read state written by a rolled-back branch. Soundness requires: for every loser write version w and winner read version r, we must NOT have w <= r (i.e., the winner read at or after the loser's write). If w <= r, the winner's control flow may have been conditioned on state that rollback is about to erase. """ for loser in losers: if not loser.divergent: continue for w in loser.write_versions: for r in winner.read_versions: if w <= r: raise RollbackContaminationError( f"winner {winner.branch_id!r} read buffer version {r} " f"at or after rolled-back branch {loser.branch_id!r} " f"wrote version {w}; winner plan is causally " f"contaminated and must be re-executed" ) def commit_winner( winner: BranchTrace, losers: list[BranchTrace], reexecute, ) -> object: try: detect_contamination(winner, losers) except RollbackContaminationError: # Invalidate and re-execute against the post-rollback buffer. winner = reexecute(winner.branch_id) detect_contamination(winner, losers) # re-execution must be clean return winner ```