Skip to content
For engineers and architects
Human-in-the-loop
Pyrana Harness
Durable execution

Approval is a policy, not a boolean

A per-tool approval flag approves the tool, not the call. Here is what breaks in production and the design that holds: policy over typed arguments, a frozen invocation, one obligation per lane, revalidation before dispatch, and idempotent effects.

Eric Tarnowski
September 9, 2026
7 min read

You added human-in-the-loop to your agent last quarter. It is a flag on the tool definition, a card in a reviewer's queue, and a resume call. In the demo it worked every time. Then a payment above the limit went out twice, and the controller wants to know which of the two approvals covered the second one.

Neither did. The flag approved the tool. Nobody approved the call.

Where the substrate ends

The plumbing under agents has gotten good. Temporal's integration with the OpenAI Agents SDK went GA on March 23, 2026: the agent loop runs as a workflow, each agent invocation runs as an activity, and the whole thing survives a crash (Temporal, "Production-ready agents with the OpenAI Agents SDK + Temporal"). Durable suspension is a solved problem. An agent can wait three days for a signature and pick up where it left off.

Temporal's announcement does not mention approvals, and that is correct. It is a substrate. What you build on top of it decides whether the thing that resumes after three days is the thing the reviewer looked at.

AWS AgentCore Policy gets further. It intercepts tool calls at the gateway and evaluates Cedar policies over the token's claims and the call's inputs; the worked example only lets a principal with the refund-agent role call the refund tool when context.input.amount is below 200 USD (AWS News Blog, December 2, 2025). That is a policy over typed arguments, and it is the right shape. But it answers one question at one instant: allow or deny, now. It has no object that means "this exact call, held, waiting for two named people, under the policy that was current when it was proposed."

That missing object is the whole problem. Gartner's May 2026 note on agent governance says enterprises treat governance as "binary, either locked down or fully trusted, and that is the root cause of failure," and puts "Act with Approval" as its own autonomy tier between advising and running free (Gartner, May 26, 2026). Deloitte's 2026 survey of 3,235 leaders found 21 percent with mature agentic governance; the first gap it names is "clear boundaries for agents that define which decisions they can make independently versus which require human approval," followed by audit trails that capture the full chain of actions (Deloitte, April 24, 2026). Both gaps are the absence of a data structure, and a policy document does not fill them.

Five ways a boolean fails in production

Walk what happens between "approval requested" and "effect applied" and the flag comes apart.

The arguments move. The agent proposed paying vendor V-1187 41,800 USD. The reviewer approved. On resume the agent has new context, re-plans, and calls the same tool with 48,100 USD and a different remittance account. Every framework that approves by tool name lets this through, because the approval was attached to the tool, and the tool has not changed.

The reviewer's authority moves. She was in the treasury approvers group when the request was assigned on Monday. Her membership was revoked Tuesday after a role change. On Wednesday she clicks approve. If eligibility was checked at assignment, the click counts.

The policy moves. Between request and decision, finance lowered the review threshold from 50,000 USD to 25,000 USD and added a second lane. The pending request was evaluated under the old rule. It executes under the old rule.

The provider is ambiguous. The payment API returns a 504 after 30 seconds. Was the payment posted? Your retry policy does not know, so it re-sends. Two payments, one approval, and the second one came from your own retry loop.

The obligations are not independent. Policy says two approvers. One approver's browser double-submits. Or the requesting user, who is also in the finance group, approves once and satisfies both. If "two approvals" is a counter, both cases pass.

None of these is exotic. Every one of them is a Tuesday.

The design that holds

The fix is to make the thing under review a durable, immutable record, and to make every later step check against that record instead of against the live agent. In the order the platform has to do it:

Evaluate policy on typed arguments, not on the tool name. Amount, currency, counterparty, evidence presence, group membership, and compound rules over them. A policy can say: below 25,000 USD run directly; at or above, require review; if the invoice reference is missing or malformed, deny.

Freeze the invocation into a proposal. The proposal holds the tool identity and schema digest, the exact arguments, the actor chain that proposed it, the authorization scope, the policy version, the effect class, the evidence, and the idempotency identity. The agent is suspended. It cannot edit the proposal while it waits.

Produce one obligation per named lane. If the policy names the controller group and the treasury group, that is two obligations, each independently satisfiable, neither satisfied by the other. A reviewer's approval cannot elevate the requester's authority or count against a lane the reviewer is not in.

Check reviewer eligibility at decision time. Group membership, workflow relation, tool relation: whatever the lane is, evaluate it when the click arrives, not when the card was assigned.

Revalidate the frozen invocation before dispatch. After all obligations are satisfied, recheck the exact frozen record against current policy and current authority. A changed schema digest, policy version, scope, or actor authority blocks dispatch or requires a replacement proposal. Approval resumes the frozen invocation, never the agent's current idea of it.

Dispatch idempotently with a receipt. The idempotency identity claimed at admission goes to the provider. A retry reuses it and returns the receipt instead of paying again.

Send ambiguous outcomes to reconciliation. If the provider may have acted and the response is unknown, the invocation stops in a reconciliation state. Nothing retries blind.

Compose layers restrictively. A platform floor on ledger writes can be tightened by an App or an agent configuration. It cannot be loosened by one.

Nothing here requires an LLM. It is the same discipline a payments engineer applies to a card authorization, applied to a tool call.

How the Pyrana Harness does it

We built conditional tool approval this way. It is enabled per App. Here is the payment scenario end to end, with illustrative identifiers.

An agent inside a close App proposes ledger.journal_entry.post for 41,800 USD against cost center 4410. The App's policy approval.journal.amount sets direct execution below 25,000 USD and, at or above it, names two lanes: the controller group and the treasury group. The platform evaluates the typed arguments, finds 41,800 is at or above 25,000, and freezes the invocation.

# illustrative: frozen proposal for execution exec_01J9Q4M7Z3
tool: ledger.journal_entry.post
schema_digest: sha256:9c41…e0b2
arguments: { amount: 41800, currency: USD, cost_center: "4410", invoice_ref: "INV-2026-07731" }
actor_chain: [user:m.okafor -> agent:close-assistant@v12]
scope: app:fin-close / entity:BU-A
policy: approval.journal.amount@v7
effect_class: external_write
idempotency_key: idem_7f2c…41aa
obligations:
  - lane: group:controllers   status: pending
  - lane: group:treasury      status: pending

The agent suspends. Two obligations exist, and the audit record for the execution was written before either reviewer sees anything.

On Wednesday a reviewer in the treasury lane approves. Her membership in group:treasury was revoked Tuesday. The decision is rejected at decision time, the obligation stays pending, and the rejection is recorded against her identity. A second treasury member approves and that lane clears. The controller approves the other lane. Neither approval satisfied the other lane, and the requesting user, who happens to sit in the controller group, was not an eligible reviewer for a call he proposed.

Before dispatch, the platform rechecks the frozen record: same schema digest, policy still at v7, scope still valid, the proposing actor chain still authorized. It dispatches once with idem_7f2c…41aa.

The ledger connector times out. The outcome is unknown, so the invocation enters reconciliation instead of a retry. Reconciliation queries the ledger for the idempotency identity and finds JE-88213 posted at 14:07:52 UTC. The execution completes with JE-88213 as its receipt. If the query had found nothing, the invocation would have been re-dispatched with the same identity, and the provider's own idempotency would have been the second line of defense. The lookup itself depends on what the connector can ask the ledger; the platform's job is to refuse the blind retry and hold the frozen identity that makes the lookup possible.

Every step of that is in one AppExecution record: the proposal, both obligations, the rejected decision, the two valid decisions, the revalidation, the dispatch, the reconciliation, the receipt. Ask for the record and you get all of it.

The restrictive layering applies here too. If the platform floor says every ledger.* external write requires at least one human lane, an App cannot configure approval.journal.amount down to zero lanes. It can add the treasury lane. It cannot remove the controller one.

The tests you should have

Whatever you build on, these are the cases your approval path should pass before an agent can touch money. Write them as integration tests against the real store, with a real clock, and with a real group directory you can mutate mid-test.

  1. The agent mutates arguments after the proposal is created; dispatch uses the frozen arguments or fails.
  2. A reviewer is removed from the lane group between assignment and decision; the decision is rejected.
  3. The policy version changes while the proposal is pending; dispatch is blocked or a replacement proposal is required.
  4. The tool is redeployed with a new schema digest while pending; same result.
  5. Two lanes are required; one reviewer's double submit satisfies at most one obligation, and never the other lane.
  6. The requesting user is a member of a lane group; their approval does not count unless the lane is explicitly "requesting user."
  7. The provider times out after the call left the platform; no second effect is applied, and the execution lands in a reconciliation state with the idempotency identity attached.
  8. The proposal expires; a later approval click does nothing.

If your current stack cannot express test 1, what you have is a pause button.

Sources

Continue the Conversation

Building production-grade agentic AI requires context, orchestration, and human-in-the-loop design. We'd love to show you how Pyrana delivers it end to end.

Request a Demo