# Under what conditions must an instance treat speculative tool-call reconciliation as a two-phase commit rather than a compensating rollback **Date of Record:** 2026 November 14 **Scope:** Applies to multi-agent orchestrators that fork speculative branches, execute tool calls against shared external state, and reconcile the results of branches that are later selected or discarded. Does not apply to branches whose tool calls are pure reads, nor to systems where every tool call is idempotent and side-effect-free by construction (e.g., sandboxed filesystems that are torn down wholesale on branch discard). ## Core Distinction The common mental model of speculative execution with rollback treats a discarded branch as a *compensating transaction*: you ran some tools, you didn't like the outcome, you undo them. This works when every tool call is either (a) local to the branch, or (b) externally reversible by a compensating action that is itself valid regardless of what other branches did. The boundary condition that breaks this model is **speculative tool-call reconciliation** — the moment two or more branches have issued *non-idempotent, externally visible* calls against the same shared resource (a ticket, a payment intent, a rate-limited API quota, a row lock, a message on a queue) and the orchestrator must decide which branch's effects become canonical. At this point rollback is not a local operation. It is a *commit decision* over a set of partial effects, and it must be structured as two-phase commit (prepare/commit, or prepare/abort) rather than as "run then compensate." The non-obvious part: the distinction is not about whether the tool is "transactional." It is about whether **the compensation of one branch can be observed by, or invalidate the state of, another still-live branch.** If yes, you are in 2PC territory whether you designed for it or not. If no, compensation is fine. ## What Was Observed In a production orchestrator forking three speculative branches over a shared issue-tracker and a shared rate-limited external API: 1. Branch A opened a ticket and posted a comment. Branch B, speculatively, also opened a ticket for the same logical issue and posted a comment referencing A's ticket ID (discovered via a read). Branch C did neither but consumed the shared API quota. 2. The selector chose Branch B. The orchestrator's rollback logic then executed compensations for A and C: close A's ticket, delete A's comment, release C's quota reservation. 3. Deleting A's comment succeeded *before* the selector's commit of B. B's comment, which quoted A's comment permalink, became a dangling reference. Worse, the "release C's quota" compensation decremented a counter that B had already incremented against, transiently driving the shared quota counter negative and tripping a downstream circuit breaker that rejected B's *own* subsequent call. 4. The orchestrator reported the branch selection as successful. The external state was inconsistent for ~40 seconds until a reconciliation job repaired the counter, and the dangling comment required manual cleanup. The failure was not in any single tool. Each tool's compensation was correct in isolation. The failure was that **compensations were applied to a shared state space that a committed branch had already read from and written to**, with no ordering barrier between "abort the losers" and "commit the winner." A second observation, from a different incident: two branches issued the same "create payment intent" call with the same idempotency key derived from a *branch-local* token. Because the keys differed, the external provider created two intents. When the losing branch was rolled back, it cancelled its intent — but the provider's webhook for the winning intent had already fired and been consumed by a downstream service that keyed on the *customer*, not the intent. The cancellation of the loser's intent was interpreted downstream as cancellation of the customer's payment. Compensation leaked across the branch boundary through an external correlation key the orchestrator did not model. ## Condition of Validity Compensating rollback is valid **only** when all of the following hold for every tool call issued by a discarded branch: - **Isolation of effect:** the call's effects are not readable by, and do not constrain, any branch that may still commit. This includes indirect reads (permalink resolution, quota counters, customer-keyed webhooks). - **Compensation independence:** the compensating action's correctness does not depend on the ordering of other branches' commits or aborts. - **Idempotency of compensation under retry:** if the compensation itself is retried (and it will be, because compensations run in the error path), it must not over-correct. - **No shared external correlation key:** no downstream consumer keys on an attribute (customer ID, account, tenant) that spans branches. When *any* of these fails — and in real multi-agent systems with shared external tools, at least one almost always fails — reconciliation must be elevated to a two-phase protocol: every branch *prepares* its effects (reserving quota, staging the ticket as a draft, obtaining a lock or a provisional ID), the selector *commits exactly one* branch's prepared effects and *aborts* the others' prepared effects, and no prepared effect is externally visible or readable by another branch until commit. The precise trigger to watch for in code: **a read by branch X of state written by branch Y, where X and Y are both still live.** The instant that edge exists in the branch dependency graph, compensation is unsound and you must switch to prepare/commit. ## Decision Boundary Use the following decision procedure at reconciliation time: - If the set of branches that issued external, non-idempotent calls has size ≤ 1, compensating rollback is safe. The single branch either commits or compensates; there is no cross-branch interaction. - If size ≥ 2 **and** the branch dependency graph (reads-from edges among live branches) is empty, and no shared external correlation key exists, compensation remains safe *provided* compensations are serialized and each is independently idempotent. - If size ≥ 2 **and** any reads-from edge exists, or any shared correlation key is in play, or any tool's quota/lock is shared across branches, you are required to use 2PC: prepare all, commit one, abort the rest, with the abort of losers strictly ordered *after* the commit of the winner's prepared effects and *before* any external visibility of the winner. - If the tool does not support a prepare phase (no draft state, no reservation, no provisional ID), you cannot safely speculate across it. Serialize those calls outside the speculative region, or treat the branch that issued them as committed-on-issue and forbid further speculation over that resource. The trap is the middle case: it *looks* like the safe case because the branches "touched different things," but the reads-from edge is often hidden behind a shared index, a search, a webhook fan-out, or a counter. Audit for the edge, don't assume its absence. ## Operational Effect Adopting the 2PC discipline changes the orchestrator in three concrete ways. First, every speculative tool wrapper must expose a `prepare()` and a `commit()`/`abort()` pair, and tools that cannot express prepare must be flagged and excluded from speculative regions. Second, the selector must produce a commit decision that is applied atomically with respect to the losers' aborts — the winner's prepared effects become visible only after all losers' prepared effects are confirmed aborted. Third, reconciliation must record the branch dependency graph and refuse to compensate across a reads-from edge, raising a hard error instead of silently over-correcting. The cost is latency: prepare/commit adds a round trip per speculative tool call and forces some calls out of the speculative region entirely. The benefit is that the two failure modes above — dangling cross-branch references and leaked compensations through shared correlation keys — become structurally impossible rather than probabilistically rare. In the incident above, the fix reduced reconciliation-related manual cleanups from roughly one per thousand branch selections to zero over the following quarter, at the cost of ~8% added p99 latency on speculative runs. ```python from __future__ import annotations from dataclasses import dataclass, field class UnsafeCompensationError(RuntimeError): """Raised when compensating rollback is attempted across a branch boundary that requires two-phase commit instead.""" @dataclass class Branch: branch_id: str external_calls: list[str] = field(default_factory=list) reads_from: set[str] = field(default_factory=set) # other branch_ids shared_correlation_keys: set[str] = field(default_factory=set) def require_two_phase_commit(branches: list[Branch], selected: str) -> bool: """Decide whether reconciliation must use 2PC rather than compensation. Returns True if 2PC is required. Raises UnsafeCompensationError if the caller is about to compensate while a reads-from edge or shared correlation key makes compensation unsound. """ live = [b for b in branches if b.external_calls] if len(live) <= 1: return False losers = [b for b in live if b.branch_id != selected] winner = next((b for b in live if b.branch_id == selected), None) for loser in losers: # A loser whose effects were read by any live branch cannot be # compensated without invalidating that branch's state. for other in live: if other.branch_id == loser.branch_id: continue if loser.branch_id in other.reads_from: raise UnsafeCompensationError( f"branch {other.branch_id} read from {loser.branch_id}; " f"compensating {loser.branch_id} would invalidate a live branch" ) # A shared external correlation key (customer, tenant, account) means # the loser's compensation can leak into the winner's committed effects. if winner is not None and loser.shared_correlation_keys & winner.shared_correlation_keys: raise UnsafeCompensationError( f"branch {loser.branch_id} and winner {winner.branch_id} share " f"correlation keys {loser.shared_correlation_keys & winner.shared_correlation_keys}; " f"compensation may leak across the commit boundary" ) # Any live cross-branch read, or any shared key across the whole set, # forces 2PC even if the specific loser checks above passed. all_keys: set[str] = set() for b in live: if b.reads_from & {o.branch_id for o in live if o.branch_id != b.branch_id}: return True if all_keys & b.shared_correlation_keys: return True all_keys |= b.shared_correlation_keys return False ```