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.
Status: planned Phase: 1
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 (ADR 0022).
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 (ADR 0021).
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 = approver must differ from the principal too
})
app.all('/permdock/approvals/*', (c) => handler(c.req.raw))interface ApprovalStore {
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(filter: { status?: ApprovalRequest['status']; principalId?: string; actorId?: string; tenant?: string }): Promise<ApprovalRequest[]> | ApprovalRequest[]
expire(now?: Date): Promise<number> | number
}createis called by an adapter whendecidereturnsapproval-required; the record carries thetoken, the permission key, the resource id, a subject summary (including the activetenantand themembershipthat supplied the matched role), the model-readabledetail,createdAtandexpiresAt(wire formats). 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.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).resolverequires an approverSubjectproduced by authentication. A store must refuse an approver whose id equals the request'sactor.id;approvalsHandlerenforces this before calling the store, and a custom store should too.listpowers the approver UI and thepermdock/terminalinteractive prompt.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.
approvalsHandler routes, all Fetch Request to Response:
| Route | Purpose | Denial |
|---|---|---|
GET /pending | Requests the authenticated approver may resolve, in the approver's active tenant (?tenant= must be one of the approver's memberships) | 401 without a subject; 403 for a tenant the approver does not belong to |
GET /mine | The authenticated principal's own requests, for useApproval polling | 401 without a subject |
POST /:token/approve | Marks approved, records resolvedBy and resolvedAt | 403 Problem Details when the approver is the actor, or the principal with requireDistinctApprover |
POST /:token/reject | Marks rejected with an optional note | Same |
GET /:token | One request, for a confirmation screen | 404 for unknown or foreign tokens |
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; 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 is a409. - Tokens on resume: unknown, pending, rejected, expired or mismatched tokens are
deniedwith reason kindapproval. No token is ever accepted without a freshdecide. 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-mismatch. - 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 shipped entry; PermDock Cloud implements the same interface over its API.
import { eq, and, lt } from 'drizzle-orm'
import type { ApprovalStore, ApprovalRequest } from 'permdock/approvals'
import { approvals } from './schema' // columns: token (pk), body (jsonb), status, expires_at, resolved_by
export function drizzleApprovalStore(db: Db): ApprovalStore {
return {
async create(r) {
await db.insert(approvals).values({ token: r.token, body: r, status: r.status, expiresAt: new Date(r.expiresAt) })
},
async get(token) {
const row = await db.query.approvals.findFirst({ where: eq(approvals.token, token) })
return row ? { ...row.body, status: row.status } : null
},
async resolve(token, { status, by, note }) {
if (!by.principal) throw new Error('approver must be authenticated')
const [row] = await db.update(approvals)
.set({ status, resolvedBy: by.principal.id, body: { note } })
.where(and(eq(approvals.token, token), eq(approvals.status, 'pending')))
.returning()
if (!row) throw new Error('approval is not pending')
return { ...row.body, status, resolvedBy: row.resolvedBy, resolvedAt: new Date().toISOString(), note }
},
async list({ status = 'pending' }) {
const rows = await db.query.approvals.findMany({ where: eq(approvals.status, status) })
return rows.map((row) => ({ ...row.body, status: row.status }))
},
async expire(now = new Date()) {
const rows = await db.update(approvals).set({ status: 'expired' })
.where(and(eq(approvals.status, 'pending'), lt(approvals.expiresAt, now))).returning()
return rows.length
},
}
}The actor check (by.principal.id !== request.subject.actor?.id) is performed by approvalsHandler and by the agent adapters before resolve is called; a store used from custom code should repeat it.
Recipes for other stores
The Drizzle store above is the template; every other backing store is the same four methods over a different driver, and none is a package (ADR 0023):
| 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 (agent frameworks) |
| 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 (ADR 0023, 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.
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 (open question below) |
| 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) |
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 and include a test that approves from a second authenticated user and refuses an approval from the actor itself. 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.
- ADR 0022.
Open questions
- Whether
listneeds cursor pagination in the interface or whether the Cloud store may add it as an extension. - Whether
once()-style session-scoped approvals (Eve's helper) should be expressible as a store policy or stay a runtime concern. - Whether an emailed link can carry an approval without a session on the approver side, and how it would bind to an identity. Slack, Teams and Discord are no longer part of this question: the Chat SDK verifies the platform signature and returns the responder, so the "Delivery" recipe covers them.
Resolved: notification delivery. It was an open question whether a notify hook on the store or the adapter was the right seam. The answer is neither: delivery is a recipe over the approval event that already fires on on('approval') and reaches every sink, the Chat SDK recipe above is the reference, and the Cloud inbox uses the same recipe for Slack and Teams. No new interface method.
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.