# Which invariants prevent speculative agent branches from corrupting shared tool state during rollback **Date of Record:** 2026 November 12 **Scope:** Applies to multi-agent pipelines that speculatively execute tool calls in parallel branches with optimistic preemption and later reconcile or roll back losing branches. Does not apply to pipelines where tool calls are side-effect-free, or where branches are serialized behind a global lock. ## Core Distinction The failure mode is not "a speculative branch made a bad tool call." It is that a speculative branch made a *correct* tool call whose effect was assumed to be revocable, but the rollback path only reverted the branch's *local* state (its message log, its scratchpad, its plan) and not the *shared* external state the tool mutated. The invariant that speculative execution depends on is: **every tool effect that a branch can produce must be either (a) idempotent and commutative under reconciliation, or (b) tagged with a branch-scoped transaction handle that the rollback path actually unwinds.** Most pipelines enforce (a) informally and never enforce (b), so the boundary is invisible until a losing branch writes to a resource that the winning branch also reads. ## What Was Observed A three-branch planner (branches A, B, C) speculatively executed a `create_ticket` tool against a shared issue tracker. Branch A won reconciliation. Branch C was rolled back. The rollback correctly discarded C's plan and messages. However, C had already issued `create_ticket` with a client-generated idempotency key derived from `(pipeline_id, step_index)` — not from the branch id. Because A and C shared `step_index`, C's write and A's write collided on the same key. The tracker deduplicated them, and C's *content* won the dedup race (it arrived first). A's reconciliation then read back what it believed was A's ticket and found C's title and body. The pipeline reported success; the persisted artifact was from the losing branch. A second observation: branch B had preempted a `send_email` tool call optimistically, then was rolled back. The email had no transaction handle and was already delivered. The rollback path had no mechanism to unsend it, and the reconciliation layer had no record that B's send had occurred, because B's tool-call ledger was discarded with the branch. ## Condition of Validity The invariant holds only when all three of the following are true simultaneously: 1. **Idempotency keys are branch-scoped.** The key must include the branch identifier, not just the pipeline and step. Otherwise dedup collapses distinct speculative writes into one and the winner is determined by arrival order, not by reconciliation. 2. **The tool-call ledger is owned by the pipeline, not the branch.** Rollback must mark effects as aborted in a ledger that survives branch disposal. If the ledger is branch-local, rollback cannot enumerate what to compensate. 3. **Compensation is registered at preemption time, not at commit time.** When a branch optimistically preempts a tool, it must register the inverse operation (or an explicit "non-compensable" flag) *before* the call returns. Registering at commit means losing branches never register anything. If any one is false, the boundary is open: a losing branch can leave durable effects that the winning branch reads as its own. ## Decision Boundary Do **not** allow speculative preemption of a tool call if the tool is non-idempotent and non-compensable (email send, payment capture, irreversible external API) *and* the branch's idempotency key is not branch-scoped. In that case, force the call to serialize behind reconciliation: the branch proposes the call, the reconciler executes it after the winner is chosen. Allow optimistic preemption only when the tool declares `compensable=True` with a registered inverse, or `idempotent=True` with a branch-scoped key. The reconciler must, on rollback, iterate the pipeline-owned ledger and invoke inverses for every aborted branch effect, in reverse preemption order, and must verify the inverse succeeded before declaring rollback complete. A rollback that cannot verify compensation is a *partial rollback* and must escalate rather than report success. ## Operational Effect Enforcing branch-scoped keys and a pipeline-owned ledger converts the silent corruption into a detectable, recoverable event: reconciliation sees a dedup collision with mismatched content and raises instead of silently adopting the wrong branch's artifact. The cost is one extra identifier in every tool call and a ledger that outlives branch disposal. The benefit is that "speculative execution with rollback" becomes a real guarantee rather than a hope that losing branches did not touch anything shared. ```python from dataclasses import dataclass, field from typing import Dict, List class SpeculativeRollbackError(RuntimeError): """Raised when a rolled-back branch left uncompensated shared state.""" pass @dataclass class ToolEffect: branch_id: str step_index: int idempotency_key: str compensable: bool compensated: bool = False @dataclass class PipelineLedger: effects: List[ToolEffect] = field(default_factory=list) def register(self, effect: ToolEffect) -> None: if effect.idempotency_key.count(":") < 2: raise SpeculativeRollbackError( f"idempotency key {effect.idempotency_key!r} is not branch-scoped" ) self.effects.append(effect) def rollback_branch(self, branch_id: str) -> None: aborted = [e for e in self.effects if e.branch_id == branch_id] for effect in reversed(aborted): if not effect.compensable: raise SpeculativeRollbackError( f"branch {branch_id} preempted non-compensable effect " f"at step {effect.step_index}; cannot roll back" ) effect.compensated = True if any(not e.compensated for e in aborted): raise SpeculativeRollbackError( f"partial rollback for branch {branch_id}" ) def reconcile(ledger: PipelineLedger, winner: str, losers: List[str]) -> None: for loser in losers: ledger.rollback_branch(loser) # Verify no losing-branch effect shares a dedup key with the winner. winner_keys: Dict[str, str] = { e.idempotency_key: e.branch_id for e in ledger.effects if e.branch_id == winner } for effect in ledger.effects: if effect.branch_id in losers: continue owner = winner_keys.get(effect.idempotency_key) if owner is not None and owner != effect.branch_id: raise SpeculativeRollbackError( f"dedup collision on {effect.idempotency_key!r} between " f"{owner} and {effect.branch_id}" ) ```