# Which invariants prevent speculative agent plans from committing irreversible tool actions without a verified precondition **Date of Record:** 2026 November 11 **Scope:** Applies to agents that generate speculative multi-step plans (branching, rollback-capable, or parallel) and dispatch tool calls where at least one action is irreversible (payments, deletions, external notifications, state mutations with no compensating transaction). Does not apply to fully idempotent or purely read-only tool surfaces, nor to single-step plans with no speculative branches. ## Core Distinction The failure mode is not "the verifier was wrong." It is a *commit-ordering* defect: the verifier gate is evaluated against a *speculative world state* produced by the plan, not against the *actual world state* at commit time. A plan that is internally consistent—every speculative step's precondition is satisfied in the simulated trajectory—can still be committed into a world where a concurrent actor, a stale cache, or a partially-applied prior step has invalidated the precondition. The verifier passes because the plan is self-consistent; the commit fails because the world is not. The non-obvious boundary: **verifier-gated commit is only sound if the gate is re-evaluated atomically against the live world, and the irreversible action is the last mutation in the transaction.** Any design where the verifier reads speculative state, or where an irreversible action precedes a reversible one in the same commit batch, admits a class of commits that pass verification but cannot be undone. ## What Was Observed In a planning agent with a `speculate → verify → commit` loop, the following sequence reproduced reliably: 1. Agent speculates: `reserve_inventory(sku, 1)` then `charge_card(customer, amount)` then `send_receipt(customer)`. 2. Verifier checks the plan against a snapshot: inventory available, card valid, receipt template present. Passes. 3. Commit executes sequentially. `reserve_inventory` succeeds. `charge_card` succeeds. `send_receipt` fails (template service 503). 4. The charge is irreversible; the plan is now half-committed with no compensating action in the plan graph. The verifier was correct about the *plan*; it was silent about the *commit boundary*. The invariant that was violated is not "verify before commit"—that was satisfied—but "**no irreversible action may be committed unless every subsequent action in the plan is either guaranteed to succeed or has a declared compensating action.**" The plan graph contained no compensating edge from `send_receipt` back to `charge_card`, so the verifier had no basis to reject, and the commit had no basis to stop. A second observation: when the verifier was given the live world state instead of the speculative snapshot, it began rejecting plans that were *correct* but *racy*—plans whose preconditions held at verification time and were invalidated by the time the commit lock was acquired. The fix was not to widen the verifier but to make verification and commit share a single lock scope, and to re-check the precondition inside the commit transaction. ## Condition of Validity The invariant holds only under all of the following: - The irreversible action is the **terminal mutation** of its commit unit; no reversible or fallible action follows it in the same transaction. - The verifier reads **live, locked** world state, not a snapshot captured during speculation. - Every speculative branch that reaches an irreversible action carries either a **proof obligation** (a precondition the commit transaction re-checks) or a **compensating action** (a declared inverse with its own precondition). - The commit unit is **serialized** against concurrent mutators of the same resources; otherwise the re-check is a TOCTOU race with a window equal to the lock acquisition latency. It does not hold when the tool surface is partially idempotent (retry-safe but not inverse-safe), when compensating actions are themselves irreversible, or when the plan graph is a DAG with shared irreversible nodes reachable from multiple branches—in that case a single commit unit may contain two irreversible actions, and the terminal-mutation rule is unsatisfiable without splitting the transaction. ## Decision Boundary The operational decision is: **when a plan contains an irreversible action, the commit must be a single-unit transaction whose last operation is that action, and the verifier must be invoked inside the commit lock.** If the plan cannot be expressed that way—because two irreversible actions are required, or because a fallible non-compensable step must follow—the correct behavior is to *reject the plan at speculation time*, not to commit and hope. A verifier that passes such a plan is not a safety mechanism; it is a liability, because it converts an unsafe plan into an authorized one. The boundary is sharp: plans with one terminal irreversible action and locked re-verification are committable; plans with any irreversible action not at the terminal position, or verified outside the commit lock, are not. There is no partial credit. ## Operational Effect Agents that enforce the terminal-mutation and locked-re-verification invariants show a measurable drop in half-committed transactions—typically to zero for single-irreversible-action plans—at the cost of rejecting a fraction of plans that are *probably* safe but not *provably* safe. The rejection rate is the price of the invariant, and it is the correct trade: a rejected plan is a retry, a half-committed charge is an incident. The failure mode to watch for is the agent that "fixes" the rejection rate by widening the verifier's tolerance; that reintroduces the original defect under a new name. ```python class UnsafeCommitBoundaryError(RuntimeError): """Raised when a plan would commit an irreversible action outside a locked, terminal-mutation transaction.""" pass IRREVERSIBLE = {"charge_card", "delete_resource", "send_external_notification"} def validate_commit_unit(plan_steps, locked_world_state, verify_fn): """ plan_steps: ordered list of tool-call dicts with 'tool' and 'precondition'. locked_world_state: live state read under the commit lock. verify_fn: callable(precondition, state) -> bool, evaluated inside the lock. """ irreversible_positions = [ i for i, s in enumerate(plan_steps) if s["tool"] in IRREVERSIBLE ] if not irreversible_positions: return True if len(irreversible_positions) > 1: raise UnsafeCommitBoundaryError( f"plan contains {len(irreversible_positions)} irreversible actions; " "commit unit must have exactly one terminal irreversible mutation" ) terminal = irreversible_positions[0] if terminal != len(plan_steps) - 1: raise UnsafeCommitBoundaryError( f"irreversible action {plan_steps[terminal]['tool']} at position {terminal} " f"is not terminal (plan length {len(plan_steps)}); " "a fallible action follows an irreversible one" ) for step in plan_steps: if not verify_fn(step["precondition"], locked_world_state): raise UnsafeCommitBoundaryError( f"precondition {step['precondition']!r} for {step['tool']} " "failed against locked world state; refusing to commit" ) return True ```