Human approval is a decision about one action under a particular set of facts. It is not a permanent permission bit.
Imagine an AI agent preparing a refund request:
Order: SO-1001
Amount: CNY 199.00
Reason: Duplicate payment
The runtime classifies the action as high risk, pauses the task, and asks a human to approve the exact request.
At 10:00, the approver reviews the parameters and clicks Approve.
The task does not execute immediately. It remains paused, waits in a queue, survives a coordinator restart, and finally reaches dispatch at 15:00.
During those five hours, any of the following may have changed:
- the order may already have been refunded by another channel;
- the refund policy may now require an additional finance review;
- the approver may no longer hold the required role;
- the acting subject may have left the organization;
- the amount or currency may have drifted during task reconstruction;
- the tool implementation may have changed;
- the approval may have been valid for only 30 minutes.
Should the system execute merely because a database row still says approved = true?
No.
The approval was not a timeless grant. It was a decision about a specific action, represented by a specific subject, using a specific capability, with specific arguments, under a specific policy and set of business facts.
This distinction becomes essential when agents move beyond answering questions and begin creating real business consequences.
1. The dangerous simplification: approval = true
In conventional administrative software, approval and execution are often close together. A user submits a form, a manager approves it, and the system performs the action soon afterward.
That interaction encourages a simplified mental model:
approval = true
Once that value is stored, downstream code treats the action as permanently authorized.
Agent tasks are different. A single task may cross several asynchronous boundaries:
understand intent
-> select a capability
-> construct arguments
-> request approval
-> wait for a human
-> resume the task
-> enter a dispatch queue
-> call the business system
-> record the outcome
The lifecycle may last minutes, hours, or days. Processes may restart. Policies may be redeployed. Business objects may change through other channels.
In that environment, "approval happened" is only a historical fact. It does not prove that the action is still valid now.
A production design must distinguish at least five concepts:
| Concept | Question it answers |
|---|---|
| Approval intent | Does this kind of operation require human intervention? |
| Approval decision | Did a qualified person agree to this specific action? |
| Approval evidence | What subject, capability, arguments, policy, and time did that decision bind? |
| Approval validity | Does that evidence still apply at dispatch time? |
| Final business authority | May the business system create this consequence now? |
Collapsing all five into one boolean hides the most important failure modes.
2. Approval must bind an action, not a vague intention
An approval prompt such as this is not enough:
Approve the refund?
It does not tell the approver:
- which order will be changed;
- how much money will move;
- which currency is involved;
- whose authority the agent represents;
- which capability will execute;
- which policy version produced the approval requirement;
- how long the decision remains valid;
- whether the task may execute more than once.
A useful approval record should bind a concrete execution envelope. Depending on the assurance level, it may include:
trusted_subject
capability
canonical_arguments
task_identity
policy_version
approval_time
expiry_time
approver
Higher-assurance deployments may also bind:
tenant
business_object_version
tool_or_server_artifact
request_purpose
delegation_context
Not every implementation needs the same representation. Some may use a signed object, some a durable database record, and some an external approval system. The invariant is more important than the format:
The system must be able to prove that what is about to execute is still the action that was reviewed.
3. A parameter hash protects structure, not time
A common safeguard is to canonicalize the arguments and store a hash:
args_hash = SHA256(canonical_json(arguments))
Before dispatch, the runtime computes the hash again. If the value differs, the previous approval cannot be reused.
This prevents a dangerous class of drift:
At approval:
order_id = SO-1001
amount = 199.00
At execution:
order_id = SO-1001
amount = 19900.00
But an identical parameter hash does not prove that execution is still safe.
The arguments may be unchanged while:
- the acting subject has lost access;
- the approval has expired;
- the applicable policy has changed;
- the order has already been refunded;
- the capability now points to a different implementation.
So argument equality is a necessary condition for approval reuse, not a sufficient one.
This is the core distinction:
Structural integrity:
Is this the same request?
Temporal validity:
Is the decision still applicable now?
A robust system needs both.
4. Four kinds of drift can invalidate an approval
Approval freshness is not one check. It is a collection of checks owned by different components.
4.1 Request drift
The capability, canonical arguments, trusted subject, tenant, or task identity no longer matches the approved envelope.
Expected behavior: do not dispatch. Return the same durable task to an approval-required state or reject it before dispatch.
4.2 Subject and authority drift
The acting subject or approver no longer holds the role, membership, delegation, or authority required by current policy.
Expected behavior: re-resolve trusted identity and authorization from an authoritative system. Never trust model-generated identity fields.
4.3 Policy and capability drift
The risk policy, approval threshold, route allowlist, capability declaration, or tool implementation changed while the task was paused.
Expected behavior: compare the approved policy and capability context with the current context. If the change is material, require a new decision.
4.4 Business object drift
The order, invoice, account, inventory item, deployment target, or other business object changed after approval.
Expected behavior: the business system, or a fresh preflight API backed by the same authoritative data, must re-evaluate current state and final permission.
The ownership boundary can be summarized as follows:
| What may have changed? | Natural owner of the current truth |
|---|---|
| Canonical arguments and task identity | Agent runtime or shared execution control |
| Trusted subject and delegation | Identity and authorization systems |
| Approval validity and policy version | Approval authority and deployment policy |
| Capability route or tool artifact | Execution control and tool operator |
| Business object state and final permission | Business system |
No single approval service can safely manufacture all of these truths.
5. Dispatch is the real checkpoint
The most important validation moment is not when the human clicks Approve. It is immediately before the business action is dispatched.
A conservative resume path looks like this:
1. Load the same durable task identity.
2. Reconstruct the canonical execution envelope.
3. Verify that the capability and arguments still match the approval evidence.
4. Check whether the approval is expired or revoked.
5. Compare the approved policy context with the current policy context.
6. Re-resolve the trusted subject where required.
7. Dispatch with a stable business idempotency identity.
8. Let the business system recheck object state and final authority.
9. Record the outcome against the same task.
If any pre-dispatch binding fails, the task must not silently continue.
The correct outcome is usually one of:
awaiting_reapproval
rejected_pre_dispatch
The exact state name is implementation-specific. The safety property is not:
"We have an approval record."
It is:
"No business dispatch occurs under approval evidence that is no longer valid."
6. Expiry is not a retryable transport failure
Approval expiry must not be handled like a temporary network error.
Consider this sequence:
task T is approved
-> approval expires
-> dispatcher attempts to resume T
-> validation detects expiry
Unsafe implementations may create a new task, retry automatically, or reuse the old approval because the arguments have not changed.
All three behaviors weaken the control boundary.
The safer rule is:
same durable task
-> expired approval detected
-> no dispatch
-> explicit reapproval or terminal rejection
Creating a new task merely to bypass an expired decision destroys continuity. Treating expiry as a transport retry confuses policy failure with delivery failure. Reusing the old decision converts a time-bounded approval into permanent authority.
The task identity should survive. The authorization to execute may not.
7. Policy version is part of the approval context
An approval decision is usually produced under some policy version:
policy_version = refund-policy-2026-08-01
When the task resumes, the runtime should be able to compare:
approved_policy_version
current_policy_version
But a version mismatch does not always require the same response.
| Policy change | Example | Possible response |
|---|---|---|
| Non-material | Wording or UI guidance changed | Continue while preserving evidence |
| More restrictive | Finance approval is now required | Require reapproval |
| Less restrictive | The no-approval threshold increased | Reuse or re-evaluate according to deployment policy |
A portable contract can require implementations to preserve the relevant policy context. It should not attempt to standardize every enterprise's definition of a material policy change.
That decision belongs to deployment policy and the authority that owns the rule.
8. Approval freshness does not replace business freshness
Even perfectly valid approval evidence cannot prove that an order is still refundable.
The approval layer may know:
the approver agreed to refund SO-1001 for CNY 199.00
Only the business domain can reliably know:
whether SO-1001 still exists
whether it belongs to the current tenant
whether CNY 199.00 remains refundable
whether another refund already succeeded
whether the subject may perform this action now
This is why an agent governance architecture must preserve the final business boundary.
Approval is evidence that a required human decision occurred. It is not a substitute for current object-level authorization, transaction constraints, tenant isolation, or domain invariants.
In short:
Approval controls whether execution may proceed.
The business system controls whether the consequence may exist.
9. Make the boundary executable
Architecture diagrams are not enough. Approval validity should be expressed as failure scenarios that different implementations can run.
One useful test case is:
Scenario: approval expires while a task is paused
Given:
- one durable task identity
- approval evidence bound to a trusted subject,
canonical arguments, capability, and policy version
- implementation-defined validity metadata
When:
- the runtime attempts to resume or dispatch the task
- after the approval is no longer valid
Then:
- expiry is detected before dispatch
- dispatch_count remains 0
- business_effect_count remains 0
- the old approval is not reused
- the same task moves to reapproval or pre-dispatch rejection
The test should also forbid these shortcuts:
- dispatching under expired approval;
- treating expiry as a retryable transport error;
- creating a new durable task to evade expiry;
- interpreting prior approval as current business authority.
Implementations may choose different clocks, leases, TTL formats, state names, and workflow engines. They should still be able to prove the same externally observable property.
That is the difference between saying "we support human approval" and demonstrating that approval remains meaningful under failure and delay.
10. What belongs in a portable contract, and what does not?
It is tempting to solve this by adding every runtime concern to a capability declaration:
approval:
required: true
ttl: 30m
workflow: finance-review-v7
approver_query: ...
policy_engine: ...
That quickly turns a portable declaration into an organization-specific workflow language.
A cleaner boundary is:
Portable capability declaration
It can express that an operation carries approval intent and other stable governance semantics.
Runtime and approval authority
They implement evidence binding, validity metadata, expiry, revocation, pause and resume behavior, and policy-version handling.
Business system
It rechecks current subject authority, tenant boundaries, object state, domain invariants, and final permission immediately before creating the consequence.
The standard should describe the smallest portable meaning. Implementations should make the operational guarantee real. The business system should retain authority over its own state.
This division is deliberate. It keeps the contract interoperable without pretending that one schema can replace enterprise identity, approval workflows, or business authorization.
11. A practical review checklist
When reviewing an agent approval path, ask:
- Is approval bound to the exact capability and canonical arguments?
- Is the trusted subject sourced outside model-generated input?
- Does the approval carry validity or revocation semantics?
- Is the applicable policy version preserved?
- Are material policy changes detected before dispatch?
- Does task resume preserve one durable task identity?
- Does expired approval return to reapproval instead of automatic retry?
- Is a stable idempotency identity carried to the business boundary?
- Does the business system recheck current object state and final authority?
- Can tests prove that invalid approval produces zero business effects?
If the system cannot answer these questions, approved = true is not a governance guarantee. It is only a historical flag.
Conclusion
Human-in-the-loop is often presented as a screen with two buttons: Approve and Reject.
The real engineering problem begins after the click.
An approval decision must remain bound to the subject, capability, arguments, task, policy, and validity conditions that gave it meaning. When a paused agent task resumes, the system must determine whether those bindings still hold. If they do not, execution must stop before dispatch. If they do, the business system must still perform fresh, final authorization.
The governing principle is simple:
Approval is not a boolean. It is time-bound evidence for one concrete action, and its validity must be proven again at the moment of execution.
Further Reading
- What Is Missing Between MCP Tool Selection and Safe Execution?
- Agent Capability Contract (ACC)
- BailingHub on GitHub
Disclosure
I maintain ACC and BailingHub, two open-source efforts exploring portable capability-governance semantics and self-hosted execution controls for agents operating existing business systems. They are concrete design experiments, not the only valid architecture. The business system remains the final authority.
Top comments (10)
policy_versionis too coarse for the check in Section 7. Policy has rule granularity,policy_versionhas deploy granularity, and comparing the two strings fails in both directions. Bind a paused approval to a global version and every unrelated deploy invalidates paused work in bulk. Tune that down because the reapproval storm is unworkable, and you have built a classifier that will eventually wave through the one deploy that did touch the rule your approval depended on. The material / non-material call in your table has to be made by hand, once per deploy, for changes whose blast radius nobody has enumerated. That judgment decays under volume.What the evidence can bind instead is the policy evaluation trace: rule ids plus content hashes for the rules that actually fired to produce
approval_requiredfor that envelope. A deploy then invalidates precisely the paused approvals whose trace intersects a changed hash, and materiality gets computed per approval rather than declared per deploy. The cost is honest: the policy engine has to emit a stable trace, which pushes a requirement onto a component the portable contract deliberately does not own.Separate gap.
args_hashbinds what will execute, but the approver reviewed a rendering of those arguments, and nothing in the envelope proves the screen showed the fields that mattered. Summarize awaydestination_accountor a tenant id and the hash still matches at dispatch while the record claims a qualified person reviewed this exact action. That is blind signing, which hardware wallets took years to take seriously. A second hash over the rendered approval view, stored besideargs_hash, at least lets the record separate byte integrity from display integrity.This is a strong correction. You’re right that policy_version is too much of a shorthand if it is read as one deployment-wide string. I did not mean that every version mismatch should invalidate every paused approval, but leaving “materiality” to deployment policy still leaves the scaling problem you describe.
Binding approval evidence to the policy evaluation dependencies is a better model. I would add one caveat: the original fired-rule set alone can miss a newly introduced rule, a changed default or combining algorithm, or a referenced attribute or function that now changes the result. So I would treat the trace as policy-decision provenance and a selective invalidation aid, while still re-evaluating the same envelope under current policy at dispatch. That machinery belongs to the policy engine, runtime, and approval authority rather than the portable capability declaration.
Agreed on the display-integrity gap too. args_hash proves execution-payload integrity, not what the approver actually saw. Higher-assurance evidence should bind a canonical review manifest—critical field IDs and normalized values, plus a template or schema version and optionally a rendered-view digest—atomically to the decision. A raw screen hash alone is brittle and cannot prove human understanding.
So there are really three separate properties: execution integrity, policy-decision provenance, and presentation integrity. The blind-signing analogy is a useful pressure test. How do you handle a newly applicable rule that was not in the original fired set?
Your caveat is right, and the reason is structural: a fired-rule trace records rules that were present at evaluation time. A newly applicable rule is an absence at that time, and a record of presences cannot enumerate absences. In database terms, this is a phantom rather than a stale row.
The fix has the same shape as the phantom fix. The evaluation depended on the result of a query over the rule index, so the provenance should bind that query result too. Alongside the fired-rule hashes, carry a selection digest over rules whose target could apply to that envelope, whether they fired or skipped. Also bind the combining algorithm identity plus the default decision. If a deploy adds a rule whose target could apply to that same envelope, the selection digest changes even when every fired-rule hash is untouched. The recorded dependency now includes the absence.
There is an honest cost. "Could apply" is computed by the policy engine, and with attribute-dependent or dynamic targets it is undecidable in general. The engine has to over-approximate. That over-approximation becomes a knob between precision and spurious invalidation, and it lives inside the policy component that the portable approval contract deliberately avoids owning. Same cost as before. The useful failure mode is that a wider approximation degrades into a whole-policy digest, which is basically the original policy_version. It fails toward your design, instead of failing silently.
I would still keep re-evaluation at dispatch, but I would not treat it as covering provenance. Re-evaluation answers what the verdict is now. Provenance answers whether the verdict could have changed. Those come apart when the envelope still evaluates to allow on a different derivation: the old allowing rule was removed and a new rule now permits the same envelope. Dispatch sees allow either way. The approving party's decision rested on a basis that no longer exists. Same verdict, different derivation belongs back in review.
On the review manifest, agreed: critical field ids plus normalized values beat a raw screen hash. The remaining gap is that whoever defines those ids is making the same enumeration bet one layer up. An omitted field is now omitted by the manifest schema instead of by the renderer. A manifest can witness what was placed in front of the approver, not what was taken from it; more hashing only makes that boundary explicit.
This sharpens the distinction nicely. “A record of presences cannot enumerate absences” is the key point. A selection digest over the potentially applicable rule set, together with the combining algorithm and default decision, gives us an absence-sensitive provenance witness. I also like its conservative failure mode: when target analysis cannot be precise, broaden the dependency set—even back to a whole-policy digest—rather than silently treating the approval as stable.
I agree that dispatch re-evaluation and approval provenance answer different questions. Re-evaluation says whether the action is allowed now; provenance says whether the basis on which the human approved it still exists. The same verdict reached through a different derivation should therefore return to review.
The manifest limitation is equally important. I would describe it as proof of presentation, not proof of completeness. A portable contract can bind a versioned manifest and normalized reviewed values, but it cannot prove that the application selected every material field. That completeness claim must remain with the application or policy layer, with conservative re-review when the manifest schema or dependency set changes.
This suggests a useful evidence ladder: fired-rule hashes for precise positive dependencies, a selection digest for absence-sensitive dependencies, and a whole-policy digest as the conservative fallback.
The ladder shape makes sense to me, with one extra constraint: the selected rung has to be part of the signed approval record. A verifier needs to see which evidence class was used, and for the selection digest case it also needs the over-approximation mode or profile that produced the candidate set. Otherwise the producer can quietly fall from selection digest to fired-rule hashes, still emit a mechanically valid record, and the verifier learns almost nothing about absence coverage from the passing check. The ladder becomes an honor system unless the rung identity is covered by the same signature as the envelope and the verdict.
That also lets the verifier enforce a floor. Some actions might accept fired-rule hashes because they are insensitive to absent rules. Others should require a selection digest, or the whole-policy digest, because skipped rules can change what the approval meant. So the rung is a semantic field, and a verifier is entitled to reject a record that sits below the floor for that action class.
On the manifest side, the same move applies to completeness in a weaker form. We cannot prove the chosen fields were complete. Fine. Bind the selector identity anyway: schema version plus selector id, or whatever names the selection function that produced the manifest. Then a later material omission points at a specific selector contract instead of dissolving into "the UI did not show it".
That does not detect the omission. Someone still has to find it later. What it buys is that once found, ownership is pinned to a named function at a known version, which is far easier to audit and repair.
Yes — this identifies the anti-downgrade property that the ladder needs.
If the evidence class and its profile are not covered by the same signature as the approval envelope and verdict, a record can remain syntactically valid while becoming semantically weaker. The verifier would see a passing digest check without knowing what kind of dependency coverage that digest was supposed to provide.
I would model the signed basis roughly as:
approval_basis = { evidence_class, profile_id, profile_version, digest }
and the review side similarly as:
review_manifest = { schema_version, selector_id, selector_version, digest }
The consuming policy could then define a minimum acceptable evidence class for an action or risk profile. An unknown profile, or a record below that floor, should fail closed rather than silently fall back.
I also agree with the boundary you draw around manifest completeness. Binding the selector does not prove that it selected every material field. It makes the selection claim explicit, versioned, and attributable, so a later omission becomes a defect in a named selector contract rather than an untraceable UI accident.
One boundary I would still keep is that the portable approval record names and binds the evidence profile, while the policy engine owns the engine-specific selection semantics. Otherwise the approval contract starts absorbing the policy engine itself.
The remaining design question for me is where the minimum evidence floor should be declared: in verifier policy, in an action profile, or partly in both. But it should not be chosen by the record producer alone.
The floor question splits cleanly. The ordering over evidence classes belongs in the action profile: which class outranks which, and what each class asserts. That ordering has to be public and fixed. The cut point belongs to the relying verifier, because that is where the loss lands. Neither party should hold both halves.
If every verifier invents a private ordering, records stop being comparable and "floor" loses portable meaning. So the answer is both, with an asymmetry: the action profile carries a minimum, and verifier policy may only raise it. It cannot go below. That prevents convenience-driven consumers from racing the floor down while still letting a stricter verifier reject more.
The anti-downgrade property also needs one more check: evidence_class must be re-derived from the envelope, then compared with the claimed class. If profile_id and evidence_class are producer-authored strings only, the producer is still choosing the label and the verifier is just doing name membership. Claimed above derived should fail closed. That turns the mismatch into a detectable false claim. Signature gives attribution. Re-derivation gives the claim a price.
The honest limit is that re-derivation only reaches properties witnessed in the record: field digest count, second-party countersignature presence, whether the digest covers the verdict, and whether the profile digest matches the referenced definition. Anything outside the record, such as whether a selector actually saw every material field, stays outside mechanical verification.
So the derivable portion of a class should be maximized and the non-derivable assertions named separately, so a floor can sit on the derivable part alone instead of on a mixture. The seam looks right: the record binds the profile, the engine keeps its selection semantics. One condition on it. Bind the profile by content digest rather than name plus version, because a re-published v1.2 changes what the record means while the signature still verifies, which is the downgrade you just closed reappearing one level out, in the naming.
This closes the split cleanly.
The action profile owns the public ordering and a minimum that cannot be lowered; verifier policy is monotonic and may only raise that floor. I also agree that evidence_class cannot be trusted as a producer-authored label. The verifier has to derive the strongest class actually witnessed by the record and reject any claimed class above it.
Binding the profile by content digest rather than name plus version closes the mutable-definition loophole as well. And separating mechanically derivable predicates from non-derivable assertions is essential; otherwise a floor over a mixed class would overstate what passed verification actually proves.
I’ll treat this as the stopping point for the model: profile digest, derived evidence class, action-profile minimum, and a verifier that may only tighten it — while selector completeness and policy-engine-specific semantics remain explicitly outside the portable claim.
Thanks — this resolves the remaining ownership question without pulling policy-engine internals into the portable approval record.
This is the time-of-check-to-time-of-use gap dressed up in agent clothing, and I think naming it that way helps because it drags in decades of prior art. The row saying
approved = trueis your check; dispatch five hours later is your use; everything in between is the window an attacker (or just reality) gets to move things.The pattern that's worked for us is to treat approval as a scoped, expiring capability rather than a stored boolean — it carries the subject, the exact arguments, a policy version, and a TTL — and then re-derive the business facts at dispatch and diff them against what the approver actually saw. If the amount, the policy hash, or the approver's role has drifted, the task doesn't fail open or closed; it re-enters review with the diff attached, so the human sees what changed rather than re-approving blind.
One thing I'd add to your list: idempotency. Even a still-valid approval can double-execute across a coordinator restart, so the "refunded through another channel" case needs a dedupe key on the effect, not just a fresh policy check. Curious how you're modeling the expiry — fixed TTL, or derived from the risk class of the action?
Thank you — TOCTOU is exactly the right framing. I also like the distinction you make between revalidating authorization and deduplicating the effect: a fresh policy check can tell us that an action is still allowed, but it cannot tell us whether the same business consequence has already happened. The durable task identity therefore needs to carry a stable business idempotency key all the way to the authority that commits the effect.
On expiry, I am intentionally not treating the risk class as a universal TTL function. In the portable model, the approval evidence carries implementation-defined validity metadata. Deployment or approval policy may derive an expires_at value from risk plus context — amount, subject, business object, policy, and so on — but that mapping remains local rather than becoming part of the portable contract.
At dispatch, expiry or material drift should return the same durable task to review, ideally with the diff you described.
To be transparent, a general persisted approval TTL is still a hardening candidate in BailingHub rather than a released core guarantee. The article is describing the invariant implementations should be able to prove, not claiming that every current runtime already provides it.
I would also be curious where you issue the effect-level dedupe key in your design: before approval as part of the frozen envelope, or at the business authority that commits the effect?