Decisions
decide returns a discriminated Decision with three outcomes, matched grants, denials, alternatives and a replay-safe token; assert and simulate build on it.
can returns a boolean. Everything else in PermDock is built on decide, which returns a Decision: a discriminated union that says whether the check passed, why, what the caller could do instead, and a token that binds any follow-up approval to these exact arguments. Adapters translate the same object into HTTP Problem Details, MCP refusals, AI SDK approval states and audit events.
The three outcomes
type Decision =
| {
outcome: "granted";
subject: Subject;
matched: Grant;
token: string;
quota?: Quota;
obligations?: Obligation[];
}
| {
outcome: "denied";
permission?: string;
denials: Array<{ role: string | null; reason: string; detail?: unknown }>;
alternatives: Permission[];
}
| {
outcome: "approval-required";
grant: Grant;
reason: string;
token: string;
};| Outcome | When | can | assert |
|---|---|---|---|
granted | At least one allow matched, no deny matched, delegation covers it | true | returns the decision |
denied | A deny matched, nothing matched, anonymous, validation failed, or delegation does not cover it | false | throws PermDockDeniedError |
approval-required | The matched allow carries approval ('human' or { by, distinct }) and no approval has been recorded for this token | false | throws PermDockApprovalRequiredError |
There is no fourth outcome. not-applicable, which the Vercel AI SDK uses when no policy covers a tool, does not exist in PermDock: a permission with no grant is denied. That is the fail-closed default, and the reason @ai-sdk/policy-opa's fail-open behaviour on unrecognised decisions (vercel/ai#19978) cannot happen through permdock/ai-sdk.
granted
const decision = permdock.decide(permissions.post.update, post);
if (decision.outcome === "granted") {
decision.subject.principal.id; // narrowed: principal is non-null here
decision.matched.role; // 'member'
decision.matched.permission; // 'post.update'
decision.token; // opaque string, see below
}matched is the grant that produced the outcome: the role name, the permission key, the normalised condition, and the grant's name and meta when it declares them. It is JSON, so it can be logged as the justification for the action.
When the matched allow has a limit, the decision also carries quota: { remaining, resetsAt }. An exhausted limit denies with reason limit and detail: { count, window, resetsAt } (LimitDetail), which HTTP adapters turn into a 429 with Retry-After and the RateLimit fields (Problem Details).
Obligations
obligations lists what the caller owes alongside a granted action. An obligation never turns granted into another outcome; the caller acts on it and proceeds.
kind | Set by |
|---|---|
over-limit | A mode: 'soft' limit granted past its count (limits) |
near-limit | Usage reached the limit's alertAt |
notify, review | A break-glass grant's declared follow-ups (elevated access) |
justify | A break-glass grant; reason is the justification the caller gave |
app | The application, through allow(p, { obligations }); PermDock carries name and detail and never acts |
allow(permissions.report.export, {
obligations: ["watermark", { name: "mfa-reprompt", detail: { maxAge: 300 } }],
});
const decision = permdock.decide(permissions.report.export, report);
if (decision.outcome === "granted") {
for (const obligation of decision.obligations ?? []) {
if (obligation.kind === "app" && obligation.name === "watermark") {
addWatermark(file);
}
}
}An app obligation's name is lower case letters, digits, _ and -, starting with a letter, unique on the grant; its detail is plain JSON. definePolicy throws for obligations on a deny, because a deny has no granted action to follow up. A snapshot carries them on its grants, so a client decision owes the same obligations as the server's. Decision events, the OCSF projection and the OTel span attributes carry the obligation names (audit and observability).
denied
{
outcome: 'denied',
denials: [
{ role: 'member', reason: 'condition' }, // the member allow did not match
{ role: 'admin', reason: 'deny' }, // an admin deny matched
],
alternatives: [permissions.post.read, permissions.post.update],
}denials has one entry per role that was consulted. Reasons are short stable strings:
| Reason | Meaning |
|---|---|
no-grant | The role has no grant for this permission |
condition | An allow exists but its where / check did not match, a { subject: { session: { live: true } } } test on a session not checked as live included |
deny | A deny matched (overrides everything) |
inactive-grant | An allow exists but the decision clock is outside its validFrom / validUntil; detail is the window { from, until } in Unix seconds (validity) |
closure-error | A closure threw; treated as no match |
opaque-condition | An imported opaque condition cannot be evaluated in memory |
server-only | Client only: the snapshot cannot answer (a closure, graph relation or period grant, or a permission outside include) and the provider has no endpoint to ask, because none was given or it is endpoint: false (Next.js Cache Components); role is null |
anonymous | No principal |
not-delegated, no-delegation | Delegation does not cover the permission (subject), including a write by a readOnly actor; role is null |
insufficient-user-authentication | The only conditions that failed read subject.assurance (acr, amr, authTime); HTTP adapters render it as the RFC 9470 step-up challenge; also a break-glass or activation assurance that was not met (elevated access) |
not-entitled | The grant's roles are held but a plan grantee in its to is not: neither principal.plans nor a seat on the active tenant's membership names it. to carries the grantee so the caller can name the plan; HTTP adapters answer 403 /not-entitled with plans when every denial is not-entitled, and describe returns kind: 'upgrade'. A plan grant whose roles are not held adds no denial, so the reason never offers an upgrade that would not grant |
purpose, reason-required | A break-glass grant was engaged (context.purpose) but the asserted purpose was not one it lists, or it requires a reason and context.reason was missing (elevated access); role is null |
actor-required | The subject holds a supportAccess membership with actorRequired but carries no act; support access is impersonation and must be attributed (elevated access); role is null |
limit | Quota exhausted |
limit-unavailable | No LimitStore, the store threw, or consume / remaining returned a thenable |
relation-depth | A graph grant's parent chain has a cycle, or goes on past its depth with no holder within it (relationships) |
relation-unavailable | A graph grant could not read the RelationSource: none configured, a throw or rejection, a malformed answer, or a Promise loadRelations did not load. On a deny it denies the decision |
validation | Boundary validation failed (validation) |
unknown-role | A role name on the principal or a membership is not declared |
pdp-denied | The remote PDP answered decision: false |
pdp-unavailable | The remote PDP timed out, returned a non-2xx, or createPermDock from core was used for a delegated permission |
pdp-invalid-response | The remote body was not an AuthZEN evaluation response |
tenant-mismatch, no-membership, scope, expired-membership | Tenancy misses (tenancy) |
stale-credentials | The permission is in the policy's fresh list and the subject's memberships come from a token behind the MembershipSource authorization version, or either version is missing (Supabase token hook); role is null |
last-holder, max-holders, transfer-only | A role change would break the role's min, max or transferOnly (only from decideRoleChange, ownership) |
not-assignable-by, self-demotion | The subject may not make this role change, or it targets themselves (ownership) |
not-allowed-for-membership, conflicting-role | The target's membership kind is not in the role's for, or they hold a role in its exclusiveWith (ownership) |
externally-managed | The target's membership is owned by the identity provider (managedBy: 'idp', from SCIM); the application cannot change it (only from decideRoleChange) |
approval | A resume token was unknown, pending, rejected, expired, consumed or for another call; detail names which (approvals) |
stale-approval | A resume token approved an earlier version of the row under approval: { staleOn: 'resource-change' }; ask again without it (approval security) |
exceeds-creator | decideCredential only: the key would hold more than its creator may hand out (a role or permission outside the creator's assignableRoles / assignablePermissions, a tenant the creator is not in, a permission outside the creator's delegation), or the creator is a link or a credential; detail names the role, permission, tenant or creator (API keys) |
credential-policy | decideCredential only: the tenant's credentials settings refuse the key; detail.rule is kind, no-expiry, ttl or unavailable (the settings source threw) |
This fixes two long-standing gaps: CASL's ForbiddenError only knows a reason when an inverted rule matched, and permix had no explain at all (permix #22). PermDock always says which roles were tried and why each did not grant; explain adds which grant decided it.
A deny denial from a grant with a name carries detail: { name }, so PermDockDeniedError.message reads editor (deny 'lock') and an agent refusal names the rule. DenialDetails maps each reason that carries a structured detail to its type, and Denial<'limit'> narrows detail to LimitDetail; any other reason's detail is unknown.
permission is the key that was checked, absent when the check named no declared permission. A fallback can resolve it with findPermission and read the leaf's meta, for example its title or the app's meta.x.
alternatives
alternatives lists permissions on the same resource that the subject does hold for this data (or for the collection). A model that was refused post.delete sees that post.read and post.update are available and can re-plan instead of retrying the same call. alternatives is computed lazily and only for denied; on hot paths use can, which skips it. Each alternative is evaluated the way simulate is: a quota grant is listed when it has remaining and never spends it. The MCP adapter puts alternatives in structuredContent; HTTP adapters put them in Problem Details.
approval-required
{
outcome: 'approval-required',
grant: { role: 'member', permission: 'post.delete', where: { /* ... */ } },
reason: 'human',
token: 'pd1.…',
}The grant matched, so the principal is allowed in principle, but a human must confirm this specific action. The adapter decides how to ask:
| Surface | Mapping |
|---|---|
Vercel AI SDK toolApproval | 'user-approval'; the approval reply is re-checked against token on resume |
Vercel AI SDK WorkflowAgent | needsApproval(permission) suspends the durable workflow |
| Claude Agent SDK | canUseTool returns an ask result; permissionRequestHook carries the reason |
| MCP | Elicitation (stateless multi-round-trip request in the 2026-07-28 spec) |
| HTTP | 403 application/problem+json with type ending in /approval-required and the token in the body |
| React | usePermission returns allowed: false with status: 'ready'; the UI may render an "ask for approval" affordance using decide |
See approvals for the full human-in-the-loop flow.
Options
decide, can and assert take the same options as their last argument.
Prop
Type
token
token is present on granted and approval-required. It is a hash over the permission key, the resource id (or the collection marker), the principal's stable identity, the actor and the condition fingerprint, plus the row's version field when the matched grant sets approval: { staleOn: 'resource-change' }. It has one job: an approval or a granted decision produced for one set of arguments cannot be replayed against another. When an approval reply comes back, the adapter recomputes the token from the actual tool arguments and rejects the reply if it differs. This is the problem the AI SDK's experimental_toolApprovalSecret addresses for its own approvals; PermDock computes it from the decision so every adapter gets it.
The token is not a capability. Possessing it grants nothing; it only proves that a decision with these inputs was issued. Treat the string as opaque; the exact encoding is on wire formats and the binding rules are on approval security.
assert
const { subject } = permdock.assert(permissions.post.delete, post);
// subject.principal is non-null from here onassert returns the granted decision or throws. Before throwing it runs the layered unauthorized handlers, in order, and rethrows the first error any of them produced:
- The per-call handler:
permdock.assert(permission, data, { onDenied: (d) => redirect('/login') }). - Instance hooks registered with
permdock.on('denied', handler).on('decision')is the audit event and is not an unauthorized handler. - The policy default declared in
definePolicy.
All layers run even if an earlier one throws, so audit hooks still fire when a Next.js handler calls redirect(). If nothing throws, assert throws PermDockDeniedError or PermDockApprovalRequiredError itself. The layering is borrowed from Kilpi's .assert(handler?) (landscape); it lets the Next.js adapter redirect and the HTTP adapters emit Problem Details from one place. Error classes are documented under errors.
explain
const explained = permdock.explain(permissions.doc.update, doc);
// {
// outcome: 'denied',
// denials: [{ role: 'editor', reason: 'deny', detail: { name: 'lock' } }],
// alternatives: [...],
// trace: {
// evaluated: 3,
// allows: [{ role: 'editor', permission: 'doc.update' }],
// denies: [{ role: 'editor', permission: 'doc.update', name: 'lock', where: {...} }],
// skipped: [{ role: 'viewer', permission: 'doc.update', effect: 'allow', why: 'role' }],
// },
// }explain is decide with explain: true: the same Decision, plus a trace. denials says why each consulted role did not grant; trace says which grants produced the outcome.
| Field | Meaning |
|---|---|
evaluated | How many candidate grants for the permission were examined before the outcome was reached. A deny returns at once, so grants after it are not counted |
allows | Every allow that matched, as a MatchedGrant, in policy order. On denied with reason deny these are the allows the deny overrode; on granted the first one is matched |
denies | Every deny that matched. denies[0] is the deny that produced a deny outcome, or the deny a break-glass grant lifted when the outcome is granted |
skipped | Grants passed over before their condition ran, each with why: role (a role it names is not held), grantee (its to did not match and added no denial), via-only, purpose, field, or break-glass-inactive |
A grant that ran its condition and did not match is in denials, not in skipped. Give a deny a name (deny(permission, { name: 'lock' })) and denies[0].name says which rule won; MatchedGrant.name carries it on matched and grant too.
The trace is computed in process from the policy and the subject, with no store or network, and costs nothing when explain is off: decide allocates no trace. It never reaches a DecisionSink, Problem Details or an AuthZEN response; a decision log records the outcome and the denials, and explain is the tool you run against the same policy and subject to see how it got there. explain consumes no quota, like simulate; its event has source: 'explain'. A snapshot client (fromSnapshot) explains over the snapshot's grants; a remote PDP decision carries an empty trace, because no local grant was evaluated. In permdock/testing, a matrix cell's deniedBy asserts denies[0].name.
simulate
const results = permdock.simulate([
[permissions.post.update, post],
[permissions.post.delete, post],
[permissions.post.create],
]);
// Decision[] in the same ordersimulate evaluates a batch without side effects: no on('decision') events for the individual checks (one simulate event instead), no approval tokens issued, no quota consumed. simulate(checks, { now }) reads the whole batch as of one Unix second, so grant validity, membership expiry and quota windows answer for that instant; it defaults to the current time. It is the pre-flight an agent runs over its plan before executing, and it is the same shape as an AuthZEN evaluations boxcar request, so the decision endpoint and the pdp provider expose it over HTTP unchanged (AuthZEN).
A third overload reads an Arazzo workflow and the applied OpenAPI description. Each step's operationId resolves to x-permdock-permissions; a hole is denied with reason undocumented or unsupported. The subject is the instance's. permdock arazzo check runs only the resolution half and never decides.
Mapping decisions to other vocabularies
| PermDock | AI SDK toolApproval | MCP tool result | HTTP | AuthZEN |
|---|---|---|---|---|
granted | approved | tool runs | 2xx | decision: true |
denied | denied with reason and alternatives | isError: true, refusal text plus structuredContent with denials and alternatives | 403 Problem Details /denied | decision: false, denials in context |
approval-required | user-approval | input_required URL request, or a refusal with token | 403 Problem Details /approval-required | decision: false, context.permdock.outcome: 'approval-required' |
Each adapter page documents its exact translation; the wire formats page has the JSON.
Immutability and performance
- A
Decisionis a frozen plain object. It can be cached by the client keyed on permission key plus resource id, serialised to the decision endpoint response, or stored with an audit record. decidenever throws and never awaits: the policy is data,contextwas loaded atcreatePermDock, and a thenable from a closure is aclosure-errordenial reported toon('error').canisdecide(...).outcome === 'granted'withalternativesskipped.
Why
- Three outcomes, closed reasons. A fourth outcome (
not-applicable) is how a fail-open bug enters a policy engine; a permission with no grant isdenied. Reasons are a closed list so adapters, Problem Details and agent refusals can map them without parsing text. - One
appobligation kind. Applications needed to steer on a grant (watermark this export, re-prompt for MFA) without PermDock learning what that means. A newkindper need would break every exhaustiveswitchoverObligationin user code. One namespaced variant,{ kind: 'app', name, detail? }, keeps the list closed and lets the application own the names. explainis a method, with the trace off by default.decidewas first documented as enough on its own:denialsnames every consulted role anddescribe(decision)turns it into prose, soexplainwas a reserved word. That left one question unanswered: which deny won, and which allows it overrode, when several roles carry grants for the same permission. Putting that on everyDecisionwould cost everycanan allocation and would push grant internals into the decision log, so the trace is opt-in (explain: true, orexplain), computed in process, and never emitted. The name follows what every other engine calls it.
Last updated on
Authentication and PermDock
PermDock never authenticates: it consumes material something else has already verified, turns it into a subject, and decides. This page defines what counts as verified, which claims may feed grants, and how tokens map to principal, actor and delegation.
Snapshots
A snapshot serialises roles, grants and portable conditions so clients evaluate permissions offline; closures stay server-only and invalidation is explicit.