# Which invariants prevent rollback hazards in speculative multi-agent execution **Date of Record:** 2026 November 14 **Scope:** Applies to systems where two or more agents execute speculative plan branches against shared external state and one branch is rolled back after divergence detection. Does not apply to purely sandboxed execution where no side effects cross the branch boundary, nor to single-agent retry loops. ## Core Distinction Speculative multi-agent execution differs from single-agent speculation in one critical way: the rollback boundary is not the agent, it is the *plan*. When Agent A and Agent B run concurrently on divergent plans, each may commit side effects (writes, messages, resource reservations) before divergence is detected. The naive invariant — "roll back the losing branch" — is unsound because it assumes the losing branch's effects are separable from the winning branch's. They are not, when the two branches share a resource, a message bus, or a monotonic counter. The real invariant is not "undo the loser" but "make every speculative effect *abortable or idempotent* before it becomes observable to the winner." Rollback is only well-defined if the system can prove, at divergence-detection time, that no effect of the losing branch has been *read* by the winning branch or by an external party. This is a causal-consistency condition, not a transactional one. ## What Was Observed In a deployment of a two-agent speculative executor (planner runs two candidate tool-call sequences in parallel, picks the first to satisfy a verifier, cancels the other), we observed a class of failure we term *phantom commit*: 1. Agent A (losing branch) acquires a lock on resource `R` and writes a partial record `R.partial`. 2. Agent B (winning branch) reads `R` to compute its next step, observing `R.partial` as if it were a committed precursor. 3. Divergence is detected (A fails the verifier), A is rolled back — the lock is released, `R.partial` is deleted. 4. B proceeds on its now-stale read of `R.partial` and produces a plan that references data which no longer exists. The verifier never fired because B's plan was internally consistent — it was consistent with a world that had been erased. Rollback succeeded at the level of the losing branch and failed at the level of the *shared observation history*. The bug was invisible in per-agent logs and only appeared as a downstream integrity violation hours later. A second, subtler variant: monotonic counter drift. Both branches incremented a shared `seq` counter during speculation. Rolling back A did not roll back the counter, so B's committed records carried sequence numbers with gaps that downstream consumers interpreted as data loss. ## Condition of Validity Rollback of a speculative branch is valid only when all three of the following hold at divergence-detection time: - **Observation isolation:** No effect of the losing branch has been read by the winning branch or by any external observer. Enforced by per-branch read snapshots or by deferring all cross-branch reads until commit. - **Effect idempotence or compensation:** Every side effect of the losing branch is either idempotent (safe to replay/ignore) or has a registered compensating action that is itself idempotent. Locks, reservations, and monotonic counters are *not* naturally idempotent and require explicit compensation. - **Causal barrier:** Divergence detection happens before the winning branch's commit point, and the commit point is a single linearization event — not a scattered set of writes. If B commits piecemeal while A is still live, no rollback of A is sound. When any of these fails, the correct action is not rollback but *quarantine and reconcile*: freeze both branches, present the divergent state to a resolver, and only then commit or discard. ## Decision Boundary Choose **rollback** when branches are observation-isolated and effects are compensable — typically short speculative windows over partitioned resources. Choose **quarantine-and-reconcile** when branches share any mutable resource, any monotonic sequence, or any external message channel, or when the speculation window exceeds the mean time between cross-branch reads. Choose **serialize** (abandon speculation) when the cost of reconciliation exceeds the latency saved by parallel speculation — this is the common case for plans with more than two shared mutable resources. The boundary is not "how likely is divergence" but "how expensive is a phantom commit." High divergence probability with cheap compensation favors speculation; low divergence probability with expensive compensation favors serialization, because the rare phantom commit dominates expected cost. ## Operational Effect Systems that enforce observation isolation and effect compensation see speculation pay off only on read-heavy or partitionable plans. Systems that skip the causal barrier but keep rollback see phantom commits at a rate proportional to cross-branch read frequency — often below 1% per run but catastrophic in aggregate because they manifest as silent data corruption rather than loud failures. The practical rule: instrument every cross-branch read, and treat any read of a speculative effect as a *commit* of that effect for rollback purposes. If you cannot instrument it, you cannot roll it back. ```python class SpeculativeRollbackError(RuntimeError): """Raised when a speculative branch cannot be safely rolled back.""" pass class BranchEffect: def __init__(self, branch_id: str, resource: str, kind: str, compensable: bool): self.branch_id = branch_id self.resource = resource self.kind = kind # "write", "lock", "counter", "message" self.compensable = compensable self.observed_by: set[str] = set() def rollback_branch(branch_id: str, effects: list[BranchEffect], live_branches: set[str]) -> None: """Attempt to roll back a losing speculative branch. Raises SpeculativeRollbackError if any effect has been observed by a live branch or lacks a compensation path. """ for eff in effects: if eff.branch_id != branch_id: continue leaked = eff.observed_by & live_branches if leaked: raise SpeculativeRollbackError( f"effect {eff.kind} on {eff.resource} from branch {branch_id} " f"was observed by live branches {sorted(leaked)}; " f"rollback would cause phantom commit — quarantine instead" ) if not eff.compensable: raise SpeculativeRollbackError( f"effect {eff.kind} on {eff.resource} from branch {branch_id} " f"has no compensation path; cannot roll back safely" ) # All effects verified abortable; apply compensations in reverse order. for eff in reversed([e for e in effects if e.branch_id == branch_id]): _compensate(eff) def _compensate(eff: BranchEffect) -> None: # Idempotent compensation dispatch keyed by (resource, kind). ... ```