# How should an instance reconcile speculative branch state when a merge rollback invalidates a sibling's already-committed side effects **Date of Record:** 2026 November 10 **Scope:** Applies to multi-agent orchestrators that speculatively fork execution across divergent tool outcomes and later merge branches, where at least one tool call is non-idempotent (writes, sends, charges, deletes). Does not apply to pure-read speculative trees, or to systems where every tool is transactional and the underlying store supports distributed two-phase commit across branches. ## Core Distinction The failure is not "a branch diverged and had to be rolled back." That is the expected case and is handled by discarding the branch's in-memory state. The failure is that **rollback of a branch's logical state does not imply rollback of its external side effects**, and a merge decision made on branch *A* can retroactively invalidate a side effect already committed by sibling branch *B* during the speculative window. The orchestrator sees a clean logical rollback; the world outside sees an orphaned write. The subtle part: the invalidation is **causally downstream**. Branch *B* committed a write because it observed a precondition that branch *A*'s eventual merge decision retroactively falsifies. No branch is individually wrong. The invariant is violated only at the join. ## What Was Observed In a planner/executor tree with three-way speculative fork over a tool result (success / partial / error), branch *B* (partial path) executed `charge_customer` and `send_receipt` under the assumption that the order was confirmed. Branch *A* (success path) won the merge, but its winning result carried a correction that the order was a duplicate. The merge logic correctly rolled back *B*'s *state*, marked *B* `ABANDONED`, and proceeded. The receipt email had already left the outbox. The charge had already settled. The orchestrator's audit log showed *B* as never-committed, because the rollback wrote `status=ABANDONED` over the branch record without preserving the side-effect ledger. Recovery took three signals to reconstruct: the payment provider webhook, the email provider delivery log, and a manual diff against the branch's `tool_call_log`. The orchestrator had no record linking the orphaned charge to the branch that produced it, because rollback had tombstoned the branch row. ## Condition of Validity The "rollback is safe" assumption holds only when, for every tool invoked in a speculative branch, either (a) the tool is idempotent and its effect is reversible by a compensating call the orchestrator knows how to issue, or (b) the merge decision is monotonic — i.e., no merge outcome can falsify a precondition observed by a sibling branch. Condition (b) is the one that silently breaks: it requires that branch preconditions be *independent*, but divergent-outcome speculation explicitly constructs branches whose preconditions are mutually exclusive by design. The moment you fork on "did the order succeed," you have created branches that assume opposite facts, and any merge that picks one fact invalidates the other's committed effects. The invariant that must be enforced: **a branch may not commit a non-idempotent side effect until its precondition is merge-stable**, or alternatively, all such effects must be staged behind a pre-commit gate keyed to the branch's eventual merge verdict. ## Decision Boundary Two viable disciplines, and the choice is forced by tool inventory, not preference: 1. **Stage-and-gate:** All non-idempotent tools write to a staging ledger with `branch_id` and `precondition_hash`. Effects are released only after merge, and release is skipped for abandoned branches. Cost: latency (nothing external happens during speculation) and the need for every tool to support a two-phase mode. Choose this when the tool set is known and you can wrap it. 2. **Commit-and-compensate:** Effects commit immediately, but every branch records a compensation handle (refund, retraction, delete) at commit time, and merge rollback *must* invoke compensations before tombstoning the branch. Choose this when tools are third-party and cannot be wrapped, and when compensation is genuinely available (refunds yes; "unsend email" no). The boundary: if a tool is non-idempotent AND non-compensable (irreversible notification, physical dispatch), it **must not be invoked inside a speculative branch at all** — it must be deferred past the merge barrier. There is no safe middle. ## Operational Effect After enforcing the boundary, the orchestrator's branch table carries a `side_effect_ledger` that survives rollback (rollback changes `status` but never deletes ledger rows), and merge emits a `compensation_required` set derived from abandoned branches' ledgers. Audit queries can now reconstruct orphaned effects from branch records alone. The residual cost is that speculative branches can no longer "just do things" — they must declare intent, and the merge barrier becomes a hard synchronization point for any irreversible action. Systems that skip this pay in reconciliation incidents that are invisible to the orchestrator's own logs, which is the worst kind of failure: the control plane believes it rolled back cleanly while the data plane has already committed. ```python from dataclasses import dataclass, field from enum import Enum class BranchStatus(str, Enum): SPECULATING = "SPECULATING" MERGED = "MERGED" ABANDONED = "ABANDONED" class MergeRollbackError(RuntimeError): """Raised when a merge rollback would tombstone a branch whose non-idempotent side effects were already committed and are not covered by a compensation handle.""" @dataclass class SideEffect: tool: str idempotent: bool compensable: bool handle: str | None = None # compensation handle, if any @dataclass class Branch: branch_id: str status: BranchStatus = BranchStatus.SPECULATING ledger: list[SideEffect] = field(default_factory=list) def commit_side_effect(branch: Branch, effect: SideEffect) -> None: if branch.status is not BranchStatus.SPECULATING: raise MergeRollbackError( f"branch {branch.branch_id} is {branch.status}; cannot commit new effect" ) if not effect.idempotent and not effect.compensable: # Irreversible action inside speculation: forbidden by the boundary. raise MergeRollbackError( f"tool {effect.tool!r} is non-idempotent and non-compensable; " f"must be deferred past merge barrier, not committed speculatively" ) branch.ledger.append(effect) def rollback_branch(branch: Branch) -> list[SideEffect]: """Tombstone a branch on merge rollback. Returns compensations to run. The ledger is preserved, never deleted, so orphaned effects remain reconstructable from branch records alone. """ if branch.status is BranchStatus.MERGED: raise MergeRollbackError( f"branch {branch.branch_id} already merged; rollback would orphan effects" ) uncompensated = [ e for e in branch.ledger if not e.idempotent and e.handle is None ] if uncompensated: raise MergeRollbackError( f"branch {branch.branch_id} has {len(uncompensated)} non-idempotent " f"effect(s) with no compensation handle: " f"{[e.tool for e in uncompensated]}" ) branch.status = BranchStatus.ABANDONED return [e for e in branch.ledger if e.compensable and e.handle] ```