PermDock
Security

Approvals (human in the loop)

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; the API and the resolve flow are on the approvals adapter. Who may approve lives on the grant.

The grant

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:

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

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.

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

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:

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(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); without one, a relation approver matches nobody.

When the grant matches, decide returns:

{
  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

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

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:

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

Between the ask and the answer, the pending approval is an ApprovalRequest record in an ApprovalStore; the API is on the approvals adapter and the record shape on wire formats. 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).

Surfaces

RuntimeHow approval-required is surfacedHow it resumes
AI SDK 7 generateText / streamText / ToolLoopAgenttoolApproval 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 WorkflowAgentneedsApproval(permissions.post.delete) suspends the durable workflowWorkflow resumes; the adapter recomputes and compares token
Claude Agent SDKcanUseTool denies with the reason and token and records the request in the store; permissionRequestHook answers the SDK's own prompt with the same decisionA reviewer resolves the request in the store; the agent's retry recomputes token and consumes the approved record once
Eveapproval.request returns "user-approval"; the session parks at session.waitingapproval.response checks the responder against the store and approvers; on resume the adapter recomputes and compares token (Eve adapter)
OpenAI Agents SDKneedsApproval returns true; the run returns interruptions and a serialisable RunStateresolveInterruptions applies the store's verdict with state.approve / state.reject; the token is recomputed before execute (OpenAI adapter)
MCPAn 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 tokenA reviewer resolves the request in the store; the retried call re-runs decide, compares and consumes
HTTP adapters403 Problem Details with type .../approval-required, permission, tokenClient 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)
A2ASkill returns a structured "approval required" result with tokenThe task enters input-required; the caller retries with the token
WebMCPTool handler returns a structured refusal with token; page renders its own approval UIPage re-invokes the guarded route with the token
React / Next.js UIusePermission reports allowed: false; decide on the server shows approval-required for rendering an approval buttonServer action re-checks with the token
Chat UIs over AG-UIThe 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 controlThe answer flows back on the same stream; the adapter in the backend recomputes and compares token (agent frameworks)
Slack, Microsoft Teams, Discord through the Vercel Chat SDKThe store's approval event triggers requestApproval from a chat/workflow step; the card carries the reason and names the approversThe 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, 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 stepThe durable step resumes with the token; the same store and token check apply (agent frameworks)

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).

Resume flow

call delete_post(p_42) decide(post.delete, post) approval-required, token T user-approval / input_required / 403 approval-required (T) ask approved (T) call delete_post(p_42) with approval T decide(post.delete, post) approval-required, token T' T' equals T? then run handler, else denied Agent Adapter PermDock Human

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

  • 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, 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).

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

Two elevated-access features ride the same ApprovalRequest machinery (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

Last updated on

On this page