0013: Three-outcome decision
Why a Decision is granted, denied or approval-required, why there is no not-applicable outcome, and how approval grants and tokens work.
Status
Accepted, September 2026.
Context
Agent runtimes added a third answer to "may this happen?". The Vercel AI SDK 7 toolApproval callback returns approved, denied, user-approval or not-applicable; WorkflowAgent suspends a durable workflow on needsApproval; the Claude Agent SDK's canUseTool can ask the user; MCP 2026-07-28 has stateless elicitation. OWASP ASI02 asks for human-in-the-loop gates on risky tools.
Two lessons from the reference adapter shaped the design. @ai-sdk/policy-opa fails open on unrecognised decisions (vercel/ai#19978): an outcome the adapter does not understand becomes "approved". And experimental_toolApprovalSecret exists because an approval granted for one set of arguments can otherwise be replayed against another.
A boolean cannot represent "allowed, but only after a human confirms", and an authorization library must never answer "I don't know".
Decision
Decision.outcome is exactly one of:
granted: a grant matched and no deny applied. Carriessubject(narrowed),matchedandtoken.denied: no grant matched, a deny applied, validation failed, the delegation did not cover the scope, or the reference is unknown at runtime. Carriesdenials(role and reason per failure) andalternatives.approval-required: a grant withapproval: 'human'matched and nothing denied. Carriesgrant,reasonandtoken.
There is no not-applicable. Anything PermDock cannot evaluate is denied with a reason. Adapters map outcomes to the runtime vocabulary and never emit a fourth value: AI SDK approved / denied / user-approval; WorkflowAgent needsApproval true only for approval-required; MCP elicitation for approval-required, isError refusal for denied; HTTP 403 application/problem+json with a .../approval-required type.
approval: 'human' is a grant option, not a condition function: allow(permissions.post.delete, { where: { authorId: subject.id }, approval: 'human' }). token is a hash of permission key, resource id, subject and actor; adapters re-check the token when an approval is resumed, so a replay cannot be swapped onto different arguments.
Consequences
- One
Decisiondrives the UI, HTTP, MCP, AI SDK and Claude Agent SDK surfaces; each adapter is a small translation table. - Fail-closed is structural: an unhandled outcome in a
switchis anevertype error, and there is no value that a downstream consumer could misread as approval. assertthrowsPermDockApprovalRequiredErrorseparately fromPermDockDeniedError, so an HTTP handler can render a consent step instead of a refusal.approval-requiredin a snapshot letsusePermissionrender a "requires confirmation" state instead of hiding the button.- How
approval-requiredshould surface over plain HTTP beyond the 403 problem type (retry with a token, a separate consent endpoint) is an open question on the roadmap. - The approval decision itself (who approves, where it is recorded) is outside PermDock; the library provides the outcome and the replay-safe token.
Alternatives considered
- Boolean plus a
pendingflag. Rejected; three states in two fields invite the fail-open misreadspolicy-opademonstrated. - Approval as a condition closure. Rejected: it would make approval non-portable and invisible to snapshots and catalogs.
not-applicablefor unknown references or missing rules (AI SDK vocabulary). Rejected: it delegates the fail-closed decision to the caller.- Approval outcome carrying no token. Rejected after reading the
experimental_toolApprovalSecretrationale.
Related
0012: Two-principal subject
Why a subject is a principal plus an optional actor and delegation, and why a decision is principal grants intersected with delegated authority.
0014: Target OpenAPI 3.2 with 3.1 fallbacks
Why PermDock emits OpenAPI 3.2 security objects and downgrades to 3.1 with x-oai-* extensions when a generator only speaks 3.1.