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 }); // seconds; 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; // seconds 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?: Instant) => Promise<number> | number;
consume: (
token: string,
now?: Instant,
) => 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. A store replaces a record only when itsstatusisexpiredor itsexpiresAthas passed, so an agent loop that asks twice never resets an approval to pending, and a resolved request never returns topending.testApprovalStore(store, { capabilities: { expire } })checks the replacement whenexpire(token)can age a record in the backing storage.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(tenant membership).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) makesapprovalsHandlerserveGET /:id/signatureonce the request is approved: a compact JWS withtyp: vnd.permdock.approval+jwtasapplication/jwt, to the requester and the approvers only. The approve response links it withLink: <…/:id/signature>; rel="alternate"; type="application/jwt". The JWS carries the registered claimsiss,aud,sub(the principal),act(the actor that asked),iat(=resolvedAt),exp(= the request'sexpiresAt) andjti(= the requestid), plustoken,resolved_by,approvalsand one RFC 9396authorization_detailsentry for the approved action (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 hashtoken(sha-256:<base64url>) stays the thing that is compared: the resuming side verifies the JWS with aTokenVerifierexpectingtyp: vnd.permdock.approval+jwt, reads itstokenclaim, and sends that value asPermDock-Approvalfor the unchanged step 5 below.PermDock-Approvalcarries only the hash, as an RFC 9651 sf-token; the header never accepts the JWS.
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, an Instant, 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 /:id/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 /:id/reject | Marks rejected with an optional note of at most 2000 characters; a longer note is a 400 and a body over 16 KiB a 413, and the request stays pending | Same |
GET /:id | One request, for a confirmation screen, with a strong ETag; If-None-Match answers 304. A request without a tenant is visible only to its requester, who answered it and who may answer it | 404 for unknown or foreign ids |
GET /:id/signature | The signed approval as application/jwt, when signer is set and the request is approved | 404 otherwise, and for anyone but the requester and the approvers |
Every route takes the request id. No response carries the resume token: a view is the ApprovalRequest without it (ApprovalView), so an approver's browser never holds what resumes the call.
- Preconditions.
approveandrejecthonourIf-Matchwith theETagfromGET /:id; a stale tag is412, so two approvers on one screen cannot overwrite each other. - Entity tags. The
ETagisapprovalEntityTag(request): the quotedsha-256:digest (sha256Digest) of the RFC 8785 JSON (canonicalJson) ofapprovalView(request). The view is the wholeApprovalRequestwithouttoken; it leaves out nothing else. Hash the request exactly as the store returns it: a field outsideApprovalRequest, such as a hosting service'senvironmentId, changes the tag. A service that serves the same approvals answersIf-None-MatchandIf-Matchwith this function, never with its own hash. - Retries. An
Idempotency-Key(an RFC 9651 sf-string of 1 to 256 characters) onapproveorrejectreplays the first successful response for 24 hours, per principal. The same key with another route, id or body is422.idempotency(anIdempotencyStore) replaces the per-handlermemoryIdempotencyStore(); deployments with more than one instance share one. - Idempotency records.
idempotencyRecord({ principalId, key, call, response, now? })builds theIdempotencyRecordthe handler stores:keyfromidempotencyRecordKey(principalId, key), theIdempotentResponse(status, headers, body text and the call's fingerprint) andexpiresAt24 hours on.callis anIdempotentCall,{ action: 'approve' | 'reject', id, body }, withbodythe request body text.matchesIdempotencyRecord(response, call)answers whether a stored response came from the same call;falseis the422. A service that shares anIdempotencyStorewithapprovalsHandlerwrites and compares records only through these functions. - Paging.
GET /pendingandGET /mineaddLink: <…?cursor=…>; rel="next"when there is a next page. - Unknown routes. An unknown path is
404; a known path with the wrong method is405withAllow.
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.
With PermDock Cloud as the store, the environment's directory mode decides which facts the Cloud checks approvers against (approver token):
| Directory mode | Where the Cloud reads the approver's facts | Signed facts on the approver token |
|---|---|---|
| Bring your own | The signed PermDock-Approver token: roles, memberships, tenant, and the entry's permissions and relations | The only input |
| Cloud-native | Its own directory, and the policy policyFromCatalog rebuilds from the pushed catalog | Ignored |
Tenant membership
The request's tenant is subject.principal.tenant at the time it was created. An approver belongs to it when one of these holds:
- the approver's
principal.tenantis that tenant; - one of the approver's memberships sits at the first scope with that id:
{ tenant }withoutteamin the shorthand, or{ scope, id }withoutwithinwith named scopes.
A membership at a nested scope does not count, even when its parent is the request's tenant. Holding lead on a team inside org_1 does not make an approver part of org_1, because a team membership grants nothing at the organisation level (scopes). The same rule picks the tenants in the pending inbox.
const request = {
subject: { principal: { id: "u_1", roles: [], tenant: "org_1" } } /* … */,
};
// Refused: "approver does not belong to the request tenant" (approver-not-eligible).
const teamLead = {
principal: {
id: "u_2",
roles: [],
memberships: [
{
scope: "team",
id: "team_7",
within: { org: "org_1" },
roles: ["lead"],
},
],
},
context: {},
};
// Accepted: the same person with a membership at the organisation.
const member = {
principal: {
id: "u_2",
roles: [],
memberships: [
{ scope: "org", id: "org_1", roles: ["member"] },
{
scope: "team",
id: "team_7",
within: { org: "org_1" },
roles: ["lead"],
},
],
},
context: {},
};The fix is data, not policy: give every approver a membership at the first scope of the tenant they approve in. approvers.by then decides which of those members may resolve the request. A role approver matches a global role or a role on that first-scope membership, never a role held only on a team, so an approver named by role needs the role on the organisation membership too.
Approval policies as data
approvalPolicies on getPermDock and createPermDock takes approvalPolicies(source) for any ApprovalPolicySource (a memoryApprovalPolicySource is accepted as is), in core 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 { getPermDock, memoryApprovalPolicySource } from "permdock";
const permdock = await getPermDock(policy, user, {
approvalPolicies: memoryApprovalPolicySource([
{
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 seconds), else the store's ownttl(memoryApprovalStore({ ttl }), or a custom store's optionalttlfield), elseDEFAULT_APPROVAL_TTL(3600 seconds). Attlthat is not a positive whole number of seconds 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. Itsnowoption is a clock returning anInstant(memoryApprovalStore({ now: () => Temporal.Now.instant() }));create,cancel, andconsumeorexpirewithout anat, read it instead of the system clock.
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, at) {
const now = new Date(at?.epochMilliseconds ?? Date.now());
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(at) {
const now = new Date(at?.epochMilliseconds ?? Date.now());
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. approvalPrompt(request, { operation }) from permdock/delivery turns the request into an ApprovalPrompt without the token, and deliver sends it to the approvers, checked against a permission each of them holds. approvalEmail from permdock/resend renders the prompt as an email with a link to the approval page, and approvalCard from permdock/chat-sdk renders it as a chat card (delivery, Resend, Chat SDK).
The approver's identity always comes from the surface that authenticated them, never from the message (threat model):
- Email. A link never approves. The approver signs in on the page that
approvalsHandlerserves and decides as themselves. - Chat.
approvalActionsresolves the platform-verified clicking user through the app's identity links and callsresolveAction. The buttons carry the request id, never the token, and the store still applies the actor-is-not-approver rule. - Background runs. A run waiting through an
ApprovalWaiterresumes when the approver endpoint callswaiter.wake(token)afterresolveApproval(automations).
Denials are not delivered; 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 (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 delivery | Cloud authentication and the platform signature | The hosted implementation of the same store and the same delivery (Cloud adapter) |
Recipe: approval through CIBA
OpenID Connect Client-Initiated Backchannel Authentication (CIBA Core 1.0) asks a user to authenticate on their own device while the client waits. It can fulfil an approval-required decision: the backend sends the request to the identity provider, the approver confirms on their phone, and the ID token that comes back is the authenticated approver resolve needs. PermDock does not drive CIBA; the backend does, with the provider's client library.
import { resolveApproval } from "permdock/approvals";
import { subjectFromJwt } from "permdock/jwt";
const request = await store.get(decision.token);
const started = await ciba.backchannelAuthenticate({
scope: "openid",
login_hint: approverEmail, // who must approve, chosen by the app from request.approvers
binding_message:
`Approve ${request.permission} ${request.resource.id ?? ""}`.slice(0, 64),
});
const result = await ciba.poll(started.auth_req_id); // grant_type=urn:openid:params:grant-type:ciba
if (result.id_token !== undefined) {
const approver = await subjectFromJwt(result.id_token, {
discovery: "https://login.example.com",
audience: CIBA_CLIENT_ID,
accept: "id-token",
});
await resolveApproval(store, decision.token, {
status: "approved",
by: approver,
});
}- The approver is the ID token's
sub, verified bysubjectFromJwt.resolveApprovalruns every eligibility and separation check, so a token for the wrong person is refused like a wrong click in the inbox. - CIBA carries no authenticated "no":
access_deniedcomes without an ID token, so it cannot resolve the request asrejected. Leave the request pending until itsexpiresAt, and tell the agent the user declined. - CIBA often asks the user the agent acts for. That user is the request's principal, who may approve only when the grant sets
approvers: { distinct: false }(approvals). - The
binding_messageis shown on both devices. Build it from the stored request, never from model output, and keep it short: providers cap its length. - An expired
auth_req_id(expired_token) also leaves the request pending. cibastands for the provider's client. Auth0, Okta, Ping and Keycloak implement CIBA; check that the provider's CIBA client may use the poll or ping mode the backend expects.
Delegation
A delegate may approve whatever the delegator could approve, inside a time window. PermDock records the approval as the delegate's, acting for the delegator. There is one hop: a delegate's own delegations are never followed.
import { getPermDockSync } from "permdock";
import {
approvalsHandler,
memoryApprovalDelegationSource,
} from "permdock/approvals";
const handler = approvalsHandler(store, {
approvalDelegations: memoryApprovalDelegationSource([
{
id: "del_1",
delegator: "u_owner",
delegate: "u_cover",
from: "2026-10-01T00:00:00.000Z",
until: "2026-10-15T00:00:00.000Z",
},
]),
subjectFor: (principalId) => loadSubject(principalId),
approverRelations: (approver, request) => relationsFor(approver, request),
permdockFor: (approver, request) =>
getPermDockSync(policy, approver, {
tenant: request.subject.principal?.tenant,
}),
});from is inclusive and until is exclusive. Both are required: there is no open-ended delegation. permissions and resources narrow what the delegation covers; absent, it covers every request. tenant holds it to one tenant. A delegation whose delegator and delegate are the same person is dropped.
On Supabase, rls.approvals: { delegations: true } writes an approval_delegations table that users fill with their own covers, and tableApprovalDelegationSource({ query }) from permdock/supabase reads it. The same rows feed permdock_is_approver for an approval decided in SQL (approval delegation with better-supabase). query runs one parameterised statement as a backend role, like supabasePostgresQuery(ctx.postgresAdmin).
SQL honours fewer covers than the handler:
| Cover | approvalsHandler | permdock_is_approver in SQL |
|---|---|---|
No permissions | Honoured | Honoured |
With permissions | Honoured | Skipped: the covers query requires permissions is null |
For a holder() approver, jwt mode | Honoured | Never matches, because the caller's token does not carry the delegator's permissions; rls generate warns about it |
For a holder() approver, database mode | Honoured | Matches through permdock_has_permission_for on the delegator |
An approval decided in SQL therefore needs covers without permissions, and holder() approvers through a cover need --authorize database.
import {
supabasePostgresQuery,
tableApprovalDelegationSource,
} from "permdock/supabase";
approvalsHandler(store, {
approvalDelegations: tableApprovalDelegationSource({
query: supabasePostgresQuery(ctx.postgresAdmin),
}),
subjectFor,
permdockFor,
});Without both approvalDelegations and subjectFor, nobody matches through a delegation. If either throws, nobody matches, so it fails closed.
The handler tries the caller as themselves first. Only an approver-not-eligible result continues to delegations. For each active match it builds the delegator's relation facts and permission facts, then runs the same approver check on the delegator's subject. The delegate is never the request's actor, and never its principal unless the grant sets distinct: false. The delegator is checked the same way, so a delegate cannot approve their own request through someone else's rights. One delegator counts once, whether they signed themselves or through a delegate.
The signature stores by as the delegate, onBehalfOf as the delegator's principal id, and delegation as the delegation id. The store runs the delegator check again from the verdict, so it does not trust the handler.
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. A delegate is held to the same rule, and so is the person they approve for: covering someone's inbox does not let you approve a request you made, or one they made. - One hop, and a closed window. Following a delegate's own delegations would let a chain of covers approve as someone none of them was asked to cover. The handler never looks up the delegator's delegations.
untilis required so a cover someone forgot to end does not stay valid. - 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.
Workflow SDK
permdock/workflow-sdk runs PermDock automations on the Workflow SDK. workflowSdkWaiter parks a run on a hook while an approval is pending, startAutomation starts the PermDock run and the Workflow SDK run together, and authorizeHook resumes a hook only for a caller with the permission.