# When must an agent instance refuse to speculate on a side-effecting tool call whose rollback is not provably idempotent? **Date of Record:** 2026 November 6 **Scope:** Applies to agentic runtimes that speculatively execute tool calls (file writes, HTTP mutations, message sends, DB writes) ahead of a dependency decision and roll back on mismatch. Does not apply to pure-read speculation, nor to side-effecting calls whose compensating action is registered as a first-class, tested, idempotent transaction. ## Core Distinction The failure is not "speculation is unsafe." Speculation is safe whenever the *rollback* is a true inverse. The non-obvious boundary is that rollback of a side effect is almost never an inverse — it is a *compensating* action, and compensation is only sound if it is idempotent *and* the pre-state is reconstructible. Agents routinely conflate "I can undo this" with "I recorded enough to undo this." The boundary condition is: **speculation must be refused the moment the rollback path's idempotency or pre-state fidelity is unproven, not when the rollback fails.** ## What Was Observed A planning agent branched on the result of a `search_catalog` call and speculatively issued `reserve_inventory(sku, qty=1)` on two candidate SKUs. The branch resolved to SKU-A; SKU-B's reservation was rolled back via `release_inventory`. The release call timed out on the first attempt, the agent retried, and the upstream service — which treated `release` as *decrement*, not *set-to-zero* — decremented a reservation that the agent had already released on the timed-out-but-actually-succeeded first attempt, driving the counter negative and triggering an oversell guard that locked the SKU for 20 minutes. The rollback "succeeded" from the agent's perspective (HTTP 200 on retry) while corrupting state. The agent had no way to distinguish the timeout from a lost response because it had not recorded the reservation's server-side identifier before speculating. ## Condition of Validity Speculation on a side-effecting call is admissible only when all three hold: (1) the call returns a durable, server-assigned handle that the agent persists *before* awaiting the branch; (2) the compensating call is idempotent keyed on that handle (release-by-id, not release-by-delta); (3) the pre-state is captured such that a partial application is detectable. If any is unknown, the correct move is to *defer* the side effect until the branch resolves — speculation buys latency, not correctness, and latency is never worth an unrecoverable mutation. Note this is a per-call property, not a per-tool property: the same tool may be speculatable on one endpoint and not another. ## Decision Boundary Refuse speculation when the rollback is delta-based, when the handle is only known post-response, or when the compensating action's idempotency is undocumented. Permit speculation when the handle is deterministic and client-supplied (idempotency key) or the tool exposes a read-back that lets you reconcile. The boundary is *provability*, not *plausibility*: "release probably works twice" is a refusal. When in doubt, serialize — the branch is cheap, the corruption is not. ## Operational Effect The runtime should treat unprovable-rollback speculation as a hard error at planning time, not a runtime retry. This converts a silent state-corruption class into a loud, debuggable refusal, and forces tool authors to publish idempotency contracts before their tools become speculation-eligible. ```python class UnsafeSpeculationError(RuntimeError): """Raised when a side-effecting call is speculated without a provable rollback.""" def assert_speculation_safe(call, rollback, pre_state_handle): # Refuse unless the rollback is idempotent AND keyed on a durable handle # that was persisted before the branch was awaited. if not rollback.get("idempotent"): raise UnsafeSpeculationError( f"rollback for {call['name']} is not idempotent; refuse speculation" ) if rollback.get("key") != "handle": raise UnsafeSpeculationError( f"rollback for {call['name']} is delta-based; cannot reconcile retries" ) if pre_state_handle is None: raise UnsafeSpeculationError( f"no pre-state handle persisted for {call['name']}; partial apply undetectable" ) return True ```