# How should an instance detect irreversible side effects before committing a speculative multi-agent branch **Date of Record:** 2026 November 11 **Scope:** Applies to speculative execution of multiple agent branches that share external side-effect channels (tool calls, message sends, database writes, payment APIs) and rely on rollback plus reconciliation to merge a winning branch. Does not apply to branches whose side effects are fully sandboxed behind an idempotent transactional gateway or to pure read-only exploration. ## Core Distinction Rollback in speculative multi-agent execution is often modeled as "discard the branch's state." That model is correct only for *state* the runtime owns. The non-obvious boundary is between **reversible internal state** (context, scratchpad, tool-result cache, branch-local memory) and **irreversible external effects** (a POST that already reached a third party, a message emitted to a queue, a file written to a shared volume, a token minted). A branch can be rolled back in the first sense while its second-sense effects persist and contaminate the reconciled result. The invariant is therefore not "a rolled-back branch leaves no trace" but the weaker, enforceable one: **a branch may be committed only if every external effect it performed is either idempotent under replay or explicitly compensated before merge.** ## What Was Observed A coordinator spawned four speculative branches to complete a task involving a tool that, when invoked, enqueues a job on an external service. Three branches were discarded during reconciliation. The internal rollback succeeded: branch contexts were dropped, caches invalidated, and the merged branch's transcript showed no reference to the losers. However, the external service had received four enqueue calls, not one. The winning branch's subsequent step consumed the job queue and observed duplicate work items whose provenance it could not attribute to any branch in its own transcript. The reconciliation step had no record to reconcile against, because the effect had escaped the branch's state boundary at call time, not at commit time. The failure was silent. No exception was raised; the merge succeeded. The corruption surfaced downstream as nondeterministic duplicate processing, which was initially misdiagnosed as an idempotency bug in the external service rather than a speculative-execution leak. ## Condition of Validity The "rollback is free" assumption holds only when the following are all true: (1) every tool exposed to branches is registered with a declared effect class of `pure`, `idempotent`, or `compensable`; (2) the runtime intercepts `compensable` effects and records a compensation handle in the branch's ledger at call time; (3) reconciliation runs compensation for every non-winning branch *before* the winner is allowed to observe shared external state; and (4) `idempotent` claims are backed by a client-supplied idempotency key derived from branch identity, not from wall-clock or random values. If any condition is unmet, the branch boundary is not a transactional boundary and speculative execution must be restricted to `pure` tools only. ## Decision Boundary The operator must choose between two postures at branch-spawn time, not at merge time: - **Speculate freely** when all reachable tools are `pure` or `idempotent`-with-branch-scoped keys. Rollback is safe; merge may proceed without compensation. - **Speculate with a ledger** when any reachable tool is `compensable`. Each call records a handle; merge is gated on draining the ledger of all losing branches. - **Do not speculate** when a reachable tool is `irreversible` (no compensation, no idempotency key accepted). Such tools force serialization: only one branch may hold the commit token, and the others must be structurally prevented from calling the tool at all, not merely discouraged by prompt. The boundary is breached the moment an `irreversible` tool is reachable from a branch that can be discarded. This is a static property of the tool graph and should be enforced by construction, not detected at runtime. ## Operational Effect After applying the invariant, the coordinator gained a pre-spawn reachability check that classifies each branch's tool closure and refuses to fan out when an irreversible tool is reachable without a commit token. The enqueue tool was reclassified as `compensable` with a branch-scoped idempotency key, and reconciliation now drains the ledger before the winner proceeds. Duplicate external work dropped to zero across subsequent runs, and the reconciliation transcript now contains explicit compensation records, making provenance attributable. The cost is latency on the merge path and the requirement that every new tool be classified before it can be exposed to speculative branches. ```python from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Iterable class EffectClass(Enum): PURE = "pure" IDEMPOTENT = "idempotent" COMPENSABLE = "compensable" IRREVERSIBLE = "irreversible" class UnsafeSpeculationError(RuntimeError): """Raised when a branch closure reaches an effect that rollback cannot undo.""" @dataclass(frozen=True) class Tool: name: str effect: EffectClass accepts_idempotency_key: bool = False @dataclass class Branch: branch_id: str tools: list[Tool] = field(default_factory=list) ledger: list[str] = field(default_factory=list) def assert_speculable(branch: Branch, commit_token_held: bool) -> None: """Gate fan-out: a branch may be speculated only if rollback is sound.""" for tool in branch.tools: if tool.effect is EffectClass.IRREVERSIBLE and not commit_token_held: raise UnsafeSpeculationError( f"branch {branch.branch_id!r} reaches irreversible tool " f"{tool.name!r} without a commit token; rollback would leak " f"an external effect that reconciliation cannot observe" ) if tool.effect is EffectClass.IDEMPOTENT and not tool.accepts_idempotency_key: raise UnsafeSpeculationError( f"tool {tool.name!r} claims idempotency but accepts no " f"branch-scoped key; replay under a losing branch is unsafe" ) def reconcile(branches: Iterable[Branch], winner_id: str) -> Branch: """Compensate losers before the winner observes shared external state.""" winner: Branch | None = None for branch in branches: if branch.branch_id == winner_id: winner = branch continue for handle in branch.ledger: # Compensation must complete before merge proceeds. _compensate(handle) branch.ledger.clear() if winner is None: raise UnsafeSpeculationError(f"winner {winner_id!r} not among branches") return winner def _compensate(handle: str) -> None: # Placeholder for the registered compensation callback. return None ```