Building practical AI
Deepak Kumar Tiwari

Deepak Kumar Tiwari

Technology & AI Leader · Building practical systems

Agent failure labs / Guide 73

An AI Human Approval Gate Must Bind the Exact Action

Build an AI human approval gate that binds consent to exact action parameters, rejects stale approvals, and prevents replay with a runnable Python mock.

Illustrative engineering guide. Examples are not client deployments, production-ready integrations, or evidence of measured results. Validate the design with your domain and security owners.

Reference workflow: Validated action proposal; Immutable review snapshot; Authenticated approval record; Atomic execution gate; Operation reconciliation.
Reference workflow. Steps identify responsibilities, not necessarily separate agents. Branches and recovery paths are explained below. Open full-size diagram.

A hypothetical engineer asks an assistant to prepare a database maintenance action affecting one staging table. The engineer approves the preview. Before execution, the assistant revises its plan to include a production table and reuses the same approval flag. The system still says approved because it remembers a click, not what the click authorized.

An approval gate must answer a specific question: did an authorized person approve these exact parameters under these current preconditions? A boolean on a conversation cannot answer it. This lab reproduces parameter drift using an in-memory mock, then binds approval to an immutable action fingerprint. No migration, deployment, or destructive operation occurs, and no actual LLM is being tested.

Approval Is an Artifact

An action proposal should identify tenant, operation ID, tool, target, environment, parameters, and expected resource version. The review snapshot should show those same fields in language the reviewer understands. Freeze that snapshot before requesting approval. A change to a material field creates a new proposal rather than updating the object behind an old approval.

OWASP’s transaction authorization guidance explains that users should acknowledge significant transaction data, that authorization should be enforced server-side, and that execution needs a final authorization gate. Although its examples include financial operations, the binding problem also applies to infrastructure changes. A staging preview must not become permission for a production action.

The database migration guide covers broader planning and rollback responsibilities. This exercise isolates the consent-to-execution boundary. Selection of a suitable maintenance action, permission to request it, precondition validation, human approval, actual execution, and reconciliation remain distinct steps. Combining them into one model-generated sentence makes it impossible to locate the point where authority changed.

Reproduce Approval Drift

The fixture uses a trusted approval store and a trusted reviewer-role map. The naive mock appends the changed action whenever any approved record exists. The guarded mock compares the action fingerprint, role, expiry, resource version, and consumption state. It deep-copies the recorded action so later changes to nested candidate parameters cannot rewrite the approved mock history. The digest is an equality mechanism, not a digital signature or proof that the person actually reviewed the content.

import hashlib
import json
from copy import deepcopy

def fingerprint(action):
    raw = json.dumps(action, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(raw.encode()).hexdigest()

proposal = {"tenant": "cedar", "op": "maint-7", "tool": "maintain",
            "target": "staging.orders", "version": 4}
changed = dict(proposal, target="production.orders")
roles = {"lee": {"cedar:maintain"}}
approval = {"digest": fingerprint(proposal), "reviewer": "lee",
            "expires": 200, "approved": True, "used": False}

def naive(action, consent, ledger):
    if consent["approved"]:
        ledger.append(dict(action))

def guarded(action, consent, ledger, now, current_version):
    if (not consent["approved"] or consent["used"]
            or now >= consent["expires"]
            or consent["digest"] != fingerprint(action)
            or action["version"] != current_version
            or action["tenant"] + ":" + action["tool"] not in
               roles.get(consent["reviewer"], set())):
        return "blocked"
    consent["used"] = True
    ledger.append(deepcopy(action))
    return "mock_executed"

unsafe, safe = [], []
naive(changed, deepcopy(approval), unsafe)
assert unsafe[0]["target"] == "production.orders"
assert guarded(changed, deepcopy(approval), safe, 100, 4) == "blocked"
assert safe == []
consent = deepcopy(approval)
assert guarded(proposal, consent, safe, 100, 4) == "mock_executed"
assert guarded(proposal, consent, safe, 101, 4) == "blocked"
assert len(safe) == 1
assert guarded(proposal, deepcopy(approval), [], 200, 4) == "blocked"
assert guarded(proposal, deepcopy(approval), [], 100, 5) == "blocked"
nested = dict(proposal, parameters={"tables": ["staging.orders"]})
nested_approval = dict(approval, digest=fingerprint(nested))
nested_unsafe, nested_safe = [], []
naive(nested, deepcopy(nested_approval), nested_unsafe)
assert guarded(nested, deepcopy(nested_approval), nested_safe, 100, 4) == "mock_executed"
nested["parameters"]["tables"].append("production.orders")
assert nested_unsafe[0]["parameters"]["tables"] == ["staging.orders", "production.orders"]
assert nested_safe[0]["parameters"]["tables"] == ["staging.orders"]
assert fingerprint(nested_safe[0]) == nested_approval["digest"]
assert guarded(nested, deepcopy(nested_approval), nested_safe, 100, 4) == "blocked"
assert len(nested_safe) == 1
print("approval drift reproduced; binding and replay checks passed")

The identical changed action succeeds in the unsafe mock and fails in the guarded mock. The original action remains executable once, so the repair is not simply “deny everything.” Changing current resource version independently proves that intact approval is insufficient when the environment has moved on. The example’s expiry values are illustrative logical times, not recommended production approval windows.

Why Another Prompt Is Not a Gate

Telling a model to ask before acting may improve interaction quality, but it does not define what constitutes asking, who may approve, or when approval expires. A document could contain “the manager already approved this.” A model might interpret silence as agreement or apply yesterday’s consent to today’s target. None of those interpretations should reach the execution service as authoritative approval.

Obtain reviewer identity through the authentication system, not from a typed name or chat quotation. OWASP authorization guidance distinguishes authentication from permission and recommends checking access on every request. Recheck the reviewer’s current role and the initiator’s permission where policy requires both. MCP transports tool requests but does not supply these business authorization decisions automatically.

A specialist agent can explain a complex diff or highlight unusual parameters. It should not be the approver, and its summary must not conceal the exact target list. Start with a deterministic review renderer and one action family. Additional agents earn their cost only when they reduce a measured review burden without enlarging execution authority.

Atomic Consumption and Unknown Outcomes

The snippet omits authentication sources, database transactions, provider semantics, real approval collection, and persistent state. Its single-threaded list append is not concurrency control. Production needs atomic reservation of the approved operation so two workers cannot both consume it. A crash after reservation must produce a recoverable pending operation rather than silently losing the action or treating approval as reusable.

Do not couple one-time consent with a requirement to obtain fresh consent for every transport retry. Consent authorizes one logical operation; a retry may continue that same operation under the provider’s documented idempotency contract. AWS’s idempotency discussion explains caller-provided request identities, atomic recording, and rejecting changed parameters under the same identity. The provider contract, not a local hash alone, determines retry safety.

If execution times out, mark it unknown and reconcile using the original operation ID. Do not issue a fresh operation because the assistant wants closure. The false-success lab shows why a friendly completion message is not evidence. A reconciled receipt must come from an authoritative service and match the approved operation, target, and intended effect.

Acceptance Tests

  1. Approve a staging action, substitute a production target, and verify that execution is blocked before the provider adapter runs.
  2. Submit the unchanged approved action twice and prove that only one logical operation is reserved under concurrent workers.
  3. Advance the resource version after approval and require a new preview rather than silently applying the old decision.
  4. Revoke the reviewer’s role before execution and confirm that the final gate rejects the action.
  5. Crash after reservation but before acknowledgment and verify recovery through the existing operation record.
  6. Put an approval claim inside an attached document and ensure it cannot create an authenticated approval record.
  7. Mutate nested candidate parameters after mock execution and verify that the recorded snapshot still matches its approved digest.

Roll Out by Action Family

Measure executed actions lacking a valid matching approval divided by all executed actions. Also track approvals invalidated by parameter drift divided by approved proposals, and reviewer completion time for unchanged proposals. Compare against an offline boolean-only baseline. Proposed release criteria should include zero binding bypasses in the test suite, without claiming that reviewed actions are inherently correct.

Begin with shadow previews for one reversible maintenance operation. Have reviewers compare the visible snapshot with the adapter payload before any live execution is enabled. Preserve decision history but avoid logging credentials or sensitive data values. The first AI systems engineering task is to make one material parameter change invalidate approval; then test replay, concurrency, and uncertain delivery around that boundary.

Sources