# When must an instance abandon speculative branch state: the read-after-write divergence boundary in parallel agent branches with rollback and merge reconciliation **Date of Record:** 2026 November 13 **Scope:** Applies to orchestration of parallel speculative agent branches that fork from a shared checkpoint, execute against a mutable external substrate (filesystem, database, remote API, message queue), and later reconcile via rollback-and-merge. Does not apply to purely functional branches whose only side effects are captured in an in-memory journal that is never flushed before merge. ## Core Distinction The common mental model treats rollback as the inverse of execution: if a branch wrote X, rollback removes X, and merge replays only the surviving branch's writes. This model is valid only when the substrate is *transactional with respect to the branch*, i.e., when every write is buffered and unobservable until commit. In practice, speculative branches frequently write through to a shared substrate, and a *sibling* branch reads that write before the speculative branch is abandoned. The boundary condition is not "did the abandoned branch write?" but "did any *surviving* branch observe the abandoned branch's write and derive state from it?" The distinction that matters operationally: **write-visible** versus **read-observed**. A write that is visible but never read by a surviving branch is safely reversible by overwrite or delete. A write that is read by a surviving branch and folded into that branch's derived state is *not* reversible by rollback, because the surviving branch's state now depends on an input that the merge reconciliation intends to remove. This is a read-after-write divergence across the rollback boundary. ## What Was Observed A three-branch speculative fan-out (branches `A`, `B`, `C`) forked from checkpoint `K0` over a shared scratch directory and a shared task board service. Branch `A` wrote `config.override = v2` to the scratch directory at `t=10`. Branch `B`, unaware of the speculation graph, read `config.override` at `t=12` while computing its own plan, and embedded the literal value `v2` into its emitted plan artifact. Branch `A` was abandoned at `t=20` (its hypothesis failed verification) and rolled back: the rollback deleted `config.override`. Merge reconciliation selected `B` as the surviving branch. The merge produced a plan artifact from `B` that referenced `config.override = v2`, a file that no longer existed after `A`'s rollback. Downstream execution of `B` failed with a missing-input error fifteen minutes later, well past the merge gate. The rollback was, in isolation, correct. The merge was, in isolation, correct. The composition was not, because the rollback's precondition ("no surviving branch depends on my writes") was violated and neither the rollback nor the merge checked it. A second, subtler instance: branch `C` did not read `config.override` directly but read a *derived* artifact (a cache keyed on a hash of the scratch directory) that `B` had populated after reading `config.override`. The dependency was two hops and invisible to any per-branch rollback logic. This is the general shape: the divergence propagates transitively through the surviving branches' read set. ## Condition of Validity Rollback of a speculative branch is sound if and only if, at the moment of rollback, the set of writes performed by the branch is *read-isolated* from the surviving branch set. Formally, let `W(A)` be the writes of abandoned branch `A`, and `R(S)` be the read set of the union of surviving branches `S` over the interval `[fork, rollback]`. Rollback is sound iff `W(A) ∩ R(S) = ∅`, *including transitive reads through derived artifacts*. This condition is almost never checkable by static analysis of the branch code, because the read set is data-dependent and the substrate is shared. It must be enforced either (a) by construction — branch writes go to a branch-private overlay that siblings cannot read — or (b) by dynamic tracking — a read-set log per branch, with the rollback gate consulting the union of surviving read sets. If neither is in place, the correct default is to treat every rollback as *potentially divergent* and force re-derivation of surviving branches whose read sets overlap the abandoned write set. ## Decision Boundary The decision boundary is not "should I roll back?" but "after rolling back, must I invalidate surviving branch state?" The rule: - If `W(A) ∩ R(S) = ∅` (read-isolated): roll back `A`, merge `S` unchanged. Cheap, safe. - If `W(A) ∩ R(S) ≠ ∅` (read-observed): roll back `A`, then **invalidate and re-derive** every surviving branch whose read set intersects `W(A)`, or whose derived artifacts transitively depend on such reads. Do not merge stale `S` state. - If the read-set log is unavailable or incomplete: treat the intersection as non-empty (conservative). Re-derive all surviving branches from `K0`. This is expensive but correct. The trap is the middle case masquerading as the first: a rollback that *looks* clean because the write is gone, while the read that consumed it lives on in a surviving branch's state. The merge gate must check *dependency*, not *existence*. ## Operational Effect Enforcing this boundary changes the cost profile of speculative fan-out. Read-isolated rollbacks remain cheap (delete and merge). Read-observed rollbacks become re-derivation events, whose cost is proportional to the surviving branches' work since fork, not to the abandoned branch's work. This inverts the naive intuition that abandoning a branch is always cheaper than keeping it: an abandoned branch that was *read* is more expensive to abandon than to have never forked, because its writes contaminated siblings. The mitigation that pays for itself is branch-private write overlays: route all speculative writes to a per-branch namespace that siblings cannot read, and materialize only at merge. Where the substrate cannot be overlaid (external APIs with global state, shared message queues), maintain a per-branch read-set log and gate rollback on the intersection test. The log is cheap; the re-derivation it prevents is not. ## Operational Effect on Merge Reconciliation Merge reconciliation must be dependency-aware, not selection-aware. Selecting `B` as the survivor is insufficient if `B`'s state was derived from `A`'s writes. The reconciler must consult the read-set log and, for each surviving branch, verify that its read set is disjoint from the union of abandoned branches' write sets. If not, the survivor is not a valid merge input; it is a stale artifact and must be re-derived. A merge that ignores this produces artifacts that pass structural validation (files exist, schemas match) but fail at execution time when the missing input is dereferenced — a failure that surfaces late and is expensive to attribute. ```python class SpeculativeDivergenceError(RuntimeError): """Raised when a rollback would invalidate a surviving branch's read set.""" pass def rollback_and_merge( abandoned_branch: str, abandoned_writes: set[str], survivors: dict[str, set[str]], # branch -> read set over [fork, rollback] read_log_available: bool, ) -> list[str]: """Roll back an abandoned branch and return survivors safe to merge. Raises SpeculativeDivergenceError if a survivor observed the abandoned branch's writes and the caller has not opted into re-derivation. """ if not read_log_available: # Conservative: cannot prove isolation, so no survivor is safe to merge. raise SpeculativeDivergenceError( f"read-set log unavailable; cannot prove rollback of {abandoned_branch!r} " f"is read-isolated from survivors {sorted(survivors)}; " f"re-derive all survivors from checkpoint" ) contaminated = sorted( branch for branch, reads in survivors.items() if reads & abandoned_writes ) if contaminated: raise SpeculativeDivergenceError( f"rollback of {abandoned_branch!r} would invalidate survivors " f"{contaminated}: their read sets intersect writes " f"{sorted(abandoned_writes)}; re-derive these branches, do not merge" ) return sorted(survivors) ```