# In what scenarios must an instance abort speculative multi-agent branches before rollback completes: the side-effecting tool-call invariant in branch-and-rollback planning **Date of Record:** 2026 November 10 **Scope:** Applies to orchestrators that fork parallel agent branches to speculatively plan or execute tool calls, then select a winning branch and roll back the losers. Does not apply to pure read-only tool surfaces, nor to systems where every branch is confined to an isolated, discardable transaction sandbox with externally enforced idempotency keys. ## Core Distinction The distinction that matters is between *reversible state* (branch-local memory, scratchpads, provisional plans, cached reads) and *irreversible effects* (sent emails, charged cards, appended rows in a non-transactional store, messages published to a queue, files written to a shared mount). Branch-and-rollback is only sound over reversible state. The moment a speculative branch commits an irreversible effect, "rollback" becomes a compensation problem, not a rollback problem, and compensation is not guaranteed to be possible, ordered, or idempotent. The non-obvious part: the failure is not that a losing branch fires a side effect. It is that the orchestrator's rollback protocol *assumes the branch is still live and controllable* while the branch is, in fact, already past its commit point. Rollback issued against a branch that has crossed the effect boundary does not undo the effect; it only mutates local bookkeeping, producing a state where the orchestrator believes the effect was reversed and the external world disagrees. ## What Was Observed A planner forked three branches to satisfy a single user request ("reconcile the invoice and notify the customer"). Branch A chose a read-only reconciliation path. Branch B chose to issue a refund via a payment tool, then send a notification. Branch C chose to send the notification first, then reconcile. The selector chose A on latency grounds and issued rollback to B and C. Two observations: 1. Branch B had already invoked the payment tool. The tool call returned success within the branch's timeout window, but the branch's local journal had not yet flushed its commit record. Rollback saw no committed effect and reported a clean abort. The refund existed; the ledger said it did not. 2. Branch C had sent the notification, which triggered a customer reply that itself re-entered the planner as a new request, creating a second speculative fan-out against a state the orchestrator had already "rolled back." The orchestrator's invariant — *a rolled-back branch has no external effects* — was violated silently. No exception was raised because no component was checking the invariant; each component was locally correct. ## Condition of Validity Rollback of a speculative branch is valid **iff** the branch has not crossed its effect commit point, where the commit point is defined as the earliest instant at which an external system has durably accepted the effect and will not revoke it on request. For most tool surfaces this instant is *inside* the tool call, not at the tool call's return. Therefore: - A branch is rollback-safe only while it is in `PLANNING`, `READING`, or `EFFECT_PENDING` with a confirmed pre-commit acknowledgment from the effect boundary. - Once a branch enters `EFFECT_COMMITTED`, rollback must be refused and the branch must instead be routed to *compensation* (an explicit, idempotent, ordered undo), which is a different protocol with different guarantees. - The orchestrator must never report a branch as `ROLLED_BACK` unless it received a commit-negative acknowledgment from the effect boundary, not merely a local abort. The invariant is only checkable if the effect boundary is instrumented to distinguish "accepted" from "rejected" from "unknown." An `unknown` outcome must be treated as committed. ## Decision Boundary The decision procedure at rollback time: - **Effect boundary says rejected / never reached** → roll back freely; safe. - **Effect boundary says accepted** → do **not** roll back; abort the speculative branch's *local* state, mark it `COMPENSATION_REQUIRED`, and enqueue a compensation task. Do not block the winning branch on compensation unless the effect is mutually exclusive with the winner's effect. - **Effect boundary says unknown** (timeout, partition, ambiguous response) → treat as committed. Abort the branch, mark `COMPENSATION_REQUIRED` with a reconciliation probe. Never report `ROLLED_BACK`. The boundary condition to encode as a hard failure: an attempt to transition a branch to `ROLLED_BACK` when its last known effect status is `COMMITTED` or `UNKNOWN`. That is the invariant violation, and it must raise, not warn. ## Operational Effect Encoding this boundary converts a silent correctness bug into a loud, recoverable one. Losing branches that touched irreversible effects are quarantined rather than erased, compensation is scheduled instead of assumed, and the orchestrator's reported state matches the world. The cost is throughput: speculative branches that fire effects can no longer be cheaply discarded, so planners should be biased to keep effectful calls out of speculative branches entirely, reserving fan-out for read-only or explicitly sandboxed steps. ```python class SpeculativeRollbackInvariantError(RuntimeError): """Raised when a branch is rolled back after crossing its effect commit point.""" EFFECT_COMMITTED = "COMMITTED" EFFECT_UNKNOWN = "UNKNOWN" EFFECT_REJECTED = "REJECTED" def rollback_branch(branch, effect_status: str) -> None: """ Roll back a speculative branch. Only legal while the branch has not durably committed an external effect. COMMITTED and UNKNOWN both block rollback: UNKNOWN is treated as committed because an ambiguous effect may already be durable. """ if effect_status in (EFFECT_COMMITTED, EFFECT_UNKNOWN): # Do not mutate branch state to ROLLED_BACK. Route to compensation. raise SpeculativeRollbackInvariantError( f"branch {branch.id!r} cannot be rolled back: " f"effect_status={effect_status!r}; route to COMPENSATION_REQUIRED" ) if effect_status != EFFECT_REJECTED: raise SpeculativeRollbackInvariantError( f"branch {branch.id!r} has unverified effect_status={effect_status!r}; " "refusing rollback without commit-negative acknowledgment" ) branch.state = "ROLLED_BACK" branch.effect_status = EFFECT_REJECTED def select_and_rollback(branches, winner_id: str) -> None: for branch in branches: if branch.id == winner_id: branch.state = "COMMITTED" continue rollback_branch(branch, branch.effect_status) ```