Field note · implementation

Why Do Approved AI Actions Execute Against Stale State?

Approved AI actions can hit stale state when approval is treated as permanent permission. Revalidate the exact action, authorization, and record version before execution.

10 minute read
  • AI agents
  • human-in-the-loop
  • implementation
Illustration of an AI tool-call preview being checked again before execution

I’m building TryUncle, an AI agent that watches the screen and annotates it live. That makes timing and human approval part of the product boundary, not paperwork added after the tool works.

The failure I wanted to isolate was smaller: what happens between an approval click and the actual side effect?

Observed fixture result: On 2026-08-24, a dependency-free Node.js fixture ran 10 preview and approval cases. All 10 expected gates matched. Eight calls were blocked, and two executed. The two executions passed a final check of the canonical call, authorization context, schema, semantic rules, and target-state version. This is a fixture result, not a production rate.

Illustration of an AI preview failure matrix with blocked and revalidated execution paths

Reproduce the stale-state failure and read the trace

Run the dependency-free fixture, then inspect the saved JSON for the approved hash, current hash, target version, and final outcome. The trace makes the failure concrete instead of treating approval as an abstract UI event.

node preview-gate-fixture.js > preview-gate-output.json

The important traces are:

CaseTraceOutcome
Argument mutationApproved hash a69be249...; current hash 36916e96...; target version stayed 7Blocked as approved_call_changed
Target driftApproved at target version 7; execution saw version 8Blocked as target_state_drift
Serialized resumeApproved hash and current hash both 9637ff97...; target version matched; schema and semantic checks passedExecuted

The fixture tests the other boundaries too: malformed arguments stop before hashing, semantic policy failures stop before approval, rejected and cancelled calls never revalidate, expired approvals stay blocked, and a rejected sibling in a multi-call pause does not execute. The hashes above are shortened for readability; the complete values remain in the raw fixture output.

Diagnosis: approval is a contract, not a boolean

Approval should bind to the exact action the reviewer saw. If any part of that action changes, the approval is no longer valid. The bug is not that the reviewer clicked the wrong button; it is that the executor treats a past decision as permission for a possibly different call.

The contract in the fixture contains four values:

approved = hash(
  tool identity,
  canonical arguments,
  authorization context,
  checked target state
)

The tool identity is not only refund_invoice. It includes its version. The arguments are canonicalized before hashing, so key order does not create a false mismatch. The authorization context includes the actor, role, and tenant. The target state includes the invoice version that produced the preview.

This matches the useful part of current framework guidance. The OpenAI Agents SDK surfaces the tool name and arguments in a pending approval and scopes a stored decision to a specific call. It also supports serializing and reloading paused state. OpenAI’s human-in-the-loop documentation describes those mechanics.

The design addition is the final invariant: the executor must compare the approved contract with the current contract. A UI can display the right preview and still be unsafe if the worker later executes a different payload.

The failure matrix shows where approved actions break

The fixture separates preview-time failures from approval-to-execution failures. That distinction tells you where to repair the system.

CaseWhat changed or failedObserved gate
Schema-valid but unsafeAmount was valid JSON and matched the shape, but exceeded the semantic refund policyBlock before approval
Malformed argumentsJSON could not be parsedBlock before hashing
Argument mutationApproved amount changed before executionBlock on hash mismatch
Target-state driftInvoice version changed from 7 to 8Block on target-state mismatch
Two pending callsOne call was approved and one rejectedExecute only the approved call after its own revalidation
Cancellation or expiryThe reviewer cancelled, or the approval window endedBlock without execution
Serialized resumePending state was saved and loaded againExecute only after the same revalidation

The raw output records each proposed call, hash, preview, decision, revalidation result, and outcome in the fixture JSON. The test matrix gives the same cases in a compact form.

That artifact is the sourceable result here. Another engineer can run the fixture, change one gate, and see which invariant disappeared.

Schema validation does not prove that an action is safe

Schema validation answers whether the arguments have the expected shape. Semantic validation answers whether the requested action is allowed for this target, actor, and state.

The fixture uses a deliberately obvious example. This argument is schema-valid:

{
  "invoiceId": "INV-100",
  "amountCents": 150000,
  "currency": "EUR"
}

It is still unsafe because the fixture's policy rejects refunds above 1000 EUR. The preview reports schemaValid: true and semanticSafe: false, then blocks the call before asking a reviewer to approve it.

Malformed input fails earlier. The OpenAI Agents SDK documents fail-closed behavior when approval logic cannot safely inspect malformed JSON or a non-object value. That behavior is documented here. The executor should keep the same boundary even if a different framework lets malformed data travel farther.

MCP makes a similar separation visible in its protocol shape: a tool has a name and an input schema, then a tools/call request carries the name and arguments. The MCP server-tools specification defines those fields. The schema says what can be parsed. Your policy still has to decide what may run.

Revalidate immediately before execution

The approval gate is only complete when the worker checks the same contract again.

Use this sequence:

  1. Parse the proposed arguments. Reject malformed input.
  2. Validate the argument schema. Reject missing, wrong-type, or out-of-range fields.
  3. Validate semantic safety against the current target and policy. Do not ask a person to approve a call that policy already forbids.
  4. Render the intended effect, exact arguments, actor, tool version, expiry, and target version.
  5. Store the canonical payload hash with the approval decision.
  6. At execution time, fetch the current target and authorization context.
  7. Recompute the hash and rerun schema and semantic validation.
  8. Execute only if the hash, authorization, target version, and validations still match.

The last step is the one demos often skip. A reviewer may approve INV-100 at version 7. A worker may later load version 8. The old preview is now a description of history, not permission for the current write.

If you use a framework that supports edited approvals, preserve the same boundary. Current LangChain guidance allows approve, edit, and reject decisions, and requires a separate decision for each pending action. Its human-in-the-loop documentation also describes persistence for pause and resume. An edit must create a new canonical payload and a new preview. It must not inherit the old hash.

Multiple pending actions need separate decisions

Treat a pause containing several calls as a set of independent approval records, not one batch-level yes.

In the fixture, two refund calls were pending. The first was approved and executed after revalidation. The second was rejected and never reached revalidation or execution. This keeps a reviewer from accidentally authorizing a sibling call by approving the batch.

The same rule appears in current LangChain guidance: when multiple tool calls are paused, each action needs its own decision, in the order supplied by the interrupt. Microsoft’s Agent Framework likewise exposes the function call and its arguments and tells the caller to continue checking for approval requests after each run. See Microsoft’s tool-approval guidance.

Store these fields per call:

FieldWhy it exists
callIdPrevents a decision for one call being applied to another
approvedPayloadHashBinds approval to the exact preview
decisionSeparates approve, reject, cancel, and expiry
expiresAtPrevents an old preview from becoming permanent permission
targetVersionDetects state changes between preview and execution
stateVersionLets resume code reject incompatible serialized state

Resume is safe only when state is still current

Serialization preserves a pause. It does not make the paused decision timeless.

The OpenAI Agents SDK supports durable RunState serialization and advises storing a version marker when pending work may sit for a while. That lets the application route old state to a compatible code path. The same documentation covers long-running approvals and versioning.

MCP’s elicitation flow shows a related multi-round-trip pattern: a tool can request structured input, and the client retries with the response and request state. The MCP elicitation specification defines that exchange. Collecting more input is not the same as reauthorizing a side effect. The retry still needs a current target check before a write.

On resume, use this decision tree:

Resume conditionAction
Same tool version, same hash, same authorization, same target version, unexpiredRevalidate and execute
Arguments editedDiscard the old approval and preview the edited call
Target version changedBlock, refresh state, and preview again
Tool or policy version changedRoute through the current compatibility path or require a new decision
Rejected, cancelled, or expiredKeep the call non-executable
Serialized state cannot be trusted or parsedFail closed and ask for a new run

Repair at the executor boundary

You do not need to redesign the whole agent to fix this class of failure. Put the invariant at the boundary where a proposed call becomes a side effect.

preview(call, context, target)
  -> parse
  -> schema check
  -> semantic policy check
  -> render exact effect
  -> hash canonical contract
  -> wait for decision

execute(approvedCall)
  -> load current context and target
  -> recompute canonical contract
  -> compare hash and expiry
  -> rerun schema and semantic checks
  -> execute or block with a reason

Keep the block reason visible: approved_call_changed, target_state_drift, approval_expired, or semantic_policy_failure. A generic “approval invalid” message makes the next repair harder and weakens the audit trail.

Verify the repair with the same fixture

The repair is verified when every expected gate matches and no call executes without a passing final revalidation. Rerun the fixture after changing the executor, then check the summary in the saved output:

Verification checkRequired result
calls10
expectedMatches10
allExpectedGatesMatchedtrue
executed and blocked2 and 8 in this dated fixture

The two executions are useful only as positive controls. They passed because the canonical hash, authorization context, schema, semantic policy, and target-state version still matched. The eight blocked cases verify that mutation, drift, malformed input, rejection, cancellation, expiry, or policy failure cannot cross the side-effect boundary. This fixture does not verify database isolation, concurrency, latency, or model behavior, so those require separate tests.

If you are starting from a dry-run design, use the parent guide on implementing dry-run AI actions. If your failures happen before approval, validate AI agent inputs before a run. This post covers the boundary between an approved action and a side effect against a possibly changed record.

The same stale-state rule has established forms outside agent frameworks. HTTP If-Match lets a server require a matching entity tag before a state-changing request, and PostgreSQL documents how concurrent updates interact with transaction visibility. For a versioned record, make the version check part of the write condition, not only a preflight read. HTTP Semantics and PostgreSQL's transaction-isolation documentation provide the underlying patterns.

The short rule is simple: approval authorizes one canonical call against one checked state. When the call or the state changes, stop. Preview again.

Primary sources

Questions people ask next

What should an AI approval bind to?

Bind approval to the exact tool identity, canonical arguments, authorization context, target-state version, and an expiry time. A later mismatch requires a new preview.

Can a reviewer edit an AI tool call after preview?

Yes, but treat the edit as a new proposed call. Recompute the preview and canonical hash, then request approval for the edited call. Do not execute an edited payload under the old approval.

What should happen when the target changes after approval?

Block execution, report the target-state drift, refresh the target, and create a new preview. Do not silently apply the old decision to the new state.