# When must an instance abort a speculative multi-agent branch: rollback divergence at the tool-result commit point **Date of Record:** 2026 November 13 **Scope:** Applies to orchestrators that fan out speculative agent branches (parallel tool calls, shadow executions, or pre-committed plans) and must decide whether to roll back a branch when tool outcomes diverge from the assumption under which the branch was launched. Does not apply to purely sequential agents, to branches with no side-effecting tool calls, or to systems where all tool calls are idempotent and reversible at zero cost. ## Core Distinction The failure mode is not "a tool returned an unexpected result." It is "a tool returned a result that is *valid* but *divergent*, and the branch already committed a downstream side effect that the divergence invalidates." Two distinct events are routinely conflated: 1. **Divergence detection** — the observed tool outcome differs from the outcome the branch's plan was predicated on. 2. **Rollback necessity** — the divergence invalidates a side effect the branch has *already externalized* (wrote a row, sent a message, reserved a resource, mutated shared state). A branch may diverge without needing rollback (the divergence is absorbed by a compensating read or a re-plan). A branch may need rollback even when no tool *failed* (every call returned 200 OK, but the *joint* outcome contradicts the invariant the speculative branch assumed). The boundary is the **commit point**: the first irreversible externalization of a value derived from a speculative assumption. Rollback is mandatory if and only if divergence is detected *after* the commit point and the divergent value participated in the committed derivation. ## What Was Observed In a three-branch speculative planner (branches A, B, C launched against a shared resource-reservation tool), branch B called `reserve(inventory_id, qty=4)` on the assumption that a prior `quote()` would return unit price ≤ threshold. The `quote()` call on branch B returned a price *above* threshold — a divergence — but the orchestrator's divergence detector only fired on *error* statuses, not on *semantic* mismatch. Branch B's reservation had already committed (the reservation tool is not transactional with the quote tool). Branch C, launched speculatively against the same inventory, then attempted to reserve the same units; it succeeded because B's reservation had not yet been reconciled, and the inventory tool's oversubscription guard was per-call rather than per-transaction. The joint state was internally consistent per call and globally inconsistent: 8 units reserved against 4 available. The non-obvious part: **no tool returned an error, no branch was "wrong" in isolation, and the divergence was visible only in the cross-branch join.** The orchestrator's rollback logic keyed on tool error codes, so it never ran. The invariant that broke was not "tool success" but "sum of committed reservations ≤ available inventory," which no single branch could observe. ## Condition of Validity Rollback of a speculative branch is *valid* (i.e., will restore a consistent state) only when all of the following hold: - The branch's committed side effects are **enumerable and addressable** — the orchestrator holds handles (reservation IDs, message IDs, write tokens) sufficient to issue compensating operations. - The compensating operation is **idempotent under retry** and **ordered after** any concurrent branch that may have observed the committed effect. - The divergence predicate is **evaluable at the join**, not only at the leaf — i.e., the orchestrator can compare the branch's committed derivation against the *actual* tool outcome, not merely against the tool's status code. - No *other* branch has already **consumed** the committed effect in a way that itself committed downstream (a cascade). If branch C read B's reservation and reserved against it, B's rollback is no longer sufficient; C must also roll back, and the rollback must be ordered C-then-B. If any of these fails, "rollback" is a fiction: the orchestrator will report a clean state while the external world holds orphaned effects. The correct action in that case is not rollback but **quarantine and escalation** — mark the branch's effects as suspect, halt dependent branches, and surface the inconsistency rather than paper over it. ## Decision Boundary The decision procedure at the join: ``` for each branch b in speculative_set: if divergence(b) and committed_before(b, commit_point): if compensable(b) and not consumed_by_other(b): rollback(b) # safe elif compensable(b) and consumed_by_other(b): rollback_topologically(consumers(b) + [b]) # cascade, ordered else: quarantine(b); escalate() # rollback would lie elif divergence(b) and not committed_before(b, commit_point): discard(b) # no side effect, no rollback else: promote(b) # assumption held ``` The boundary that is easy to get wrong: `committed_before(b, commit_point)` must be evaluated against the **externalization timestamp**, not the branch's internal log order. A branch that *logged* a reservation intent before the quote returned but *executed* the reservation after is committed-after, and discard — not rollback — is correct. Conflating log order with externalization order causes the orchestrator to issue compensating deletes for effects that were never created, which themselves become orphaned effects. ## Operational Effect When the boundary is respected, speculative fan-out stays cheap: divergent-but-uncommitted branches are discarded at zero cost, and committed-but-unconsumed branches roll back cleanly. When it is violated — specifically, when divergence detection is keyed to error status rather than semantic mismatch, or when commit-point ordering is taken from internal logs rather than externalization — the system exhibits a characteristic signature: **per-call success rates near 100%, global invariant violations that appear only under concurrent speculative load, and rollback logs that show compensations for effects that do not exist alongside orphaned effects that were never compensated.** The remediation is not more retries; it is moving the divergence predicate to the join and the commit-point check to the externalization boundary. ```python from dataclasses import dataclass, field from enum import Enum from typing import Optional class RollbackBoundaryError(RuntimeError): """Raised when a speculative branch cannot be safely rolled back.""" pass class BranchState(Enum): SPECULATIVE = "speculative" COMMITTED = "committed" # side effect externalized CONSUMED = "consumed" # another branch derived from this effect ROLLED_BACK = "rolled_back" QUARANTINED = "quarantined" @dataclass class Branch: name: str state: BranchState = BranchState.SPECULATIVE diverged: bool = False committed_at: Optional[float] = None # externalization timestamp handles: list = field(default_factory=list) # compensating-op addresses consumers: list = field(default_factory=list) def decide(branch: Branch, divergence_time: float) -> str: """Decide rollback vs discard vs quarantine for one branch.""" if not branch.diverged: return "promote" committed = ( branch.state in (BranchState.COMMITTED, BranchState.CONSUMED) and branch.committed_at is not None and branch.committed_at <= divergence_time ) if not committed: # Divergence seen before externalization: discard, do not roll back. return "discard" if branch.state == BranchState.CONSUMED: # A downstream branch derived from this effect; rollback must cascade. if not branch.consumers: raise RollbackBoundaryError( f"branch {branch.name} marked CONSUMED but records no consumers; " f"cannot order cascade rollback" ) return "rollback_cascade" if not branch.handles: # Committed, divergent, but no compensating handles: rollback would lie. raise RollbackBoundaryError( f"branch {branch.name} committed at t={branch.committed_at} and diverged " f"at t={divergence_time}, but holds no compensating handles; " f"quarantine and escalate instead of issuing phantom rollback" ) return "rollback" def run_join(branches: list[Branch], divergence_time: float) -> dict: actions = {} for b in branches: try: actions[b.name] = decide(b, divergence_time) except RollbackBoundaryError as e: # Surface, do not swallow: a phantom rollback corrupts global state. actions[b.name] = "quarantine" actions.setdefault("_errors", []).append(str(e)) return actions ```