# Approvals

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

permdock/approvals is the pluggable store behind every approval-required decision, an in-memory default, a Fetch handler for approvers, and the interface that self-hosted stores and PermDock Cloud implement.

`permdock/approvals` holds the part of a human-in-the-loop flow that the agent runtimes leave to the application: the pending request, who answered it, when it expires, and how a later call proves it was approved. It ships the `ApprovalStore` interface, `memoryApprovalStore()` as the default, and `approvalsHandler` so an application can mount list, approve and reject routes for its approvers. Every agent adapter (`ai-sdk`, `eve`, `openai`, `claude-agent`, `mcp`) and the HTTP kernel accept a `store` option typed against this interface. The decision itself is unchanged: `decide` still runs in-process and never waits on a store.

## Purpose [#purpose]

The AI SDK, Eve, the OpenAI Agents SDK, MCP elicitation and the Claude Agent SDK each pause a run and hand the application an approval to collect. None of them stores an auditable approval record, knows who may approve, or expires a stale ask, and Eve's documentation says outright that a four-eyes flow needs an application-owned approval request ([Eve human-in-the-loop](https://eve.dev/docs/human-in-the-loop)). Without a shared store, every adapter would reinvent this, and PermDock Cloud's inbox would be the only durable option. `permdock/approvals` makes the store an interface with an in-process default so an application can run entirely on its own.

## API [#api]

```ts
import { memoryApprovalStore, approvalsHandler } from "permdock/approvals";
import type { ApprovalStore, ApprovalRequest } from "permdock/approvals";

const store = memoryApprovalStore({ ttl: 60 * 60 * 1000 }); // default: one hour

// Every agent and HTTP adapter takes the same option
const { toolApproval } = createPermDock(policy, {
  subject,
  actor,
  tools,
  store,
});

// Routes for approvers: list pending, approve, reject
const handler = approvalsHandler(store, {
  subject: (request) => subjectFromJwt(request), // the approver, from real authentication
  requireDistinctApprover: false, // true = refuse the principal even where a grant sets distinct: false
  relations, // RelationSource for relation() approvers; without it they match nobody
  permissions, // the permission tree, for approvers reached through links
});
app.all("/permdock/approvals/*", (c) => handler(c.req.raw));
```

```ts
interface ApprovalStore {
  readonly ttl?: number; // ms a new request stays open; default one hour
  create(request: ApprovalRequest): Promise<void> | void;
  get(token: string): Promise<ApprovalRequest | null> | ApprovalRequest | null;
  resolve(
    token: string,
    verdict: { status: "approved" | "rejected"; by: Subject; note?: string },
  ): Promise<ApprovalRequest> | ApprovalRequest;
  list(query: {
    status?: ApprovalRequest["status"];
    principalId?: string;
    actorId?: string;
    tenant?: string;
    session?: string;
    limit?: number;
    cursor?: string;
  }): Promise<ApprovalPage> | ApprovalPage;
  // ApprovalPage = { items: ApprovalRequest[]; next?: string }
  expire(now?: Date): Promise<number> | number;
  consume(
    token: string,
    now?: Date,
  ): Promise<ApprovalRequest | null> | ApprovalRequest | null;
  cancel?(
    filter,
    meta: { by: string; note?: string },
  ): Promise<number> | number;
}
```

* `create` is called by an adapter when `decide` returns `approval-required`; the record is an [approval request](/docs/concepts/wire-formats) (`v: 1`) and carries the `token`, the permission key, the resource id, a subject summary (including the active `tenant`, `session` and the `membership` that supplied the matched role), `approvers` from `grant.approval` when present, the model-readable `detail`, `createdAt` and `expiresAt`. The token is bound to permission key, resource id, subject and actor; because the subject summary includes the tenant, an approval obtained in one tenant cannot resume the same call in another. `create` is idempotent per token: a repeated call for a pending or resolved request keeps the existing record, and only an expired record is replaced, so an agent loop that asks twice never resets an approval to pending.
* `resolve` requires an approver `Subject` produced by authentication. A store must refuse an approver whose id equals the request's `actor.id`, and one whose id equals the request's principal id unless `approvers.distinct` is `false` (`approver-is-principal`); a request without `approvers` (`approval: 'human'`) refuses the principal too. When `approvers` is set, the approver must belong to the request tenant and match `by` (or an open stage's `by`), or match `escalation.to` once `escalation.after` has passed since `createdAt`. A relation approver matches only through `verdict.relations`, the facts the handler computed; a store never derives them. `approvalsHandler` enforces this before calling the store, and a custom store should too (`approver-not-eligible`).
* `resolve` with `status: 'approved'` appends `{ by, at, stage? }` to `approvals`. The request becomes `approved` when there are `approvers.quorum` of them (default 1), or when every stage has its quorum; until then it stays `pending`, `consume` returns `null`, and the same principal is refused a second time (`approver-repeated`, a `409`). One `rejected` verdict ends the request whatever the quorum. `applyApprovalVerdict(request, verdict, now?)` computes the next request for a custom store, which should write it only over a row that is still pending with the same number of approvals so two approvers racing both count.
* `cancel(filter, { by, note })` is optional. It marks matching pending requests `rejected` with `resolvedBy: 'system:<by>'` and does not run eligibility, because a rejection never grants. `cancelApprovals` in `permdock/approvals` calls it, or falls back to `list` plus a system `resolve`.
* `list` returns one page, oldest `createdAt` first (ties broken by `token`): `limit` is 1 to 200 and defaults to 50, `next` is present only when more requests match, and passing it back as `cursor` returns the following page. The cursor is opaque to callers; an unreadable one returns an empty last page, never the first page again. `testApprovalStore` checks the order, the page boundary and the unreadable cursor. `cancelApprovals` and other callers that need every match follow `next`. A custom store reuses the built-in paging from `permdock/approvals`: `pageApprovals(matching, query)` pages requests it filtered in memory, `approvalPageSize(limit)`, `encodeApprovalCursor(request)` and `decodeApprovalCursor(cursor)` serve a store that pages in its own query (the cursor is the `(createdAt, token)` of the last item, `ApprovalCursorPosition`, and `null` means an unreadable one), and `listAllApprovals(store, filter)` follows `next` to the end.
* `list({ tenant })` scopes the approver inbox to one tenant, which is how a tenant admin sees only their organisation's pending requests; `approvalsHandler` applies it from the approver's active tenant and the approver must hold a membership there ([tenancy](/docs/concepts/tenancy)).
* `consume` is the atomic step that makes an approval single-use: it sets `consumedAt` on an approved, unexpired, unconsumed request and returns it, or returns `null`. Two concurrent calls never both succeed. Adapters call it when a resume enforces the call (`protect`, tool execution, MCP); the decision endpoint only inspects. A store without `consume` cannot resume.
* A request with a tenant can be resolved only by an approver with a membership in that tenant, whether or not the grant names `approvers`. `assertApprover(request, by, requireDistinct, now?, relations?)` applies these checks, returns the stage the approval counts towards, and throws `ApprovalError`; custom stores call it in `resolve` with `verdict.relations`.
* `list` powers the approver UI and the `permdock/terminal` interactive prompt. `approvalsHandler`'s pending inbox lists the requested tenant, else the active tenant, else every tenant the approver holds a membership in; requests without a tenant appear only in the last case.
* `expire` is called opportunistically by adapters and by `approvalsHandler`; a scheduled job may call it as well.
* `requestApproval(store, decision, meta)` and `resolveApproval(store, token, verdict)` are the two internal helpers agent adapters share; applications rarely call them directly.
* `signer` (optional, a `TokenSigner` such as `joseTokenSigner` from `permdock/jwt`) makes `approvalsHandler` return, on approve, a compact JWS with `typ: permdock-approval+jwt` alongside the bare token: registered claims `iss`, `aud`, `sub` (the principal), `iat`, `exp` (= the request's `expiresAt`), `jti`, and `{ token, permission, resource, status }` under the `approval` claim ([wire formats](/docs/concepts/wire-formats)). It exists for a resume that crosses services or trust domains, where the resuming side wants proof of who resolved the request before it spends a `decide`. The bound hash `token` stays the thing that is compared: the adapter verifies the JWS with its `verifier`, extracts `approval.token`, and then runs the unchanged step 5 below. `PermDock-Approval` carries either form; the kernel tells them apart by the `pd1.` prefix versus the three-segment JWS shape.

An application that decides approvals in its own gate, outside an adapter, resumes a call the way the adapters do: `resumeDecision({ decision, permission, subject, store, resource, adapter, token })` with `token: await storedApprovalToken(store, decision)`. `storedApprovalToken` returns the decision's own token when the store holds an approved or rejected request for it, so a retried call resumes (or is denied) without the caller carrying the token; it returns `undefined` for no record, an expired one, a store that throws, or a decision that needs no approval. A pending request counts only with `{ denyPending: true }`, which denies the call with `approval-pending` instead of asking again; `now` sets the clock for the expiry check.

```ts
import {
  readApprovalHeader,
  resumeDecision,
  storedApprovalToken,
} from "permdock/approvals";

const raw = permdock.decide(permissions.payment.send, payment);
const decision = await resumeDecision({
  decision: raw,
  permission: permissions.payment.send,
  subject: permdock.subject,
  store,
  resource: { type: "payment", id: payment.id },
  adapter: "app",
  token:
    readApprovalHeader(request.headers) ??
    (await storedApprovalToken(store, raw)),
});
```

`approvalsHandler` options take `permdockFor: (approver, request) => PermDock` for [`holder()` approvers](/docs/security/approvals#any-of-several-approvers-and-permission-holders): it builds the approver's instance in the request's tenant, and the handler passes the permission keys `approverPermissions` reads from it on `verdict.permissions`.

`approvalsHandler` routes, all Fetch `Request` to `Response`:

| Route | Purpose | Denial |
| --- | --- | --- |
| `GET /pending` | `{ items, next? }`: requests the authenticated approver may resolve, in the approver's active tenant (`?tenant=` must be one of the approver's memberships); `?limit=` and `?cursor=` page through the store, and a page may hold fewer items than `limit` after the eligibility filter | `401` without a subject; `403` for a tenant the approver does not belong to |
| `GET /mine` | `{ items, next? }`: the authenticated principal's own requests, for `useApproval` polling, paged the same way | `401` without a subject |
| `POST /:token/approve` | Appends to `approvals`; once the quorum is met, marks approved and records `resolvedBy` and `resolvedAt` | `403` Problem Details when the approver is the actor, the principal (unless the grant sets `distinct: false` and `requireDistinctApprover` is off), or when neither `approvers.by` nor an open `approvers.escalation.to` matches (`approver-not-eligible`); `409` when the same approver answers twice (`approver-repeated`) |
| `POST /:token/reject` | Marks rejected with an optional `note` | Same |
| `GET /:token` | One request, for a confirmation screen | `404` for unknown or foreign tokens |

## Verdicts the application decides [#verdicts-the-application-decides]

An application whose own rules decide who approves (a manager chain, delegates, a quorum it counts, an order of steps) records that verdict with `vouchApproval(store, token, { status, by, rule, note? })`. `rule` names the rule that decided it, for the audit trail. The store resolves the request in one step: `approved` or `rejected`, `resolvedBy` the approver, and `vouched: rule` on the request and on the approval it records. The eligibility checks of `approvers` (roles, relations, holders, stages, escalation, the tenant membership and the quorum) are skipped, because the application already applied its rules; the separation checks still run, so the actor, the principal (unless the grant sets `distinct: false`), a repeated approver, an unauthenticated subject and a request that is not pending or has expired are refused. The resumed call then goes through `resumeDecision` as any other approval.

```ts
import { vouchApproval } from "permdock/approvals";

if (await managerChainApproves(request, approver)) {
  await vouchApproval(store, request.token, {
    status: "approved",
    by: approverSubject,
    rule: "manager-chain",
  });
}
```

`vouched` is server-side input like `verdict.relations`: `approvalsHandler` builds its verdict from the authenticated approver and never takes `vouched` from a request body. A store built on `applyApprovalVerdict` (the memory store, the generated Postgres and Supabase store, a custom store following the recipe) handles it; `testApprovalStore` checks it.

## Relation approvers [#relation-approvers]

A `relation()` approver ([approval security](/docs/security/approvals#relation-approvers)) is a fact about the requested row, so the handler reads it at the verdict. With `relations` set, `approvalsHandler` calls `approverRelations(request, approver, { relations, permissions })` before `resolve`: it follows the approver's `through` links from the request's resource id, asks the `RelationSource` who holds the relation, and passes the matching approvers to `resolve` as `verdict.relations`. Each entry is `approverRelationKey(approver)`. The `GET /pending` inbox uses the same check, so a manager sees only their reports' requests.

A custom resolve path calls `resolveApproval(store, token, verdict, { relations, permissions })`, which reads the facts the same way. Never fill `verdict.relations` from a request body: the facts must come from your own `RelationSource`.

## Approval policies as data [#approval-policies-as-data]

`approvalPolicies` on `createPermDock` takes an `ApprovalPolicySource`, on the core factory and on every adapter that builds an instance (`permdock/next`, `permdock/server` and the HTTP adapters, `permdock/supabase/middleware`, `permdock/mcp`, `permdock/a2a`, `permdock/authzen`, `permdock/terminal` and the agent adapters `ai-sdk`, `claude-agent`, `eve` and `openai`), for approval rules an organisation configures at runtime, such as "payments over 1000 need finance". An entry adds stages to a matching allow; it never grants, never removes a code requirement and never lowers a quorum.

```ts
import { memoryApprovalPolicies } from "permdock";

const permdock = await createPermDock(policy, user, {
  approvalPolicies: memoryApprovalPolicies([
    {
      permission: "expense.pay", // a key; a former key from renamed resolves
      tenant: "o_acme", // absent: every tenant
      actors: ["agent"], // absent: every call; a call without an actor never matches a list
      where: { op: "gt", field: "amount", value: 1000 }, // a portable condition over the row
      approval: { by: "finance" }, // the approval option shape
    },
  ]),
});
```

* The source is read once, when the instance is created, for the subject's tenants. Entries are plain JSON; `where` and `check` are portable conditions.
* An entry matches an allowed call for its permission when its tenant is the decision's tenant (the matched membership's root scope, else the active tenant), its `actors` list contains the actor's kind, and its `where` and `check` hold.
* The effective requirement combines the grant's own approval and every matching entry as stages: `sequential` when any part is sequential, `all` otherwise. Identical stages appear once. The grant's escalation is kept and still applies to every stage; an entry's `escalation` is attached to that entry's own stages only, so data can never let someone step in on the code's stages. The shortest `ttl` wins, and `distinct` stays on unless every part turns it off.
* A call that ends up needing approval does not consume quota.
* A source that throws, rejects or returns an entry that does not load makes every call an allow would grant deny with reason `approval` and detail `approval-policy-unavailable` (`APPROVAL_POLICY_UNAVAILABLE` from `permdock`, for an app that tells this denial apart). An entry naming a permission the policy does not declare applies to nothing.
* `where` reads the row, so it is allowed only on an instance permission. On a collection permission (`invoice.create`) an entry with `where` does not load, because a collection action has no row and the entry could never match; use `check`, which reads the call's input (the proposed row).
* `validateApprovalPolicy(policy, entry)` checks one entry the way the source is loaded and returns `{ ok: true }` or `{ ok: false, problem }`, with `problem` one of `unknown-permission`, `invalid`, `where-on-collection` and `stale-on-without-version`. Call it where entries are saved, so a bad entry is refused there instead of denying every call.
* Client snapshots do not carry the entries: `fromSnapshot` and `usePermission` answer from the code grants, and the server's `decide` adds the approval.

`testApprovalPolicySource` in `permdock/testing` checks a source ([extension interfaces](/docs/concepts/extension-interfaces#conformance-runners)).

## Request lifecycle [#request-lifecycle]

1. An adapter calls `decide`; the outcome is `approval-required` with a deterministic `token`.
2. The adapter builds an `ApprovalRequest` and calls `store.create`. An `approval` event with `phase: 'requested'` fires on `on('decision')`.
3. The runtime surfaces the ask (AI SDK `user-approval`, Eve `input.requested`, OpenAI `interruptions`, a `403` with the token, a terminal prompt, or `useApproval(decision).request()` from a UI built with `permdock/react` ([UI](/docs/concepts/ui))).
4. A person answers through the runtime's own UI, through `approvalsHandler`, or through the PermDock Cloud inbox. `store.resolve` records the verdict and approver; once the grant's `quorum` is met (one approver unless the grant says otherwise), an `approval` event with `phase: 'resolved'` fires.
5. The original call is retried. The adapter looks up the token, requires `status: 'approved'` and `expiresAt` in the future, re-runs `decide`, recomputes the token and compares. Only then does the tool or route execute; the resumed decision fires as a normal `decision` event carrying the same `token`.

Over plain HTTP, step 5 is the retried request with a `PermDock-Approval: <token>` header; the [server kernel](/docs/adapters/server-kernel) reads it.

## What it validates [#what-it-validates]

* The approver comes from `subject` in `approvalsHandler` options or from the adapter's verified session, never from the approve request body.
* The approver is not the request's actor, and optionally not its principal.
* Status transitions: only `pending` can become `approved` or `rejected`; `expired` is terminal; a second resolve of a resolved request, or a second approval from the same principal, is a `409`.
* `expiresAt` is the window from `createdAt`, shortened to the grant's `approval.ttl` when that is shorter; a grant never keeps a request open longer than the window. The window is the `ttl` the call passes (`resumeDecision({ ..., ttl })`, `requestApproval(store, decision, { ..., ttl })`, in milliseconds), else the store's own `ttl` (`memoryApprovalStore({ ttl })`, or a custom store's optional `ttl` field), else `DEFAULT_APPROVAL_TTL_MS` (one hour). A `ttl` that is not a positive whole number of milliseconds throws a `RangeError`, which an adapter turns into a denial.
* Tokens on resume: a token only applies to the call it was issued for. On that call, unknown, pending, rejected, expired, consumed or mismatched tokens are `denied` with reason kind `approval`; on any other call, or when the decision needs no approval, the token is ignored. Under `approval: { staleOn: 'resource-change' }`, a token issued for an earlier `version` of the same row (same permission, resource and principal, still pending or approved) is `denied` with reason `stale-approval` and is not consumed; the call without it asks again ([approval security](/docs/security/approvals#approvals-that-go-stale)). No token is ever accepted without a fresh `decide`. Agent adapters read it from the `permdockApproval` context key. Approvals are per call; a session-scoped approval such as Eve's `once()` helper is not expressible in the store.
* `memoryApprovalStore` is per process. The adapter logs a development warning when it is used in a serverless runtime, because a request that resumes on another instance will not find its record.

## How denials surface [#how-denials-surface]

* Resume with a bad token: the normal `denied` path of the calling adapter (`'denied'` in the AI SDK, `{ type: 'denied', reason }` in Eve, `state.reject` in OpenAI, `403` Problem Details over HTTP) with `detail` naming the cause: `approval-not-found`, `approval-pending`, `approval-rejected`, `approval-expired`, `approval-consumed`, `approval-mismatch`. A stale approval has its own reason, `stale-approval`, and no `detail`.
* Approver not allowed: `403` from `approvalsHandler` with the `denied` problem type and `detail` "approver is the actor of this request".
* Everything is recorded: the ask, the answer and the resumed decision share `token`, so a `DecisionSink` reconstructs the flow from three rows.

## Recipe: a Drizzle-backed store [#recipe-a-drizzle-backed-store]

The interface is small enough that persistence is a short file. This is documentation, not a package entry; PermDock Cloud implements the same interface over its API. The same code (`tests/integration/fixtures/approval-store/drizzle.ts`) runs against Postgres under `testApprovalStore(store, { reopen })`, so it holds the idempotent `create`, single-use `consume`, tenant-bound `resolve` and resume-after-restart rules.

```ts
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type {
  ApprovalListQuery,
  ApprovalRequest,
  ApprovalStatus,
  ApprovalStore,
} from "permdock/approvals";

import { and, eq, gt, isNull, lte, or, sql } from "drizzle-orm";
import { jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { ApprovalError, applyApprovalVerdict } from "permdock/approvals";

export const approvals = pgTable("permdock_approvals", {
  token: text("token").primaryKey(),
  body: jsonb("body").$type<ApprovalRequest>().notNull(),
  status: text("status").$type<ApprovalStatus>().notNull(),
  tenant: text("tenant"),
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
  consumedAt: timestamp("consumed_at", { withTimezone: true }),
});

export const approvalsDdl = `
create table if not exists permdock_approvals (
  token text primary key,
  body jsonb not null,
  status text not null,
  tenant text,
  expires_at timestamptz not null,
  consumed_at timestamptz
)`;

export function drizzleApprovalStore(db: NodePgDatabase): ApprovalStore {
  const get = async (token: string): Promise<ApprovalRequest | null> => {
    const [row] = await db
      .select({ body: approvals.body })
      .from(approvals)
      .where(eq(approvals.token, token));
    return row?.body ?? null;
  };

  return {
    async create(request) {
      const values = {
        token: request.token,
        body: request,
        status: request.status,
        tenant: request.subject.principal?.tenant ?? null,
        expiresAt: new Date(request.expiresAt),
        consumedAt: null,
      };
      // A repeated ask keeps the existing record; only an expired one is replaced.
      await db
        .insert(approvals)
        .values(values)
        .onConflictDoUpdate({
          target: approvals.token,
          set: values,
          setWhere: or(
            eq(approvals.status, "expired"),
            lte(approvals.expiresAt, new Date()),
          ),
        });
    },
    get,
    async resolve(token, verdict) {
      const current = await get(token);
      if (current === null) {
        throw new ApprovalError("approval-not-found", "approval was not found");
      }
      const next = applyApprovalVerdict(current, verdict);
      // Write only over the row this verdict was computed from, so two
      // approvers racing towards a quorum both count.
      const seen = current.approvals?.length ?? 0;
      const [row] = await db
        .update(approvals)
        .set({ status: next.status, body: next })
        .where(
          and(
            eq(approvals.token, token),
            eq(approvals.status, "pending"),
            gt(approvals.expiresAt, new Date()),
            sql`coalesce(jsonb_array_length(${approvals.body} -> 'approvals'), 0) = ${seen}`,
          ),
        )
        .returning({ body: approvals.body });
      if (row === undefined) {
        throw new ApprovalError(
          "approval-not-pending",
          "approval is not pending",
        );
      }
      return row.body;
    },
    async consume(token, now = new Date()) {
      const [row] = await db
        .update(approvals)
        .set({
          consumedAt: now,
          body: sql`${approvals.body} || jsonb_build_object('consumedAt', ${now.toISOString()}::text)`,
        })
        .where(
          and(
            eq(approvals.token, token),
            eq(approvals.status, "approved"),
            isNull(approvals.consumedAt),
            gt(approvals.expiresAt, now),
          ),
        )
        .returning({ body: approvals.body });
      return row?.body ?? null;
    },
    async list(query: ApprovalListQuery) {
      const limit = Math.min(Math.max(Math.trunc(query.limit ?? 50), 1), 200);
      let after: readonly [string, string] | undefined;
      if (query.cursor !== undefined) {
        try {
          const parsed: unknown = JSON.parse(query.cursor);
          if (
            !Array.isArray(parsed) ||
            typeof parsed[0] !== "string" ||
            typeof parsed[1] !== "string"
          ) {
            return { items: [] };
          }
          after = [parsed[0], parsed[1]];
        } catch {
          return { items: [] };
        }
      }
      const createdAt = sql`${approvals.body} ->> 'createdAt'`;
      const rows = await db
        .select({ body: approvals.body })
        .from(approvals)
        .where(
          and(
            query.status === undefined
              ? undefined
              : eq(approvals.status, query.status),
            query.tenant === undefined
              ? undefined
              : eq(approvals.tenant, query.tenant),
            query.principalId === undefined
              ? undefined
              : sql`${approvals.body} #>> '{subject,principal,id}' = ${query.principalId}`,
            query.actorId === undefined
              ? undefined
              : sql`${approvals.body} #>> '{subject,actor,id}' = ${query.actorId}`,
            query.session === undefined
              ? undefined
              : sql`${approvals.body} #>> '{subject,session}' = ${query.session}`,
            after === undefined
              ? undefined
              : sql`(${createdAt}, ${approvals.token}) > (${after[0]}, ${after[1]})`,
          ),
        )
        .orderBy(createdAt, approvals.token)
        .limit(limit + 1);
      const items = rows.slice(0, limit).map((row) => row.body);
      const last = items.at(-1);
      return rows.length > limit && last !== undefined
        ? { items, next: JSON.stringify([last.createdAt, last.token]) }
        : { items };
    },
    async expire(now = new Date()) {
      const rows = await db
        .update(approvals)
        .set({
          status: "expired",
          body: sql`${approvals.body} || '{"status":"expired"}'::jsonb`,
        })
        .where(
          and(eq(approvals.status, "pending"), lte(approvals.expiresAt, now)),
        )
        .returning({ token: approvals.token });
      return rows.length;
    },
  };
}
```

`create` uses `onConflictDoUpdate` with `setWhere` so a repeated ask keeps the existing record and only an expired one is replaced. `resolve` writes the whole next body, never a partial one; `applyApprovalVerdict` applies the actor, four-eyes, tenant, `approvers`, escalation and quorum rules with the same `ApprovalError` codes `approvalsHandler` maps to Problem Details, and the `jsonb_array_length` guard makes a quorum race lose the write rather than an approval.

## A generated store for Postgres and Supabase [#a-generated-store-for-postgres-and-supabase]

`rls.approvals: true` makes `permdock rls generate` add the store to the helpers: an `approval_requests` table in the helper schema (`permdock` unless `rls.schema` says otherwise) holding each request's body with the columns `list` filters on, and one `security definer` function per method (`permdock_approval_open`, `_get`, `_resolve`, `_consume`, `_list`, `_expire`, `_cancel`), each one statement, so a repeated ask keeps the open request, a quorum race loses the write and not an approval, and two resumes never both consume. RLS is on, and no client role may read the table or execute the functions. `supabaseApprovalStore(client, { schema?, ttl?, onOpen? })` from `permdock/supabase` implements `ApprovalStore` over them through supabase-js; grant the functions to the role of the client you pass, which is a server-side one:

```sql
grant usage on schema permdock to service_role;
grant execute on function permdock.permdock_approval_open(jsonb), permdock.permdock_approval_get(text),
  permdock.permdock_approval_resolve(text, jsonb, integer), permdock.permdock_approval_consume(text, text),
  permdock.permdock_approval_list(jsonb, text, text, integer), permdock.permdock_approval_expire(text),
  permdock.permdock_approval_cancel(jsonb, text, text, text) to service_role;
```

```ts
import { approvalsHandler } from "permdock/approvals";
import { supabaseApprovalStore } from "permdock/supabase";

const store = supabaseApprovalStore(admin, {
  // the app's own semantics on open: notify approvers, cancel the requests this one replaces
  onOpen: async (request) => notifyApprovers(request),
});
```

`onOpen` runs once per stored request, after the call that stored it, with the stored request; a repeated ask that finds the request still open does not run it again, so a notification or a record the app opens there is not duplicated. A throw fails the open, so the action does not run. Routing to a manager chain or a delegate is the app's: `approverRelations` and `approverPermissions` feed the facts `applyApprovalVerdict` checks, and `cancelApprovals` rejects the requests a newer one supersedes. The same conformance runner as the Drizzle recipe runs it against Postgres. Schedule `store.expire()` (a cron or `pg_cron`) to mark overdue requests expired.

### Adopting an existing approvals table [#adopting-an-existing-approvals-table]

An app that already keeps approvals in its own table points `rls.approvals` at it instead of getting `approval_requests`:

```ts
rls: {
  approvals: {
    table: "public.approvals",
    token: "permdock_token", // a text column, unique per request; default token
    body: "request", // a jsonb column holding the ApprovalRequest; default body
    mirror: { status: "state", tenant: "organization_id", expiresAt: "expires_at" },
  },
},
```

`generate` adds the `token` and `body` columns when the table lacks them, a unique index on the token and indexes for paging and status, and writes the same `permdock_approval_*` functions over the table, so `supabaseApprovalStore` works unchanged. The functions read every field from the body, which stays the record of truth: no column type of the app's has to match PermDock's, and rows the app wrote without a body are never listed, expired or cancelled. `mirror` copies request fields (`status`, `permission`, `tenant`, `principalId`, `actorId`, `session`, `approvals` as a count, `createdAt`, `expiresAt`, `resolvedAt`, `resolvedBy`, `consumedAt`) into the app's columns on every write, converted to each column's type through `jsonb_populate_record`, so the app's own screens and queries keep reading them. Other columns need a default or must accept null, unless the app inserts the row itself with `open: 'attach'`. The table's grants and policies stay the app's: `generate` does not enable RLS or revoke anything on it, so keep client roles from reading bodies there, which carry a summary of the requesting subject. `rls.jsonSchema` checks the adopted body column.

When the table has required columns only the app can fill (an organization, a title, a link to the record awaiting approval), set `open: 'attach'`. The app inserts its row with the request's token in the token column before the store opens the request, and `permdock_approval_open` attaches the request to that row instead of inserting one: it writes the body and the mirrored columns where the token matches and the row has no body yet, or an expired one, and keeps a pending one. With no row holding the token it raises `P0002` (`no row of <table> holds approval token <token>`), so the open fails and the action does not run. Wrapping `create` inserts the row on every path that opens a request, adapters included:

```ts
const generated = supabaseApprovalStore(admin, { schema: "public" });

export const store: ApprovalStore = {
  ...generated,
  async create(request) {
    await admin.from("approvals").upsert(
      {
        organization_id: request.subject.principal?.tenant,
        title: request.detail,
        permdock_token: request.token,
      },
      { onConflict: "permdock_token", ignoreDuplicates: true },
    );
    await generated.create(request);
  },
};
```

`schema` writes the store functions into another schema than the helper schema, such as `public` when the helper schema is not exposed through the API: `approvals: { table: 'public.approvals', schema: 'public' }` with `supabaseApprovalStore(client, { schema: 'public' })` needs no wrapper functions. They stay closed to `anon` and `authenticated` wherever they live.

`rls.jsonSchema` adds a check constraint on `body`, so the database refuses a row that is not a v1 [approval request](/docs/concepts/wire-formats#approval-request) whatever writes it:

| `rls.jsonSchema` | Generated SQL |
| --- | --- |
| `false` (default) | No constraint |
| `true` | `create extension if not exists pg_jsonschema with schema extensions`, then the constraint; the migration fails without the extension |
| `'auto'` | The same statements inside a `do` block that runs only when `pg_available_extensions` lists `pg_jsonschema` |

The constraint is `approval_requests_body_schema`: `check (extensions.jsonb_matches_schema('<approval-request-v1.json>'::json, body))`. It is added `not valid` and then validated, and each run replaces it, so a regenerated schema applies to existing rows. A violation fails the write with SQLSTATE `23514`, and `supabaseApprovalStore` rejects with it. pg\_jsonschema compiles the schema on every write; Supabase ships version 0.3.3.

## Recipes for other stores [#recipes-for-other-stores]

The Drizzle store above is the template; every other backing store is the same methods over a different driver, and none is a package:

| Runtime or store | `ApprovalStore` | Notes |
| --- | --- | --- |
| Cloudflare Durable Objects | One object per approval `token`, or one per tenant holding a map; `alarm()` implements expiry | The natural fit: single-writer, survives isolate restarts, colocated with the Agents SDK. KV works for `SnapshotSource` (eventually consistent reads are fine for snapshots) but not for approvals, which need a consistent `resolve` |
| Cloudflare D1, Turso, SQLite | The Drizzle store with the `sqlite` dialect | `expiresAt` comparisons in the query; a scheduled job or the read path sweeps expired rows |
| Redis, Upstash, Vercel KV | Hash per `token` with `EXPIRE` set to `expiresAt`; `resolve` is a `WATCH` or Lua script so two approvers cannot both win | Good for serverless functions; `list` needs a secondary index (a sorted set per subject) |
| Postgres, Neon, Supabase | The Drizzle store with the `pg` dialect, or the equivalent Prisma or Kysely queries | Row-level security on the approvals table can restrict `list` to the approver's tenant using the same generated policies as the rest of the schema |
| Durable execution (Inngest, Vercel Workflow, LangGraph checkpointer) | The framework's own durable step or state holds the pending request; `resolve` is the event that resumes it | The token check still runs in the adapter on resume ([ecosystem index](/docs/research/ecosystem-index)) |
| PermDock Cloud | `cloud().approvals` | The hosted implementation of the same interface ([Cloud adapter](/docs/adapters/cloud)) |

Whatever the store, `permdock doctor` warns when `memoryApprovalStore()` is the configured store in a serverless or edge target, because a cold start would lose pending approvals ([installation](/docs/getting-started/installation), runtimes).

## Delivery [#delivery]

A store holds the pending request; something still has to tell a human. Delivery is a recipe over the `approval` event that fires when a request is created (`permdock.on('approval')`, or the same event arriving at a `DecisionSink`), never a package, and the approver's identity always comes from the surface that authenticated them, never from the message ([adapters](/docs/adapters), [threat model](/docs/security/threat-model)). The reference recipe is the [Vercel Chat SDK](https://chat-sdk.dev), because it is the one surface that is durable, signature-verified and reaches Slack, Microsoft Teams and Discord from one call; it is also what the PermDock Cloud inbox uses for its Slack and Teams delivery, so self-hosters and Cloud users run the same code path. Neither the store nor the adapters have a `notify` hook: the `approval` event already reaches every listener and sink, so delivery needs no new interface method.

```ts
import { requestApproval } from "chat/workflow";
import { permdock, store } from "./permdock"; // the factory result and your ApprovalStore
import { subjectFromChatUser } from "./subjects"; // maps a verified chat user to a Subject

// `approval` events fire when a request is created and when it is resolved; a DecisionSink sees the same events
permdock.on("approval", async (event) => {
  if (event.request.status !== "pending") return;
  // Runs inside a Workflow SDK step, so the wait survives redeploys and cold starts
  const outcome = await requestApproval({
    channel: process.env.APPROVALS_CHANNEL!,
    approvers: await approversFor(event.request), // Slack / Teams user ids allowed to answer
    title: `Approve ${event.request.permission}?`,
    body: describe(event.request), // reason, resource id, actor, expiry from the request
    timeout: event.request.expiresAt,
  });
  // `outcome.user.id` is the platform-verified responder; the Chat SDK checked the signature
  const by = await subjectFromChatUser(outcome.user);
  await store.resolve(
    event.request.token,
    outcome.approved ? "approved" : "rejected",
    { by },
  );
});
```

What the recipe does and does not do:

* **Identity.** The Chat SDK verifies the platform signature on the interaction and returns `user.id`; `subjectFromChatUser` maps that id to a `Subject` from your own directory (a Slack user id to an employee record). `store.resolve` then applies the actor-is-not-approver rule and any `requireDistinctApprover` policy, and the resumed call recomputes the `token`, so a forged or replayed card cannot approve a different call.
* **Durability.** `requestApproval` is a Workflow SDK step; the pending wait outlives the function invocation. The store record is still the source of truth: if the workflow is lost, the request expires at `expiresAt` and the agent's resume fails closed.
* **Approvers.** `approvers` on the card limits who can click; PermDock's check runs anyway, because the card is a convenience and the store is the control.
* **Not delivery of the decision.** Denials are not sent anywhere by this recipe; they are decision events for a sink ([audit and observability](/docs/concepts/audit-and-observability)).

Other surfaces implement the same three steps (send, wait, resolve with a verified identity):

| Surface | Send and wait | Verified responder | Notes |
| --- | --- | --- | --- |
| Trigger.dev waitpoints | `wait.createToken` produces an HMAC-signed callback URL; `wait.forToken` suspends the task | Whoever your callback endpoint authenticates (a session or a signed link); the token is not an identity | Pair with the approval UI or a Chat SDK card that calls the callback |
| Temporal, Restate, Inngest, Cloudflare Workflows | Signals, awakeables, `step.waitForEvent`, `waitForEvent` | The event's sender as authenticated by your HTTP layer | The durable step holds the wait; the store record holds the request |
| n8n | AI Agent tool "Require approval" and the "Send and Wait for Response" node (Slack, Teams, Gmail, Telegram, Discord, WhatsApp) | n8n's own credential for the channel; map the responder in the workflow | No-code delivery for agents that run inside n8n; the tools themselves are guarded through `permdock/mcp` |
| Eve | `approval.response` receives the authenticated `responder` | Eve | The adapter already does this; no delivery code needed ([Eve adapter](/docs/adapters/eve)) |
| Email and notification infrastructure (Resend, Knock, Novu, Courier, Twilio) | Send a message with a link to `approvalsHandler` | The approver's session on the approval page | A channel only; a link is never an approval by itself, and the approver always authenticates on the page |
| PermDock Cloud | Inbox UI plus Slack and Teams through the Chat SDK recipe above | Cloud authentication and the platform signature | The hosted implementation of the same store and the same recipe ([Cloud adapter](/docs/adapters/cloud)) |

## Why [#why]

* **The requester never approves by default.** An approval exists so a second person looks at the call. If `approval: 'human'` let the principal approve, a user could ask their own agent to issue a refund and approve it themselves, and the approval would add a click, not a check. So every approval refuses the principal as well as the actor, and a grant that really wants the user to confirm their own agent's call says so with `approval: { distinct: false }`, which `permdock doctor` PD024 lists. `requireDistinctApprover` is a handler-wide floor, for an app where no grant may opt out.
* **Keyset pages with an opaque cursor.** An inbox or an access review over a busy environment can hold thousands of requests, and an unbounded `list` makes every store load all of them. Offsets skip or repeat rows while `expire` and new asks change the set; ordering by `(createdAt, token)` and resuming after the last pair is stable under both, and every database the recipes target can index it. The cursor stays opaque so a store may encode its own position (the Cloud uses its own), and an unreadable one ends the listing instead of restarting it, so a tampered cursor can never loop a client.
* **No approval without a session.** An emailed or chat link carries only the token and opens an authenticated page that calls `approvalsHandler`; the approver is whoever that page authenticated. A signed link that approves on its own would make possession of an email the approver's identity, bypass `approvers`, `distinct` and tenant membership, and turn every forwarded message or link-preview bot into an approver. The Chat SDK recipe below is the same rule: the platform's signature-verified user is mapped to an application subject, never read from the message.

## Example app [#example-app]

None of its own. `apps/examples/ai-sdk-agent`, `apps/examples/eve-agent` and `apps/examples/openai-agent` each mount `approvalsHandler` next to their agent. Approving from a second authenticated user and refusing an approval from the actor itself are tested in `packages/permdock/tests/approvals`. `apps/examples/terminal` resolves approvals interactively against the same store.

## Related standards [#related-standards]

* [Approvals](/docs/security/approvals): the grant, the token, the resume flow.
* [Wire formats](/docs/concepts/wire-formats): the `ApprovalRequest` shape.
* [Audit and observability](/docs/concepts/audit-and-observability): `approval` events and the `DecisionSink`.
* [Problem Details](/docs/standards/problem-details): the `403` bodies.
* [Cloud adapter](/docs/adapters/cloud): the hosted store with an inbox UI.
