PermDock
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

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

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));
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 (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).
  • 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). 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.

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: 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:

RoutePurposeDenial
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 filter401 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 way401 without a subject
POST /:token/approveAppends to approvals; once the quorum is met, marks approved and records resolvedBy and resolvedAt403 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/rejectMarks rejected with an optional noteSame
GET /:tokenOne request, for a confirmation screen404 for unknown or foreign tokens

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.

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

A relation() approver (approval security) 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

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.

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

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

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

  • 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

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.

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

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:

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;
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

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

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:

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 whatever writes it:

rls.jsonSchemaGenerated SQL
false (default)No constraint
truecreate 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

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 storeApprovalStoreNotes
Cloudflare Durable ObjectsOne object per approval token, or one per tenant holding a map; alarm() implements expiryThe 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, SQLiteThe Drizzle store with the sqlite dialectexpiresAt comparisons in the query; a scheduled job or the read path sweeps expired rows
Redis, Upstash, Vercel KVHash per token with EXPIRE set to expiresAt; resolve is a WATCH or Lua script so two approvers cannot both winGood for serverless functions; list needs a secondary index (a sorted set per subject)
Postgres, Neon, SupabaseThe Drizzle store with the pg dialect, or the equivalent Prisma or Kysely queriesRow-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 itThe token check still runs in the adapter on resume (ecosystem index)
PermDock Cloudcloud().approvalsThe hosted implementation of the same interface (Cloud adapter)

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

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, threat model). The reference recipe is the Vercel Chat SDK, 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.

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

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

SurfaceSend and waitVerified responderNotes
Trigger.dev waitpointswait.createToken produces an HMAC-signed callback URL; wait.forToken suspends the taskWhoever your callback endpoint authenticates (a session or a signed link); the token is not an identityPair with the approval UI or a Chat SDK card that calls the callback
Temporal, Restate, Inngest, Cloudflare WorkflowsSignals, awakeables, step.waitForEvent, waitForEventThe event's sender as authenticated by your HTTP layerThe durable step holds the wait; the store record holds the request
n8nAI 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 workflowNo-code delivery for agents that run inside n8n; the tools themselves are guarded through permdock/mcp
Eveapproval.response receives the authenticated responderEveThe adapter already does this; no delivery code needed (Eve adapter)
Email and notification infrastructure (Resend, Knock, Novu, Courier, Twilio)Send a message with a link to approvalsHandlerThe approver's session on the approval pageA channel only; a link is never an approval by itself, and the approver always authenticates on the page
PermDock CloudInbox UI plus Slack and Teams through the Chat SDK recipe aboveCloud authentication and the platform signatureThe hosted implementation of the same store and the same recipe (Cloud adapter)

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

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.

Last updated on

On this page