# Approvals (human in the loop)

Source: https://permdock.com/docs/security/approvals

How approval: 'human' grants produce approval-required decisions, how the replay-safe token binds an approval to one call, and how each runtime surfaces and resumes the approval.

Some actions should be permitted in principle but confirmed by a person each time: refunds, deletions, publishing, anything an agent might do a thousand times before anyone notices. PermDock models this as a third decision outcome rather than a denial with a note, so every adapter can translate it into the runtime's own approval vocabulary and resume safely once a human says yes. See [decisions](/docs/concepts/decisions); the API and the resolve flow are on the [approvals adapter](/docs/adapters/approvals). Who may approve lives on the grant.

## The grant [#the-grant]

```ts
const clerk = role("clerk", [
  allow(permissions.filing.pay, {
    approval: {
      by: [roles.admin, assurance({ amr: ["mfa"] })],
      distinct: true,
    },
  }),
]);
```

`approval: 'human'` marks a grant that any authenticated person other than the requester may resolve: neither the actor nor the principal can approve. `{ by }` names the eligible approvers with the same grantee selectors as `to` (`role`, `assurance`, `authenticated`, `relation()`, an array as intersection), plus `user(id)` for one named person, and refuses the principal and the actor the same way. `distinct` defaults to `true`; `{ distinct: false }` lets the principal approve their own request (a user confirming their own agent's call), and `permdock doctor` PD024 lists every grant that sets it. The actor is refused either way.

Three more fields shape the request the grant produces:

```ts
allow(permissions.payout.release, {
  approval: {
    by: roles.finance,
    quorum: 2, // two distinct finance approvers
    ttl: "30m", // the ask expires after half an hour
    escalation: { after: "10m", to: roles.cfo }, // after ten minutes the CFO may approve too
  },
});
```

* `quorum` (an integer, default 1) is how many distinct approvers the request needs. Each approval is recorded as `{ by, at }` on the request's `approvals`, the same principal counts once, and the request stays `pending` (so it cannot be consumed) until the quorum is met. One rejection ends it.
* `ttl` (a duration such as `'30m'` or `'2d'`) caps how long the request stays open. The window comes from the call's `ttl` (`resumeDecision`), else the store's `ttl`, else one hour; the grant can shorten it, never extend it.
* `escalation` names, with the same selectors as `by`, who may approve in addition to `by` once `after` has passed since the request was created. It widens the approver set; it does not change the quorum or remove anyone.

`definePolicy` refuses a `quorum` below 1 or a `ttl` or `after` that is not a duration.

### Stages [#stages]

`mode` orders several approver sets. `'any'` (the default) is the single `by` set above. `'all'` and `'sequential'` take `stages`, each with its own `by` and `quorum`; `by` and `quorum` are then refused at the top level.

```ts
allow(permissions.expense.pay, {
  approval: {
    mode: "sequential",
    stages: [
      { by: relation(permissions.expense, "manager") }, // the expense's manager first
      { by: relation(permissions.report, "approver", { through: ["report"] }) }, // then the report's approver
      { by: roles.finance, quorum: 2 },
    ],
  },
});
```

* `'all'` needs every stage's quorum, in any order. `'sequential'` opens a stage only when the previous one is complete.
* Each signature records its `stage`. One approver signs once per request, so one person never completes two stages.
* `escalation.to` may approve any open stage once `after` has passed. A stage may carry its own `escalation` (`stages: [{ by, quorum, escalation }]`), which widens only that stage.

### Any of several approvers, and permission holders [#any-of-several-approvers-and-permission-holders]

A list in `by` (or `allOf(...)`) is a conjunction: the approver must match every item. `anyOf(...)` matches an approver who matches at least one item, and a list inside it is an all-of group. `holder(permission)` names whoever holds that permission in the request's tenant through any role, custom roles included:

```ts
allow(permissions.payment.send, {
  approval: { by: anyOf(user("cfo-id"), holder(permissions.payment.approve)) },
});
```

`holder()` is checked when the verdict is given, from the approver's own instance: `approvalsHandler(store, { permdockFor })` (and `resolveApproval(..., { permdockFor })`) builds it with `permdockFor(approver, request)`, and `approverPermissions(request, instance)` lists the permission keys it grants without a row as `verdict.permissions`. An instance for another tenant or an anonymous one holds nothing; without `permdockFor`, or when it throws, `holder()` matches nobody. A custom store receives the same facts on `verdict.permissions` and passes them to `assertApprover(request, by, requireDistinct, now, relations, permissions)`; it never derives them.

### Relation approvers [#relation-approvers]

`relation(resource, name)` in `by`, a stage or `escalation.to` names whoever holds that relation on the requested row, such as the manager on an expense. It is checked when the verdict is given, by the request's resource id, so a manager who changes between the ask and the answer is the one who may approve. `definePolicy` refuses a relation approver on a collection permission, on a resource other than the permission's own or the end of its `through` links, on an undeclared relation, and in an activation approval. The approvals handler reads the relation through its `relations` source ([approvals adapter](/docs/adapters/approvals#relation-approvers)); without one, a relation approver matches nobody.

When the grant matches, `decide` returns:

```ts
{
  outcome: ("approval-required", grant, reason, token);
}
```

If the grant does not match, the outcome is `denied` as usual; approval is never offered for something the subject could not do even with approval. If a `deny` grant applies, it wins. `can` returns `false` for `approval-required` because a boolean caller cannot ask anyone; only `decide` and the adapters see the third outcome.

## The replay-safe token [#the-replay-safe-token]

`Decision.token` is a hash of the permission key, the resource id (from the resource's `id` field), a digest of the call's data when there is no id (a collection action such as `refund.create`, or a row without its `id` field), the principal's `id`, `tenant` and `issuer`, the actor's `id` and `kind`, and the fingerprint of the matched grant's conditions. Roles, memberships, assurance and claims are not hashed: the resume re-runs `decide`, so a role lost in between is a denial. It exists so an approval given for one call cannot be attached to another: approving "delete post p\_42 for user u\_123 via agent A" must not authorise deleting p\_43, or deleting p\_42 as a different user, or the same action by a different agent. This is the problem the AI SDK's `experimental_toolApprovalSecret` addresses for its own approval replies; PermDock computes the binding itself so the guarantee holds in every runtime.

Properties:

* Deterministic for the same inputs, so a resumed request can recompute and compare.
* Includes `actor`, so an approval obtained by one agent cannot be spent by another.
* Includes a SHA-256 of the canonical JSON of the validated data (keys sorted, `undefined` members dropped) when the resource id would be `*`. An approved `refund.create` for `{ amount: 5, to: 'c_1' }` does not cover `{ amount: 5000, to: 'c_attacker' }`: the retried call recomputes a different token and is a new `approval-required`.
* Does not include a timestamp; expiry lives on the `ApprovalRequest` record in the store, not in the token.
* Includes the row's `version` field only for a grant with `approval: { staleOn: 'resource-change' }` (below).
* Carries no secret material and reveals nothing beyond what the caller already knows.

On resume, the adapter recomputes the token from the actual call being made and compares it with the approved token. A mismatch is `denied` with a reason of kind `approval`. A signed `permdock-approval+jwt` never replaces this comparison: it proves who resolved the request, the token proves what was approved.

## Approvals that go stale [#approvals-that-go-stale]

By default the token binds the row's id, not its content: an approval for "pay invoice inv\_1" stays valid if the amount changes between the ask and the resume. For actions where the approver approves the content (a payment amount, a contract text, a deploy target), the grant binds the approval to the row's version:

```ts
const permissions = definePermissions({
  invoice: resource(Invoice, { actions: ["pay"], version: "updatedAt" }),
});

allow(permissions.invoice.pay, {
  approval: { by: roles.finance, staleOn: "resource-change" },
});
```

* `version` on `resource()` names the row field that changes whenever the row does: an `updatedAt` timestamp or a revision counter. Its value (a `Date` as ISO text, a string, a number) joins the token input; a row without the field hashes as `null`.
* `staleOn: 'resource-change'` needs an instance action on a resource that declares `version`. `definePolicy` throws otherwise, and a hosted grant that sets it on such a resource is dropped as `invalid`.
* On resume, `decide` runs on the current row, so a changed version recomputes a different token and the approval no longer applies. When the presented token names a pending or approved request for the same permission, resource and principal, the resume is `denied` with reason `stale-approval`, and the old record is not consumed. The next call without that token is a new `approval-required` with a new token, and the approver sees the row as it is now.
* Grants without `staleOn` hash exactly what they hashed before, so their tokens do not change.

## The store [#the-store]

Between the ask and the answer, the pending approval is an `ApprovalRequest` record in an `ApprovalStore`; the API is on the [approvals adapter](/docs/adapters/approvals) and the record shape on [wire formats](/docs/concepts/wire-formats#approval-request). An adapter without a `store` denies every resume (Eve and the OpenAI Agents SDK adapter default to `memoryApprovalStore()`). Rules the store and the adapters hold:

* The approver comes from authentication, never from the resume request, and is never the request's actor: an agent cannot approve its own call. Nor is it the request's principal, unless the grant sets `distinct: false`; `requireDistinctApprover` on the handler refuses the principal even then.
* Only `pending` becomes `approved` or `rejected`; `expired` is terminal. A resume against anything but `approved` is `denied`.
* An approval is spent once. A resume that enforces the call (`protect`, agent tool execution, MCP) calls the store's atomic `consume`, which sets `consumedAt`; a second resume is `denied` with detail `approval-consumed`. A store without `consume` cannot resume. The decision endpoint only inspects, so a UI check does not spend the approval.
* `create` is idempotent per token: a pending, approved or rejected record is kept until it expires, so a consumed or rejected call cannot be asked again before `expiresAt`.
* A request with a tenant is resolvable only by an approver with a membership in that tenant.
* A `quorum` counts principals, not clicks: the same approver is refused a second time, so one person can never satisfy a quorum of two, whatever role they hold.
* A verdict the application vouches for (`vouchApproval`, `verdict.vouched`) skips the eligibility checks of `approvers`, the tenant membership and the quorum, because the application's own rules decided them, and resolves the request at once. It still refuses an unauthenticated approver, the actor, the principal unless `distinct: false`, a repeated approver and a request that is not pending or has expired, and the request records the rule's name. Only server code can set it: `approvalsHandler` never reads it from a body.
* `expiresAt` is the shorter of the store's window and the grant's `ttl`; the escalation window opens at `createdAt` plus `escalation.after` and the store checks it at resolve time, so an approver who was not eligible when the request was created can become eligible without any write.
* A `simulate()` plan produces one request per `approval-required` step; approving the plan approves each token, and each step is still re-checked at execution.
* The ask, the answer and the resumed decision are three `on('decision')` records sharing `token`.

Applications that need approvals to survive a restart implement the interface over their database (a Drizzle recipe is on the adapter page). PermDock Cloud implements the same interface with an inbox UI and delivery ([Cloud adapter](/docs/adapters/cloud)).

## Surfaces [#surfaces]

| Runtime | How `approval-required` is surfaced | How it resumes |
| --- | --- | --- |
| AI SDK 7 `generateText` / `streamText` / `ToolLoopAgent` | `toolApproval` returns `'user-approval'` | The SDK's re-check of the same `toolCallId` is `denied` unless the store holds an approved, unconsumed record for the recomputed `token`; the record is consumed before the tool runs |
| AI SDK 7 `WorkflowAgent` | `needsApproval(permissions.post.delete)` suspends the durable workflow | Workflow resumes; the adapter recomputes and compares `token` |
| Claude Agent SDK | `canUseTool` denies with the reason and `token` and records the request in the store; `permissionRequestHook` answers the SDK's own prompt with the same decision | A reviewer resolves the request in the store; the agent's retry recomputes `token` and consumes the approved record once |
| Eve | `approval.request` returns `"user-approval"`; the session parks at `session.waiting` | `approval.response` checks the responder against the store and `approvers`; on resume the adapter recomputes and compares `token` ([Eve adapter](/docs/adapters/eve)) |
| OpenAI Agents SDK | `needsApproval` returns `true`; the run returns `interruptions` and a serialisable `RunState` | `resolveInterruptions` applies the store's verdict with `state.approve` / `state.reject`; the token is recomputed before `execute` ([OpenAI adapter](/docs/adapters/openai)) |
| MCP | An MRTR `input_required` result with a URL-mode `elicitation/create` request to `approval.at` and the token as `requestState`, when the client takes URL elicitations; otherwise a structured tool result (`isError`) carrying the reason and `token` | A reviewer resolves the request in the store; the retried call re-runs `decide`, compares and consumes |
| HTTP adapters | `403` Problem Details with `type` `.../approval-required`, `permission`, `token` | Client retries the same request with a `PermDock-Approval: <token>` header; the kernel requires an `approved` record, re-runs `decide` and compares |
| Terminal (`permdock/terminal`) | A y/N prompt only for a grant with `distinct: false` and no actor; otherwise the request is recorded in `store` and the CLI exits `75` (`77` with no `store`) | The rerun recomputes `token` and consumes the approved record once ([terminal adapter](/docs/adapters/terminal)) |
| A2A | Skill returns a structured "approval required" result with `token` | The task enters `input-required`; the caller retries with the token |
| WebMCP | Tool handler returns a structured refusal with `token`; page renders its own approval UI | Page re-invokes the guarded route with the token |
| React / Next.js UI | `usePermission` reports `allowed: false`; `decide` on the server shows `approval-required` for rendering an approval button | Server action re-checks with the token |
| Chat UIs over AG-UI | The backend emits the approval request (reason, `token`, what to ask) as an AG-UI human-in-the-loop or custom event; the UI renders the control | The answer flows back on the same stream; the adapter in the backend recomputes and compares `token` ([agent frameworks](/docs/research/ecosystem-index)) |
| Slack, Microsoft Teams, Discord through the Vercel Chat SDK | The store's `approval` event triggers `requestApproval` from a `chat/workflow` step; the card carries the reason and names the approvers | The Chat SDK verifies the platform signature and returns the responder's `user.id`; the app maps it to a `Subject` and calls `store.resolve`, which applies the actor rule; the resumed call recomputes `token` ([approvals adapter](/docs/adapters/approvals), Delivery) |
| Other frameworks (LangGraph.js, Mastra, Inngest AgentKit, Google ADK) | Recipes, not adapters: `decide` in the framework's before-tool hook; `approval-required` parks as an `interrupt`, `suspend` or waiting step | The durable step resumes with the token; the same store and token check apply ([agent frameworks](/docs/research/ecosystem-index)) |

The AI SDK adapter fails closed: `denied` maps to `'denied'`, `approval-required` maps to `'user-approval'`, and `'not-applicable'` is never returned, in contrast to `@ai-sdk/policy-opa`, which executes tools on unrecognised decisions ([vercel/ai#19978](https://github.com/vercel/ai/issues/19978)).

## Resume flow [#resume-flow]

<Mermaid
  chart="sequenceDiagram
  participant Agent
  participant Adapter
  participant PermDock
  participant Human
  Agent->>Adapter: call delete_post(p_42)
  Adapter->>PermDock: decide(post.delete, post)
  PermDock-->>Adapter: approval-required, token T
  Adapter-->>Agent: user-approval / input_required / 403 approval-required (T)
  Agent->>Human: ask
  Human-->>Agent: approved (T)
  Agent->>Adapter: call delete_post(p_42) with approval T
  Adapter->>PermDock: decide(post.delete, post)
  PermDock-->>Adapter: approval-required, token T'
  Adapter->>Adapter: T' equals T? then run handler, else denied"
/>

The second `decide` matters: the policy, the resource and the subject are re-evaluated at resume time, so a revocation between the ask and the answer (a CAEP `session-revoked`, a role change, the post being reassigned) turns the approval into a denial. The token proves the human approved *this* call; the re-check proves the call is still permitted.

## What an approval does not do [#what-an-approval-does-not-do]

* It does not grant a permission the subject lacks. Approval sits on top of a matching grant.
* It does not outlive its request. Each call needs its own approval, which is consumed on use; the record expires at `expiresAt`, and a `simulate()` plan is approved step by step with no plan-level token.
* It is not remembered. A rejection applies to one call; the next call asks again, and the OpenAI SDK's `alwaysReject` is never set.
* It does not decide who may approve beyond the grant and the actor rule. `approval.by`, `approval.mode`, `approval.stages`, `approval.distinct`, `approval.quorum` and `approval.escalation` on the grant, tightened by any matching [approval policy entries](/docs/adapters/approvals#approval-policies-as-data), are copied to the `ApprovalRequest` as `approvers` and enforced by `ApprovalStore.resolve` and `approvalsHandler`. Adapter `approvers` lists (Eve, OpenAI, Chat SDK) are an extra restriction; both must pass. `requireDistinctApprover` on the handler is an application-wide floor: with it on, `distinct: false` on a grant no longer lets the principal approve.
* It is never given by a link alone. An emailed or chat link carries only the token and opens a page where the approver authenticates; the verdict goes through `approvalsHandler` with that session's subject. A link that approves without a session would make possession of the message the approver's identity ([approvals](/docs/adapters/approvals)).

## Why [#why]

* **Approvals can bind content, not only identity.** An approval is a person's judgement about what they saw. When the row can change between the ask and the resume, someone could get a small refund approved and then raise the amount before the retry. The version field is the cheapest stable signal of "the row changed" that every database already has, and putting it in the token reuses the existing comparison instead of adding a second check. It is opt-in per grant because most approvals are about the action on a row ("delete this post"), and forcing re-approval on every unrelated edit would train approvers to click yes. The resume says `stale-approval` rather than a generic mismatch, so the client can tell the user to ask again instead of retrying the same token.
* **A call without a row id binds its arguments.** A collection action has no row to name, so a token of key, subject and actor alone would let one approval cover every later call of that action until it is consumed, and an agent could swap the arguments after the human said yes. Hashing the validated data costs one digest per approval-required decision and needs no new field on the record. Rows with an id keep binding the id, with `staleOn` for content, because a row's content changes for reasons unrelated to the approval.
* **Relation approvers are checked at the verdict, outside the store.** `relation()` answers "does this subject relate to this row", which needs a relation reader at resolve time; the store has none and stays synchronous. The approvals handler asks its `RelationSource` which relation approvers the approver satisfies for the request's resource id and passes those facts to `resolve` as `verdict.relations`. A store never derives them, so without the facts a relation approver matches nobody. Reading at the verdict, not at the ask, means the current holder approves: a manager who left the team the day before cannot approve.
* **Stages are ordered sets, not a workflow engine.** Two-step sign-off (a line manager, then finance) is common enough to live on the grant, where `permdock diff` and a policy review see it. `sequential` refuses a later stage until the earlier one is complete, so finance never signs something the manager has not seen. Branching, parallel paths with different outcomes and delegation of a stage are left to the application.
* **Quorum, ttl and escalation are grant fields, not store configuration.** How many people must agree, how long an ask may wait and who may step in when nobody answers are statements about the action, the same as who may approve, so they live next to `by` on the grant, appear in the catalog and in `permdock diff`, and travel with the request as `approvers`. A store setting would apply to every permission at once and would be invisible to a policy review. The request records each approval as `{ by, at }` rather than a counter so an audit can see who agreed, and the quorum counts distinct principals because a quorum one person can satisfy alone is a single approval with extra clicks. Escalation only widens the approver set, because a workflow where the fallback approver replaces the first set would let anyone wait out the timer to pick their approver. Recurring windows and per-approver weights are left out: both are approval workflow design, which an application or PermDock Cloud builds on these primitives.
* **Four eyes is the default, not an option.** A user who can approve their own request turns an approval into a confirmation dialog: the person who wants the refund is the person who says yes. So the principal is refused unless the grant opts out. Where a confirmation is what the product wants (the user approving their own agent's call in a chat), `approval: { distinct: false }` says so on the grant and PD024 keeps the list visible in review. Separation between the agent and its user was already guaranteed by the actor rule; this closes the one between the user and themselves.

## Elevated access [#elevated-access]

Two elevated-access features ride the same `ApprovalRequest` machinery ([elevated access](/docs/concepts/elevated-access)). Role activation with `activation: { approval }` returns `approval-required` from `permdock.activate`; once resolved, the granted decision carries the `via: 'elevated'` membership to write under `elevation`. Support access asks a tenant owner to consent: the request's `membership` is the `via: 'support'` row, and on `consume` the app writes it. Both reuse the replay-safe token and four-eyes default above; neither lets PermDock write the membership.

## Sources [#sources]

* [AI SDK 7 tool approvals](https://ai-sdk.dev/docs/agents/tool-approvals): `toolApproval` outcomes and `WorkflowAgent` `needsApproval`.
* [AI SDK `needsApproval` deprecation commit](https://github.com/vercel/ai/commit/04585590ff9e93f6823ad550d59a69dc4039cdbf).
* [AI SDK policy-based tool approvals](https://ai-sdk.dev/docs/agents/policy-tool-approvals) and the [fail-open issue](https://github.com/vercel/ai/issues/19978).
* [MCP 2026-07-28 release](https://blog.modelcontextprotocol.io/posts/2026-07-28/): stateless elicitation via multi-round-trip requests.
* [Eve human-in-the-loop](https://eve.dev/docs/human-in-the-loop): `approval` request and response policies, `session.auth.initiator` and `current`.
* [OpenAI Agents SDK human-in-the-loop](https://openai.github.io/openai-agents-js/guides/human-in-the-loop/): `needsApproval`, `interruptions`, `RunState` serialisation.
* [Vercel Chat SDK `requestApproval`](https://chat-sdk.dev) (6 August 2026): durable approval cards for Slack, Teams and Discord with signature-verified responders.
