0022: Approvals are pluggable
Pending approvals live in an ApprovalStore interface with an in-memory default; the ApprovalRequest wire shape, approver identity, expiry, the PermDock-Approval resume header and plan-level approvals are fixed here so every agent adapter, self-hosted app and PermDock Cloud share one model.
Status
Accepted, September 2026. Resolves the ApprovalStore, expiry and bulk-approval open questions on approvals and roadmap open question 10 (approval-required over plain HTTP).
Context
0013 made approval-required a first-class outcome and approvals defined the replay-safe token: a hash of permission key, resource id, subject and actor, recomputed on resume and compared. What the docs left open was everything between the ask and the answer: where a pending approval is kept, who may answer it, how long it stays valid, how a plain HTTP client resumes, and whether a simulate() plan can be approved once.
Every agent runtime pauses differently but expects the same thing from the application. The AI SDK returns user-approval and later an approval response; Eve's approval hook pauses a durable session and its docs say a four-eyes flow needs an application-owned approval request resolved by an eligible approver; the OpenAI Agents SDK returns interruptions with a serialisable RunState and resumes with state.approve() or state.reject(); MCP uses elicitation; the Claude Agent SDK asks through canUseTool. In all five the application must store something, notify someone and correlate the answer to the call.
0021 requires that this be doable without PermDock Cloud. An interface with an in-memory default satisfies that; the Cloud is one more implementation with a UI.
Decision
ApprovalStoreis the interface;memoryApprovalStore()is the default. Both live inpermdock/approvals. Every adapter that can surfaceapproval-requiredacceptsstorein itscreatePermDockoptions and uses the in-memory store when none is given. The interface has five methods:create(request),get(token),resolve(token, verdict),list(filter),expire(now). Implementations may be async; core never awaits one on thedecidepath (the store is consulted by adapters on resume, not by the evaluator).ApprovalRequestis a versioned wire format. Fields:version,token,permission(key),scope,resource(type,id),subject(principal id and roles, actor id and kind, delegation summary),detail(the model-readable sentence from the Decision),adapter,createdAt,expiresAt,status(pending,approved,rejected,expired), and on resolutionresolvedAt,resolvedBy(approver principal id) andnote. It never carries the resource object, the policy, tokens fromauthInfoor secrets. It is recorded in wire formats and fixtures in@permdock/testing.- Expiry lives on the request, not in the token.
Decision.tokenstays deterministic so a resumed call can recompute it.expiresAton theApprovalRequest(default one hour, configurable per adapter) bounds how long an approval may be spent;expire()marks stale requests and a resume against an expired request isdeniedwith reason kindapproval. - The approver is recorded and is never the actor.
resolve()requires an approver subject produced by the application's authentication (a session, asubjectFrom*result). An approval whose approver id equals the request'sactor.idis refused: an agent cannot approve its own call. Whether the approver must also differ from the principal (true four-eyes) is an application option,requireDistinctApprover, off by default. - Plain HTTP resumes with the
PermDock-Approvalrequest header. A403with theapproval-requiredproblem type returnstokenin the body. The client retries the same request withPermDock-Approval: <token>. The server kernel reads the header, looks the token up in the store, requires statusapproved, re-runsdecide, recomputes the token and compares. A missing, unknown, pending, rejected, expired or mismatched token isdenied. There is no consent endpoint in core;approvalsHandlerinpermdock/approvalsprovides the list, approve and reject routes an application mounts for its approvers. - Plan-level approvals are a list of per-call tokens.
simulate()over a plan produces oneDecisionper step; the adapter creates oneApprovalRequestperapproval-requiredstep and returns the tokens together. Approving the plan approves each token; each step is still re-checked at execution time. There is no separate plan token, so a step whose inputs changed between plan and execution fails its own comparison. - Approval events are audit events.
createandresolveeach emit anapprovalevent on the instance'son('decision')stream (sourceapproval), so aDecisionSinksees the ask, the answer and the resumed decision as three correlated records sharingtoken.
Consequences
security/approvals.mdxgains the store lifecycle and the HTTP resume header; its open questions on storage, expiry and bulk approval close.- Every agent adapter page (
ai-sdk,eve,openai,claude-agent,mcp) documents thestoreoption and which runtime state it correlates with the token (AI SDK approval response, Eve session, OpenAIRunState, MCP elicitation reply, ClaudepermissionRequestHook). permdock/terminalresolvesapproval-requiredinteractively by writing to and reading from the same store, so a CLI approval leaves the same audit record as a web one.- PermDock Cloud implements
ApprovalStoreover its API and adds an inbox UI, delivery (Slack, email, mobile) and approver management; none of that changes the interface. - Applications that need approvals to survive a process restart implement the interface over their database; the approvals adapter page carries a Drizzle recipe of about thirty lines.
- The threat model gains rows for store tampering (a store that flips
pendingtoapprovedis a trusted server component, like the database) and approver spoofing (the approver subject must come from authentication, never from the resume request).
Alternatives considered
- Encode expiry and the approver in the token itself (a signed JWT-like approval). Rejected: it breaks token determinism, which is what lets a resumed call recompute and compare without a lookup, and it moves state into something the model can see and replay within the window. A deterministic token plus a store record keeps the proof of "this call" separate from the record of "who said yes when".
- Leave storage entirely to each runtime (AI SDK approval payloads, Eve sessions, OpenAI
RunState). Rejected: the runtimes store their state, not the approval record an auditor needs, and none of them knows about approvers. The store is small and gives every runtime the same audit shape. - Ship a persistent store in the open-source package. Deferred per 0021: interfaces and a recipe first, stores when the Cloud's implementation has stabilised the interface.
- A consent endpoint instead of a header for HTTP resume. Rejected as the core mechanism: a header on the retried request keeps the original route as the single enforcement point and needs no new URL surface; a consent UI is an application concern served by
approvalsHandleror by the Cloud inbox. - A plan token covering a whole
simulate()result. Rejected: it would authorise steps whose inputs changed after planning. Per-step tokens approved together give the same one-click experience with per-call safety.
Related
0021: Embedded PDP, hosted ADS and control plane
PermDock is a policy decision point you embed and that never needs a network call to decide; PermDock Cloud is an optional Authorization Decision Service and control plane that implements interfaces anyone can implement, lives in its own repository, and is reached through the thin permdock/cloud entry.
0023: Compose with the OpenAPI toolchain, do not wrap it
Why PermDock reaches spec producers (next-openapi-gen, hono-openapi, @orpc/openapi), SDK generators (Hey API, Orval) and docs UIs (Scalar) through standard security fields and an Overlay rather than per-tool packages, why the same rule applies to every adjacent ecosystem, and what stays out of scope.