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;
}createis called by an adapter whendecidereturnsapproval-required; the record is an approval request (v: 1) and carries thetoken, the permission key, the resource id, a subject summary (including the activetenant,sessionand themembershipthat supplied the matched role),approversfromgrant.approvalwhen present, the model-readabledetail,createdAtandexpiresAt. 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.createis 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.resolverequires an approverSubjectproduced by authentication. A store must refuse an approver whose id equals the request'sactor.id, and one whose id equals the request's principal id unlessapprovers.distinctisfalse(approver-is-principal); a request withoutapprovers(approval: 'human') refuses the principal too. Whenapproversis set, the approver must belong to the request tenant and matchby(or an open stage'sby), or matchescalation.toonceescalation.afterhas passed sincecreatedAt. A relation approver matches only throughverdict.relations, the facts the handler computed; a store never derives them.approvalsHandlerenforces this before calling the store, and a custom store should too (approver-not-eligible).resolvewithstatus: 'approved'appends{ by, at, stage? }toapprovals. The request becomesapprovedwhen there areapprovers.quorumof them (default 1), or when every stage has its quorum; until then it stayspending,consumereturnsnull, and the same principal is refused a second time (approver-repeated, a409). Onerejectedverdict 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 requestsrejectedwithresolvedBy: 'system:<by>'and does not run eligibility, because a rejection never grants.cancelApprovalsinpermdock/approvalscalls it, or falls back tolistplus a systemresolve.listreturns one page, oldestcreatedAtfirst (ties broken bytoken):limitis 1 to 200 and defaults to 50,nextis present only when more requests match, and passing it back ascursorreturns the following page. The cursor is opaque to callers; an unreadable one returns an empty last page, never the first page again.testApprovalStorechecks the order, the page boundary and the unreadable cursor.cancelApprovalsand other callers that need every match follownext. A custom store reuses the built-in paging frompermdock/approvals:pageApprovals(matching, query)pages requests it filtered in memory,approvalPageSize(limit),encodeApprovalCursor(request)anddecodeApprovalCursor(cursor)serve a store that pages in its own query (the cursor is the(createdAt, token)of the last item,ApprovalCursorPosition, andnullmeans an unreadable one), andlistAllApprovals(store, filter)followsnextto the end.list({ tenant })scopes the approver inbox to one tenant, which is how a tenant admin sees only their organisation's pending requests;approvalsHandlerapplies it from the approver's active tenant and the approver must hold a membership there (tenancy).consumeis the atomic step that makes an approval single-use: it setsconsumedAton an approved, unexpired, unconsumed request and returns it, or returnsnull. 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 withoutconsumecannot 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 throwsApprovalError; custom stores call it inresolvewithverdict.relations. listpowers the approver UI and thepermdock/terminalinteractive 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.expireis called opportunistically by adapters and byapprovalsHandler; a scheduled job may call it as well.requestApproval(store, decision, meta)andresolveApproval(store, token, verdict)are the two internal helpers agent adapters share; applications rarely call them directly.signer(optional, aTokenSignersuch asjoseTokenSignerfrompermdock/jwt) makesapprovalsHandlerreturn, on approve, a compact JWS withtyp: permdock-approval+jwtalongside the bare token: registered claimsiss,aud,sub(the principal),iat,exp(= the request'sexpiresAt),jti, and{ token, permission, resource, status }under theapprovalclaim (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 adecide. The bound hashtokenstays the thing that is compared: the adapter verifies the JWS with itsverifier, extractsapproval.token, and then runs the unchanged step 5 below.PermDock-Approvalcarries either form; the kernel tells them apart by thepd1.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:
| 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
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;
whereandcheckare 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
actorslist contains the actor's kind, and itswhereandcheckhold. - The effective requirement combines the grant's own approval and every matching entry as stages:
sequentialwhen any part is sequential,allotherwise. Identical stages appear once. The grant's escalation is kept and still applies to every stage; an entry'sescalationis attached to that entry's own stages only, so data can never let someone step in on the code's stages. The shortestttlwins, anddistinctstays 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
approvaland detailapproval-policy-unavailable(APPROVAL_POLICY_UNAVAILABLEfrompermdock, for an app that tells this denial apart). An entry naming a permission the policy does not declare applies to nothing. wherereads the row, so it is allowed only on an instance permission. On a collection permission (invoice.create) an entry withwheredoes not load, because a collection action has no row and the entry could never match; usecheck, 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 }, withproblemone ofunknown-permission,invalid,where-on-collectionandstale-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:
fromSnapshotandusePermissionanswer from the code grants, and the server'sdecideadds the approval.
testApprovalPolicySource in permdock/testing checks a source (extension interfaces).
Request lifecycle
- An adapter calls
decide; the outcome isapproval-requiredwith a deterministictoken. - The adapter builds an
ApprovalRequestand callsstore.create. Anapprovalevent withphase: 'requested'fires onon('decision'). - The runtime surfaces the ask (AI SDK
user-approval, Eveinput.requested, OpenAIinterruptions, a403with the token, a terminal prompt, oruseApproval(decision).request()from a UI built withpermdock/react(UI)). - A person answers through the runtime's own UI, through
approvalsHandler, or through the PermDock Cloud inbox.store.resolverecords the verdict and approver; once the grant'squorumis met (one approver unless the grant says otherwise), anapprovalevent withphase: 'resolved'fires. - The original call is retried. The adapter looks up the token, requires
status: 'approved'andexpiresAtin the future, re-runsdecide, recomputes the token and compares. Only then does the tool or route execute; the resumed decision fires as a normaldecisionevent carrying the sametoken.
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
subjectinapprovalsHandleroptions 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
pendingcan becomeapprovedorrejected;expiredis terminal; a second resolve of a resolved request, or a second approval from the same principal, is a409. expiresAtis the window fromcreatedAt, shortened to the grant'sapproval.ttlwhen that is shorter; a grant never keeps a request open longer than the window. The window is thettlthe call passes (resumeDecision({ ..., ttl }),requestApproval(store, decision, { ..., ttl }), in milliseconds), else the store's ownttl(memoryApprovalStore({ ttl }), or a custom store's optionalttlfield), elseDEFAULT_APPROVAL_TTL_MS(one hour). Attlthat is not a positive whole number of milliseconds throws aRangeError, 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
deniedwith reason kindapproval; on any other call, or when the decision needs no approval, the token is ignored. Underapproval: { staleOn: 'resource-change' }, a token issued for an earlierversionof the same row (same permission, resource and principal, still pending or approved) isdeniedwith reasonstale-approvaland is not consumed; the call without it asks again (approval security). No token is ever accepted without a freshdecide. Agent adapters read it from thepermdockApprovalcontext key. Approvals are per call; a session-scoped approval such as Eve'sonce()helper is not expressible in the store. memoryApprovalStoreis 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
deniedpath of the calling adapter ('denied'in the AI SDK,{ type: 'denied', reason }in Eve,state.rejectin OpenAI,403Problem Details over HTTP) withdetailnaming 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 nodetail. - Approver not allowed:
403fromapprovalsHandlerwith thedeniedproblem type anddetail"approver is the actor of this request". - Everything is recorded: the ask, the answer and the resumed decision share
token, so aDecisionSinkreconstructs 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.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
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) |
| PermDock Cloud | cloud().approvals | The 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;subjectFromChatUsermaps that id to aSubjectfrom your own directory (a Slack user id to an employee record).store.resolvethen applies the actor-is-not-approver rule and anyrequireDistinctApproverpolicy, and the resumed call recomputes thetoken, so a forged or replayed card cannot approve a different call. - Durability.
requestApprovalis 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 atexpiresAtand the agent's resume fails closed. - Approvers.
approverson 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):
| 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) |
| 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) |
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 withapproval: { distinct: false }, whichpermdock doctorPD024 lists.requireDistinctApproveris 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
listmakes every store load all of them. Offsets skip or repeat rows whileexpireand 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, bypassapprovers,distinctand 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.
Related standards
- Approvals: the grant, the token, the resume flow.
- Wire formats: the
ApprovalRequestshape. - Audit and observability:
approvalevents and theDecisionSink. - Problem Details: the
403bodies. - Cloud adapter: the hosted store with an inbox UI.
Last updated on
AuthZEN
permdock/authzen serves the OpenID AuthZEN Authorization API 1.0 (evaluation, evaluations, search, discovery) from a PermDock policy so the decision endpoint is a standard PDP.
Cloud
permdock/cloud is the thin, optional client for PermDock Cloud; it implements ApprovalStore, DecisionSink and SnapshotSource over a documented HTTP API, never sits in the decision path, and is provisioned from the Vercel Marketplace.