# Which invariants prevent speculative tool-call branches from corrupting committed state **Date of Record:** 2026 November 9 **Scope:** Applies to agent runtimes that fork a conversation/plan into multiple speculative branches, each of which may issue side-effecting tool calls, and where branches are later committed or discarded by a merge/commit protocol. Does not apply to pure-read tool calls, nor to single-branch sequential agents where no speculative fork exists. ## Core Distinction The failure mode is not "a speculative branch executed a side effect." It is "a speculative branch executed a side effect whose *effect token* was not registered with the commit coordinator before the effect became externally observable." Speculation and side effects are compatible; speculation and *uncoordinated* side effects are not. The invariant that separates safe from unsafe speculation is therefore about the ordering of *registration* relative to *observability*, not about whether the branch ran at all. A second distinction matters: a commit protocol that is *linearizable with respect to the branch tree* is still unsafe if a tool's idempotency key is derived from branch-local state (e.g., a per-branch counter or a random UUID generated inside the sandbox). Two branches can produce distinct keys for what is semantically the same write, so "commit" and "discard" no longer partition the effect space — discard cannot undo what it cannot address. ## What Was Observed In a fork-merge runtime with three speculative branches (`plan-A`, `plan-B`, `plan-C`) racing to satisfy a single goal, the following sequence reproduced reliably: 1. Branch A calls `write_file(path=/tmp/out.json, content=X)` and the tool returns success. The effect token is stored in A's local journal. 2. Branch B, unaware of A, calls the same tool with identical arguments. The tool is *not* idempotent (it appends a version suffix), so two artifacts now exist: `out.json` and `out.json.v2`. 3. The coordinator selects B's plan as the winner and issues `discard(A)`. Discard replays A's journal in reverse — but the journal recorded only the *return value* of A's call, not a handle to the created artifact. The reverse replay is a no-op. 4. The committed state now contains an orphaned artifact from a discarded branch. Downstream consumers that glob `out.json*` see two files and pick non-deterministically. The non-obvious part: the coordinator's logs showed `discard(A) = success`. The protocol reported correctness while violating it. The invariant "discard(A) restores the pre-branch state" was assumed but never checked, because the journal schema had no field to check it against. A second observation, from a different incident: branch C called a *non-idempotent* `send_email` tool, then was discarded after the email left the process boundary. No commit protocol can recall a sent email. The runtime's speculation policy permitted side-effecting tools on branches without classifying them as *externally observable* vs. *process-local*. This is the same invariant failure wearing different clothes: observability preceded registration, and registration was never required for this tool class. ## Condition of Validity The commit protocol is sound **iff** all of the following hold: - **(I1) Pre-observability registration.** Every side-effecting tool call registers a *reversible effect token* with the coordinator *before* the effect becomes externally observable. For process-local writes this means before `fsync`/rename; for network effects this means before the request is dispatched, with the token carried in the request (e.g., as an idempotency key). - **(I2) Branch-independent effect identity.** The effect token is a function of `(tool, canonical_args, goal_id)`, not of branch-local state. Two branches issuing the same semantic write must collide on the same token, and the tool must treat a token collision as "already done, return prior result." - **(I3) Discard completeness.** `discard(b)` must either (a) reverse every token registered by `b`, or (b) return a typed `UnreversibleEffect` error naming the token. Silent success is forbidden. A discard that cannot fully reverse must escalate, not report `success`. - **(I4) Commit exclusivity.** At most one branch may commit per `goal_id`, and commit must be a compare-and-swap on the goal's effect-token set, so a late-arriving branch cannot register a token after the commit point. (I1) and (I2) together are what the first incident violated; (I3) is what made the violation invisible; (I4) is what prevents the race from re-opening after a commit. ## Decision Boundary The practical boundary is drawn at the **tool classification** step, not at the branch scheduler. Classify each tool as: - *Pure* — no side effects; speculate freely, no tokens needed. - *Idempotent + token-addressable* — safe to speculate under (I1)–(I4); the token makes duplicate execution harmless. - *Non-idempotent or externally observable* (email, payments, third-party POSTs, deletes) — **must not run on an uncommitted branch.** Either serialize it to the commit phase, or require an explicit `--allow-speculative-irreversible` opt-in that the coordinator records and surfaces to the operator. The boundary is crossed the moment a tool in the third class is dispatched from a branch whose commit is not yet decided. Everything downstream — journal replay, reverse replay, "discard success" — is theater once that dispatch happens. ## Operational Effect When (I1)–(I4) hold, speculation is cheap: branches can race, losers are discarded by token reversal, and the commit is a single CAS. When any one fails, the failure is *silent and asymmetric* — the protocol reports success on an operation it did not perform, and the corruption surfaces later as duplicate artifacts, double-sends, or non-deterministic reads. The operational rule is therefore to make (I3) loud: a discard that cannot reverse must raise, and the runtime must treat an unreversible speculative effect as a hard error, not a warning, because the alternative is a commit protocol that lies. ```python from dataclasses import dataclass, field from typing import Dict, Set class SpeculativeCommitError(RuntimeError): """Raised when a speculative branch violates a commit-protocol invariant.""" @dataclass class EffectToken: tool: str canonical_args: str goal_id: str def key(self) -> str: # (I2): identity is branch-independent. return f"{self.goal_id}:{self.tool}:{self.canonical_args}" @dataclass class Branch: name: str tokens: Set[str] = field(default_factory=set) externally_observable: Set[str] = field(default_factory=set) class CommitCoordinator: IRREVERSIBLE_TOOLS = {"send_email", "charge_card", "http_post_external"} def __init__(self, goal_id: str) -> None: self.goal_id = goal_id self.registered: Dict[str, str] = {} # token key -> branch name self.committed_branch: str | None = None def register(self, branch: Branch, token: EffectToken) -> None: # (I4): no registration after commit. if self.committed_branch is not None: raise SpeculativeCommitError( f"registration of {token.key()} after commit by " f"{self.committed_branch}; branch {branch.name} is stale" ) # (I2): token collision across branches is allowed and deduplicated. key = token.key() owner = self.registered.get(key) if owner is None: self.registered[key] = branch.name branch.tokens.add(key) def dispatch(self, branch: Branch, token: EffectToken) -> None: # (I1): registration must precede observability. if token.key() not in branch.tokens: raise SpeculativeCommitError( f"branch {branch.name} dispatched {token.tool} before " f"registering effect token {token.key()}" ) if token.tool in self.IRREVERSIBLE_TOOLS: # Decision boundary: irreversible tools may not run speculatively. raise SpeculativeCommitError( f"tool {token.tool} is externally observable and non-idempotent; " f"refusing speculative dispatch on branch {branch.name}. " f"Serialize to commit phase or set allow_speculative_irreversible." ) branch.externally_observable.add(token.key()) def commit(self, branch: Branch) -> None: if self.committed_branch is not None: raise SpeculativeCommitError( f"goal {self.goal_id} already committed by {self.committed_branch}" ) self.committed_branch = branch.name def discard(self, branch: Branch) -> None: # (I3): discard must reverse or raise; never silent success. if branch.name == self.committed_branch: raise SpeculativeCommitError( f"cannot discard committed branch {branch.name}" ) unreversible = [ k for k in branch.tokens if k in branch.externally_observable and k in self.registered and self.registered[k] == branch.name ] if unreversible: raise SpeculativeCommitError( f"discard of branch {branch.name} cannot reverse " f"{len(unreversible)} effect(s): {sorted(unreversible)}" ) for k in branch.tokens: if self.registered.get(k) == branch.name: del self.registered[k] ```