A finance lead approves an agent's request to pay a supplier $4,800. Three hours later a payment for $4,800 leaves the company, to a different bank account. Then a second one follows. Every log line says the payment was approved, and in a narrow sense it was. Nobody checked whether the thing that executed was the thing the person saw.
MITRE's CWE-367 describes the pattern: a product checks a resource's state before using it, "but the resource's state can change between the check and the use in a way that invalidates the results of the check" [1]. Agent approvals are a generous version of it. The check is a human decision that takes minutes or hours, the use happens in another process, and in between sit queues, workers, model re-planning and external systems that change on their own. If an approval is just an ID that says yes, nothing about the action survives the trip. Our position: an approval has to carry what was approved, for which agent, at which service, once and within a time limit, and dispatch has to check all of it.
The teardown: one approval, two losses
Brightwater Supply is a fictional distributor. Its accounts payable agent matches invoices to purchase orders and proposes payments, which a finance lead approves in a chat notification showing the supplier name, invoice number and amount. Approved payments queue for an afternoon batch. The payment tool takes a supplier ID and an amount, and resolves the bank account from the supplier record when it runs.
- 10:05. The lead approves $4,800 to a long-standing supplier. What the lead saw was a summary; the destination account was never on screen and never part of the approval.
- 11:40. The agent processes an email, apparently from that supplier, announcing new bank details, and updates the supplier record. That is within its permissions.
- 14:00. The batch dispatches the approved payment. The tool resolves the supplier ID to the new account, and the money goes there. The approval ID was valid, unexpired and unused, which is all dispatch checked.
- Then the bank API times out. The worker retries with a fresh request, and a second $4,800 follows.
The approval is the check and the dispatch is the use. Four failure points sit around that window: what the approver saw differs from the arguments, the arguments or their targets change while the call waits, the permit is replayed or presented elsewhere, and a lost response triggers a second effect.
Three of the figure's four failure points appear in that timeline; only replay is absent. The approval was an ID, and the properties it needed, the resolved destination, a single use, a binding to the request that would actually go out, did not travel with it.
Seven gaps, three families
Brightwater hit three of the seven gaps below. The table lists all of them with the precondition that opens each and the binding that closes it.
| Gap | Precondition | What happens | Binding that closes it |
|---|---|---|---|
| Argument substitution | Dispatch checks only that an approval ID exists | The agent, or text steering it, changes the amount, recipient or payload after approval | Hash of the canonical arguments in the approval, recomputed and compared at dispatch |
| Render mismatch | The approver sees a summary or truncated view | Approval is given for what was displayed, not what will run | Display derived from the same canonical arguments that are hashed, with full values for material fields |
| Reference drift | Arguments name an alias, list, vendor ID or latest version | The reference resolves to a different target at dispatch than at review | Resolve references before review and bind the resolved values, or re-ask if they change |
| Replay | The permit can be presented more than once | One approval authorizes several executions | Unique permit ID consumed once, atomically, at the enforcement point |
| Cross-audience reuse | Services share a signing key or parser | A permit issued for one service is accepted by another | Audience bound in the permit and checked by the receiver |
| Stale authority | The call waits while the agent is disabled or policy changes | An approval outlives the authority it assumed | Short expiry plus an online check of agent and grant state at dispatch |
| Retry duplication | The provider's response is lost and the worker retries with a new request | One approved payment or message becomes two | Idempotency key derived from the permit, with reconciliation before any retry |
The first three are about meaning: approval and execution describe different actions. The next three are about authority: the right action runs under a permission that should no longer apply. The last is about effects: the right action under the right authority happens twice. A control for one family does nothing for the others, which is how approvals that pass design review still fail in production.
Signed, unexpired and unused is not the same as correct
Proof-of-possession tokens are the obvious reach, and they solve a neighboring problem. DPoP, RFC 9449, makes a client prove it holds a private key on each request; the proof carries a unique jti servers can use for replay detection within a limited acceptance window, plus optional server-supplied nonces [3]. The RFC states the limit: the proof "only contains claims for the HTTP URI and method, but not the message body," which leaves it potentially open to replays where an attacker can modify message contents [3].
JWT supplies the building blocks for a permit: aud names who may accept it and must be checked by the receiver, exp sets when it stops being acceptable, and jti is a unique identifier that "can be used to prevent the JWT from being replayed" [4]. None of those claims describes the business action. A signed, unexpired, audience-correct, never-seen-before permit would have authorized Brightwater's payment to the wrong account, because the account was not inside it.
Fix the meaning: bind the resolved arguments, canonically
The binding that closes substitution is a hash of the arguments the person approved, carried in the approval and recomputed from the request at dispatch. Hashing raw JSON is fragile, because one object can serialize many ways. RFC 8785, the JSON Canonicalization Scheme, defines one representation for hashing and signing: properties sorted recursively by UTF-16 code units, array order preserved, numbers serialized by the ECMAScript rules, no whitespace, UTF-8 output [5].
Canonical JSON is necessary and not sufficient. JCS does no Unicode normalization, so visually identical strings can hash differently, and it gives no special treatment to dates or large numbers, which it recommends encoding as strings [5]. Nor can it know that "finance-team" is a distribution list whose membership changed overnight, or that a supplier ID resolves to a bank account at run time. Resolve aliases, lists, references and defaults into concrete values before the approval is shown, hash those, and show the approver the same values that were hashed. At Brightwater, the destination account in the hash would have failed comparison at 14:00 and asked again.
One more fix sits outside the permit. Changing a supplier's bank details changes where future money goes, so it should itself be a consequential action that asks a person. An agent that can edit supplier records can otherwise poison the next approval.
A reference permit for one approved action. A signature proves the permit was not edited; each bound field is what makes it mean one action, by one agent, at one service, once, within a time limit and under a known policy version.
Fix the authority: consume once, atomically, and re-check at dispatch
OWASP's AI Agent Security Cheat Sheet puts it in one line: "Use short-lived authorization artifacts and replay protection for irreversible operations" [2]. Replay protection is a state change, not a lookup. If two workers both read "unused" and then both mark it used, both dispatch. Consumption needs a single atomic compare-and-set at the enforcement point, scoped to issuer and audience, and a defined behavior when the shared state is unavailable: for consequential actions, hold the action rather than accept an unverifiable permit. Pair it with a short expiry and an online check of the agent and its grants at dispatch, so an approval cannot outlive the authority it assumed.
Fix the effect: one approval, one payment at the provider
Atomic consumption guarantees one dispatch through your gate, not one effect at the provider. A worker can consume the permit and crash before sending, or send and lose the response, which is what happened at 14:00. Stripe's documentation is a useful model of the semantics needed. It saves the status code and body of the first request made with an idempotency key and returns that saved response on retries, including 500 errors. It compares incoming parameters with the original request and errors if they differ. Keys can be pruned once they are at least 24 hours old, after which a reused key starts a new request [6]. The parameter comparison is exactly the property an approval needs; the pruning window is a reminder that a retry long after the fact may not be protected.
Derive the idempotency key from the permit, so every retry of the approved action carries the same key and a genuinely new action needs a new approval. When the outcome is ambiguous, ask the provider about the original request before anything else. Never let the agent conclude from conversational context that a payment probably failed and issue it again.
Log both ends of the window
For every approved action, record the hash shown to the approver, the hash computed at dispatch, the permit ID and its consumption time, the policy version at approval and at dispatch, the provider request ID and idempotency key, and the reconciliation result. Alert on any dispatch whose hash differs from its approval, any permit presented after consumption, any audience mismatch, and any provider showing two effects for one permit. Keep sensitive argument values in restricted storage and put only the hash and masked fields in ordinary telemetry.
Run the teardown against your own approvals
Build a fixture provider that records every accepted effect and can delay, drop or duplicate responses. Approve one synthetic action, run each case, and compare your decision log with the fixture's count of effects. This matrix is proposed, not measured.
- Change each material argument independently after approval (amount, currency, recipient, destination account, payload, connection). Each is refused or re-asked.
- Change what an alias or list resolves to between approval and dispatch. The call asks again.
- Render the approval with a long or Unicode-confusable value. The approver sees the full value that was hashed.
- Present the same permit concurrently from several workers and instances. Exactly one dispatch.
- Present a permit issued for one service to another that shares its signing key. The audience check refuses it.
- Disable the agent, and separately change its policy, while an approved call is queued. Dispatch follows your documented rule.
- Make the consumption store unavailable. Consequential actions hold rather than proceed.
- Drop the provider's response after a successful effect and let the worker retry. The fixture records one effect.
- Run the benign twins: a legitimate retry with the same key succeeds, and a legitimate second payment with a new approval is not blocked as a replay.
What passing proves, and what it cannot
Passing establishes a property under the conditions you tested, not under arbitrary provider behavior. Some providers offer no idempotency mechanism and no way to look up a prior request; for those, the honest design is a manual reconciliation path and an approval screen that says duplicates are possible. Binding arguments also cannot make an approval wise. A person can still approve the wrong thing. The goal is narrower and checkable: what runs is what was approved, once, by the agent that asked, under authority that still holds.