PermDock
Adapters

Approvals

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

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
}
  • create is called by an adapter when decide returns approval-required; the record carries the token, the permission key, the resource id, a subject summary (including the active tenant and the membership that supplied the matched role), the model-readable detail, createdAt and expiresAt (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; approvalsHandler applies it from the approver's active tenant and the approver must hold a membership there (tenancy).
  • resolve requires an approver Subject produced by authentication. A store must refuse an approver whose id equals the request's actor.id; approvalsHandler enforces this before calling the store, and a custom store should too.
  • list powers the approver UI and the permdock/terminal interactive prompt.
  • expire is called opportunistically by adapters and by approvalsHandler; a scheduled job may call it as well.
  • requestApproval(store, decision, meta) and resolveApproval(store, token, verdict) are the two internal helpers agent adapters share; applications rarely call them directly.

approvalsHandler routes, all Fetch Request to Response:

RoutePurposeDenial
GET /pendingRequests 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 /mineThe authenticated principal's own requests, for useApproval polling401 without a subject
POST /:token/approveMarks approved, records resolvedBy and resolvedAt403 Problem Details when the approver is the actor, or the principal with requireDistinctApprover
POST /:token/rejectMarks rejected with an optional noteSame
GET /:tokenOne request, for a confirmation screen404 for unknown or foreign tokens

Request lifecycle

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

Over plain HTTP, step 5 is the retried request with a PermDock-Approval: <token> header; the server kernel reads it.

What it validates

  • The approver comes from subject in approvalsHandler options or from the adapter's verified session, never from the approve request body.
  • The approver is not the request's actor, and optionally not its principal.
  • Status transitions: only pending can become approved or rejected; expired is terminal; a second resolve is a 409.
  • Tokens on resume: unknown, pending, rejected, expired or mismatched tokens are denied with reason kind approval. No token is ever accepted without a fresh decide.
  • memoryApprovalStore is per process. The adapter logs a development warning when it is used in a serverless runtime, because a request that resumes on another instance will not find its record.

How denials surface

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

Recipe: a Drizzle-backed store

The interface is small enough that persistence is a short file. This is documentation, not a 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 storeApprovalStoreNotes
Cloudflare Durable ObjectsOne object per approval token, or one per tenant holding a map; alarm() implements expiryThe natural fit: single-writer, survives isolate restarts, colocated with the Agents SDK. KV works for SnapshotSource (eventually consistent reads are fine for snapshots) but not for approvals, which need a consistent resolve
Cloudflare D1, Turso, SQLiteThe Drizzle store with the sqlite dialectexpiresAt comparisons in the query; a scheduled job or the read path sweeps expired rows
Redis, Upstash, Vercel KVHash per token with EXPIRE set to expiresAt; resolve is a WATCH or Lua script so two approvers cannot both winGood for serverless functions; list needs a secondary index (a sorted set per subject)
Postgres, Neon, SupabaseThe Drizzle store with the pg dialect, or the equivalent Prisma or Kysely queriesRow-level security on the approvals table can restrict list to the approver's tenant using the same generated policies as the rest of the schema
Durable execution (Inngest, Vercel Workflow, LangGraph checkpointer)The framework's own durable step or state holds the pending request; resolve is the event that resumes itThe token check still runs in the adapter on resume (agent frameworks)
PermDock Cloudcloud().approvalsThe hosted implementation of the same interface (Cloud adapter)

Whatever the store, permdock doctor warns when memoryApprovalStore() is the configured store in a serverless or edge target, because a cold start would lose pending approvals (installation, runtimes).

Delivery

A store holds the pending request; something still has to tell a human. Delivery is a recipe over the approval event that fires when a request is created (permdock.on('approval'), or the same event arriving at a DecisionSink), never a package, and the approver's identity always comes from the surface that authenticated them, never from the message (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; subjectFromChatUser maps that id to a Subject from your own directory (a Slack user id to an employee record). store.resolve then applies the actor-is-not-approver rule and any requireDistinctApprover policy, and the resumed call recomputes the token, so a forged or replayed card cannot approve a different call.
  • Durability. requestApproval is a Workflow SDK step; the pending wait outlives the function invocation. The store record is still the source of truth: if the workflow is lost, the request expires at expiresAt and the agent's resume fails closed.
  • Approvers. approvers on the card limits who can click; PermDock's check runs anyway, because the card is a convenience and the store is the control.
  • Not delivery of the decision. Denials are not sent anywhere by this recipe; they are decision events for a sink (audit and observability).

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

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

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.

Open questions

  • Whether list needs 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.

On this page