DEV Community

Zira
Zira

Posted on

Your AI Agent Approval Is Stale Until You Bind It to a Version

An approval is not a permanent yes. In an AI-agent system, it is a short-lived authorization decision over a specific request, policy version, resource set, and expected effect.

If you store only approved: true, a delayed worker can execute an action after the user changed the policy, the target changed, or the request was replaced. The dangerous part is that the audit log still looks like the user approved it.

This article shows a small approval contract, the dispatch checks that make it enforceable, and a failure-injection test for stale approvals.

The approval record needs an identity

Bind approval to the thing that was reviewed, not just to a human or conversation. A minimal record can look like this:

{
  "approval_id": "ap_01",
  "request_id": "req_782",
  "effect_key": "github:merge:repo-a:pr-91",
  "policy_version": 12,
  "resource_digest": "sha256:...",
  "decision": "ALLOW",
  "expires_at": "2026-08-19T08:20:00Z",
  "used_at": null
}
Enter fullscreen mode Exit fullscreen mode

The effect_key identifies the intended side effect. The resource_digest is a hash of the material the reviewer saw: repository, pull request, selected files, recipient, amount, or other target-specific inputs. If those inputs change, the approval no longer matches.

Do not use a model-generated summary as the identity. Keep the identity in a deterministic envelope outside the model.

Recheck at dispatch, not only at approval time

A safe approval flow has two checks:

  1. Review time: construct the exact effect envelope and ask for approval.
  2. Dispatch time: load the record again and compare every binding before calling the tool.

Pseudocode:

def authorize_dispatch(approval, current):
    if approval.decision != "ALLOW":
        return "DENY"
    if approval.used_at is not None:
        return "DENY"
    if now() >= approval.expires_at:
        return "DENY"
    if approval.request_id != current.request_id:
        return "DENY"
    if approval.effect_key != current.effect_key:
        return "DENY"
    if approval.policy_version != current.policy_version:
        return "DENY"
    if approval.resource_digest != current.resource_digest:
        return "DENY"
    return "ALLOW"
Enter fullscreen mode Exit fullscreen mode

The final database update and the tool dispatch need a clear boundary. If the tool provider supports an idempotency key, use a stable key such as approval_id plus effect_key. If it does not, record DISPATCH_INTENT before the call and treat a timeout as UNKNOWN, not as a reason to ask for a second approval automatically.

An approval should be single-use. Marking it used before the provider call prevents duplicate dispatch, but it can leave an ambiguous result if the process dies after the mark and before the call. Marking it after the call allows a crash window for duplicate execution. Pick the tradeoff explicitly and add reconciliation with the provider when possible.

Policy changes must invalidate old decisions

A policy version is useful only if it participates in the comparison. Increment it when a relevant rule changes, such as:

  • a new protected branch rule
  • a narrower tool or workspace scope
  • a changed spending limit
  • a revoked credential or identity
  • a changed recipient or data classification

You can invalidate approvals eagerly by writing a revocation event, or lazily by rejecting any record whose version is not current. Lazy invalidation is often simpler, but the current version lookup must be durable and available at dispatch time.

Do not silently convert a stale approval into a new approval. Return a reason such as POLICY_VERSION_CHANGED and require a fresh review of the new envelope.

Test the stale-worker scenario

The most valuable test pauses a worker between approval and dispatch:

  1. Create request req-782 with policy version 12.
  2. Approve effect github:merge:repo-a:pr-91.
  3. Pause the worker.
  4. Change the policy to version 13, or change the pull request digest.
  5. Resume the worker.
  6. Assert that no provider call occurs.
  7. Assert that the rejection records the old and current versions.
  8. Approve the new envelope and assert that only the new approval can dispatch.

Also test expiry during a long queue delay, two workers racing on one approval, a request being replaced while the approval UI is open, a revoked credential, a provider timeout, and a retry after UNKNOWN.

Useful evidence fields include approval_id, request_id, policy_version_at_review, policy_version_at_dispatch, resource_digest_at_review, resource_digest_at_dispatch, decision, rejection_reason, and the provider idempotency key.

Hosting does not solve approval semantics

An always-on runtime can make it easier to keep the approval ledger, worker, and reconciliation loop available, but it does not decide whether an approval is fresh or correctly scoped. If you need managed infrastructure for an OpenClaw or browser-automation worker, managed always-on agent hosting on Ampere is one option to evaluate. You still own the policy contract, credential boundaries, idempotency behavior, and prompt-injection defenses.

The practical rule

Treat approval as a capability with an expiry, a single-use state, a policy version, and a deterministic effect identity. Recheck all four at dispatch. When any binding differs, stop and request a new decision.

That small contract prevents a common class of failures where the system can prove that someone approved something, but cannot prove they approved the thing that eventually ran.

Top comments (0)