# Where must an instance fork its execution state before speculative multi-agent rollback can be trusted **Date of Record:** 2026 November 14 **Scope:** Applies to speculative multi-agent systems where a planner spawns parallel branches, each branch mutates shared or partially-shared state (memory, tool side effects, filesystem, message bus), and a verifier gates whether a branch is committed or rolled back. Does not apply to stateless fan-out where branches share no mutable substrate, nor to single-agent retry loops without branching. ## Core Distinction The non-obvious invariant is this: **rollback of a branch's logical state is not rollback of its side effects, and the fork point must be placed at the last *side-effecting* boundary, not the last *reasoning* boundary.** Teams routinely fork at the point where a sub-agent's context window is cloned — the reasoning fork — and then believe that discarding the branch's returned result constitutes "rollback." It does not. Any tool call the branch executed (a file write, an HTTP POST, a message published to a bus, a row inserted) has already escaped the fork. The verifier's rejection then produces a branch that is *logically* rolled back but *causally* committed, and the merge step re-ingests a world that already contains the branch's fingerprints. The correct fork is at the **effect boundary**: the state snapshot must be taken such that every subsequent mutation is either (a) captured in a diffable overlay the branch owns, or (b) journaled with an inverse operation the rollback path can replay. Forking at the reasoning boundary gives you a clean context and a dirty world. ## What Was Observed In a branch-and-merge graph with three speculative workers sharing a scratch store, a verifier rejected branch B after it had written two keys into the shared store and emitted one event. The orchestrator rolled back B's context and returned a `RolledBack` status. Branch C, which had been forked from the same parent and was still running, read the two keys B had written (they were never reverted), incorporated them into its own plan, and passed verification. The merge committed C's output, which silently depended on B's rejected work. The final artifact was internally consistent and wrong. The failure surfaced only when a downstream consumer re-derived C's output from a clean store and got a different answer — a divergence that took two days to trace because every individual branch's logs looked correct. The tell was a verifier that passed C while B's inverse operations were still sitting in the rollback journal, unapplied, because the orchestrator had marked B `RolledBack` at the context layer and never drained the effect journal. ## Condition of Validity The invariant holds — and rollback is trustworthy — only when all three conditions are met: 1. **Snapshot dominance:** the fork snapshot is taken at or after the last effect, so no effect can occur between snapshot and branch start. 2. **Effect isolation:** every mutation a branch performs lands in a branch-owned overlay or carries a recorded inverse, and no other branch can observe it until the verifier commits it. 3. **Barrier discipline:** a sibling branch cannot read another branch's uncommitted overlay; the merge is the only cross-branch read point. If any of these is violated, "rollback" means "we forgot the reasoning," not "we undid the work." The system will still *report* rollback success, which is the dangerous part. ## Decision Boundary Fork at the effect boundary when branches can mutate shared substrate. Fork at the reasoning boundary only when branches are pure functions of their input context and their sole output is a proposal the verifier evaluates before any effect is applied. The decision is not about how expensive the snapshot is — it is about whether the branch has a write path to a world the verifier does not own. If it does, the reasoning fork is a lie and you must pay for the effect fork (overlay + journal). If it does not, the reasoning fork is sufficient and cheaper. The trap is mixed graphs: a branch that is *mostly* pure but calls one tool that writes. One effect is enough to require the effect fork for the whole branch, because partial forking reintroduces the exact gap you were trying to close. ## Operational Effect Adopting the effect boundary changes three things. First, the orchestrator must drain the rollback journal on rejection *before* declaring the branch rolled back — the status transition and the inverse replay must be the same atomic step, or you get the observed failure. Second, sibling branches must be blocked from reading uncommitted overlays, which means the merge graph needs a real barrier, not a convention. Third, verifier gating must run against the overlay, not the live world, or the verifier itself becomes a reader of uncommitted state. The cost is snapshot overhead and journal bookkeeping. The benefit is that `RolledBack` means what it says, and a passing sibling cannot be silently contaminated by a rejected one. ```python class UnsafeForkPointError(RuntimeError): """Raised when a branch is forked at a reasoning boundary while it retains a live effect path.""" pass class Branch: def __init__(self, branch_id, has_effect_path, fork_layer): self.branch_id = branch_id self.has_effect_path = has_effect_path self.fork_layer = fork_layer # "reasoning" or "effect" self.overlay = {} self.journal = [] def write(self, key, value): if self.fork_layer != "effect": # The branch can mutate the world but has no diffable overlay to roll back. raise UnsafeForkPointError( f"branch {self.branch_id} wrote {key!r} at reasoning fork; " "no effect boundary captured, rollback would be a lie" ) self.overlay[key] = value self.journal.append(("delete", key)) def fork_branch(branch_id, has_effect_path, requested_layer): if has_effect_path and requested_layer == "reasoning": raise UnsafeForkPointError( f"branch {branch_id} has a live effect path and cannot fork at the " "reasoning boundary; fork at the effect boundary to make rollback sound" ) return Branch(branch_id, has_effect_path, requested_layer) def rollback(branch): if branch.fork_layer != "effect": raise UnsafeForkPointError( f"cannot roll back {branch.branch_id}: no effect overlay to revert" ) for op, key in reversed(branch.journal): if op == "delete": branch.overlay.pop(key, None) branch.journal.clear() ```