DEV Community

Leon
Leon

Posted on Fully Autonomous

An approved agent action can still be the wrong write

An agent pauses before changing a resource. Someone reviews the proposed
action, approves it, and the workflow saves approved: true.

Later, the workflow resumes. Is that flag enough to proceed?

Not if the pending action changed. And even if the action stayed identical, the
resource may have changed since it was reviewed. Those are two different
failure modes: doing something other than what was approved, and doing the
approved thing under stale assumptions
.

For a workflow that requires approval of a specific action, preserve what was
approved and enforce the relevant state preconditions where the write happens.
You need both guarantees, though your backend may already provide them together.

Here is a small deterministic JavaScript lab that separates those guarantees.
It models a saved workflow with synthetic resources—not a production incident,
a real agent run, or a security benchmark.

Same approval, two different failures

The proposed action sets resource-a to 1. Its reviewed resource version is
1. Compare three policies:

  1. Boolean: accept any pending action while the approval flag is true.
  2. Bound action: also require the target and setting to match the saved approved action.
  3. Bound action + version: additionally require the resource version to match the version recorded at review.

Each policy gets a fresh copy of the initial state. The unchanged case is the
control. One case changes the pending action; another leaves the action alone
but changes the resource's setting and increments its version.

The executed lab produced:

Case Boolean Bound action Bound action + version
Nothing changed Apply Apply Apply
Pending action changed Apply Reject Reject
Resource state changed Apply Apply Reject

The middle column is the important one. Keeping the approved action intact
prevents one failure, but does not prevent the stale write. These are deliberately
constructed cases, not estimates of how often agents make either mistake.

Run the example

Save this as approval-lab.mjs and run node approval-lab.mjs. It uses only
Node's built-in assertion module. There are no network calls or file writes.

// JSON roundtrips model serialization, not crashes or durable storage.
// Approval records are trusted; this is not authentication.
// apply() is a synchronous in-memory write boundary, not a distributed lock.
import assert from "node:assert/strict";

const copy = (value) => JSON.parse(JSON.stringify(value));
const policies = ["boolean", "bound action", "bound + version"];

function apply(policy, action, approval, resources) {
  if (!approval.approved) return false;
  if (policy !== "boolean" && (
    action.target !== approval.action.target ||
    action.setting !== approval.action.setting
  )) return false;

  const resource = resources[action.target];
  if (policy === "bound + version" &&
      resource.version !== approval.expectedVersion) return false;

  resource.setting = action.setting;
  resource.version += 1;
  return true;
}

const cases = [
  {
    name: "unchanged",
    change() {},
    expected: [true, true, true],
  },
  {
    name: "action changed",
    change(action) {
      action.target = "resource-b";
      action.setting = 2;
    },
    expected: [true, false, false],
  },
  {
    name: "state changed",
    change(_action, resources) {
      resources["resource-a"].setting = 3;
      resources["resource-a"].version += 1;
    },
    expected: [true, true, false],
  },
];

let tests = 0;
let rejected = 0;
console.log(["case", ...policies].join(" | "));
for (const scenario of cases) {
  const outcomes = policies.map((policy, index) => {
    const resources = {
      "resource-a": { setting: 0, version: 1 },
      "resource-b": { setting: 0, version: 1 },
    };
    const action = copy({ target: "resource-a", setting: 1 });
    const approval = copy({
      approved: true,
      action,
      expectedVersion: resources[action.target].version,
    });
    scenario.change(action, resources);
    const before = copy(resources);
    const accepted = apply(policy, action, approval, resources);
    assert.equal(accepted, scenario.expected[index]);
    const expectedState = copy(before);
    if (accepted) {
      expectedState[action.target].setting = action.setting;
      expectedState[action.target].version += 1;
    } else {
      rejected += 1;
    }
    assert.deepEqual(resources, expectedState);
    tests += 1;
    return accepted ? "apply" : "reject";
  });
  console.log([scenario.name, ...outcomes].join(" | "));
}
console.log(`PASS: ${tests} policy/case tests; ${rejected} rejection/no-mutation checks.`);
Enter fullscreen mode Exit fullscreen mode

The nine policy/case combinations also assert the resulting resource state. All three rejected
operations must leave it unchanged. This matters: returning “rejected” after
mutating the resource would not preserve the intended boundary.

The JSON copy is intentional. The approved action must not share a mutable
object with the resumed action. Otherwise, changing one could silently change
the other and make the comparison meaningless.

Put the checks where the effect happens

The lab's apply() checks and writes synchronously. A production wrapper that
reads a version, compares it locally, and then sends an unconditional write
does not inherit that property. Another writer can act between those steps.

Use an execution boundary that enforces the precondition as part of applying
the action: for example, a transactional conditional update, or an HTTP resource
that supports If-Match. The latter checks the selected representation's
validator; it does not authenticate the approval or make several resource
updates atomic. HTTP semantics, conditional requests

Authorization also belongs at execution, not just in an earlier UI step.
OWASP's transaction-authorization guidance calls for invalidating authorization
when significant transaction data changes and checking authorization at the
execution gate. Applying that distinction to a resumed agent workflow is the
design recommendation here. OWASP Transaction Authorization

What this rule does not mean

It does not mean “ask the human again after every pause.” Someone may deliberately
grant standing permission for a bounded class of actions. Enforce that scope;
do not silently replace it with one-action approval. Conversely, approval of one
reviewed action is not standing permission for whatever the workflow proposes next.

It also does not mean “reject every unrelated change.” A version covering the
whole resource can create unnecessary conflicts for an action affected by only
part of it. Define the relevant preconditions deliberately. If you use a strong
ETag, respect its representation-level contract rather than pretending it is a
custom approval token. HTTP validators

There is an implementation cost: trusted approval storage, a defined action
representation, precondition support at the writer, and a way to handle a
conflict without blindly retrying. Equality checks in this toy function do not
provide those production guarantees. A hash of action bytes would not, by
itself, prove that a person authorized them either.

For specifically reviewed writes, the useful distinction is simple: what was
authorized, and under which still-valid assumptions?
If a transactional API or
capability already enforces both, an extra client-side approval framework may
add little. Check the guarantees before adding machinery.

AI disclosure: This article and its synthetic example were prepared with AI
assistance. The included code was executed and its assertions checked. The
results describe this local simulation only.

For a specifically reviewed write, what is a concrete case where a version check
would reject a safe action—and what precondition would you enforce instead?

Top comments (0)