PermDock
Concepts

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;
    };
OutcomeWhencanassert
grantedAt least one allow matched, no deny matched, delegation covers ittruereturns the decision
deniedA deny matched, nothing matched, anonymous, validation failed, or delegation does not cover itfalsethrows PermDockDeniedError
approval-requiredThe matched allow carries approval ('human' or { by, distinct }) and no approval has been recorded for this tokenfalsethrows 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.

kindSet by
over-limitA mode: 'soft' limit granted past its count (limits)
near-limitUsage reached the limit's alertAt
notify, reviewA break-glass grant's declared follow-ups (elevated access)
justifyA break-glass grant; reason is the justification the caller gave
appThe 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:

ReasonMeaning
no-grantThe role has no grant for this permission
conditionAn allow exists but its where / check did not match, a { subject: { session: { live: true } } } test on a session not checked as live included
denyA deny matched (overrides everything)
inactive-grantAn allow exists but the decision clock is outside its validFrom / validUntil; detail is the window { from, until } in Unix seconds (validity)
closure-errorA closure threw; treated as no match
opaque-conditionAn imported opaque condition cannot be evaluated in memory
server-onlyClient 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
anonymousNo principal
not-delegated, no-delegationDelegation does not cover the permission (subject), including a write by a readOnly actor; role is null
insufficient-user-authenticationThe 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-entitledThe 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-requiredA 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-requiredThe subject holds a supportAccess membership with actorRequired but carries no act; support access is impersonation and must be attributed (elevated access); role is null
limitQuota exhausted
limit-unavailableNo LimitStore, the store threw, or consume / remaining returned a thenable
relation-depthA graph grant's parent chain has a cycle, or goes on past its depth with no holder within it (relationships)
relation-unavailableA 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
validationBoundary validation failed (validation)
unknown-roleA role name on the principal or a membership is not declared
pdp-deniedThe remote PDP answered decision: false
pdp-unavailableThe remote PDP timed out, returned a non-2xx, or createPermDock from core was used for a delegated permission
pdp-invalid-responseThe remote body was not an AuthZEN evaluation response
tenant-mismatch, no-membership, scope, expired-membershipTenancy misses (tenancy)
stale-credentialsThe 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-onlyA role change would break the role's min, max or transferOnly (only from decideRoleChange, ownership)
not-assignable-by, self-demotionThe subject may not make this role change, or it targets themselves (ownership)
not-allowed-for-membership, conflicting-roleThe target's membership kind is not in the role's for, or they hold a role in its exclusiveWith (ownership)
externally-managedThe target's membership is owned by the identity provider (managedBy: 'idp', from SCIM); the application cannot change it (only from decideRoleChange)
approvalA resume token was unknown, pending, rejected, expired, consumed or for another call; detail names which (approvals)
stale-approvalA resume token approved an earlier version of the row under approval: { staleOn: 'resource-change' }; ask again without it (approval security)
exceeds-creatordecideCredential 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-policydecideCredential 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:

SurfaceMapping
Vercel AI SDK toolApproval'user-approval'; the approval reply is re-checked against token on resume
Vercel AI SDK WorkflowAgentneedsApproval(permission) suspends the durable workflow
Claude Agent SDKcanUseTool returns an ask result; permissionRequestHook carries the reason
MCPElicitation (stateless multi-round-trip request in the 2026-07-28 spec)
HTTP403 application/problem+json with type ending in /approval-required and the token in the body
ReactusePermission 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 on

assert 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:

  1. The per-call handler: permdock.assert(permission, data, { onDenied: (d) => redirect('/login') }).
  2. Instance hooks registered with permdock.on('denied', handler). on('decision') is the audit event and is not an unauthorized handler.
  3. 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.

FieldMeaning
evaluatedHow 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
allowsEvery 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
deniesEvery 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
skippedGrants 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 order

simulate 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

PermDockAI SDK toolApprovalMCP tool resultHTTPAuthZEN
grantedapprovedtool runs2xxdecision: true
denieddenied with reason and alternativesisError: true, refusal text plus structuredContent with denials and alternatives403 Problem Details /denieddecision: false, denials in context
approval-requireduser-approvalinput_required URL request, or a refusal with token403 Problem Details /approval-requireddecision: false, context.permdock.outcome: 'approval-required'

Each adapter page documents its exact translation; the wire formats page has the JSON.

Immutability and performance

  • A Decision is 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.
  • decide never throws and never awaits: the policy is data, context was loaded at createPermDock, and a thenable from a closure is a closure-error denial reported to on('error').
  • can is decide(...).outcome === 'granted' with alternatives skipped.

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 is denied. Reasons are a closed list so adapters, Problem Details and agent refusals can map them without parsing text.
  • One app obligation kind. Applications needed to steer on a grant (watermark this export, re-prompt for MFA) without PermDock learning what that means. A new kind per need would break every exhaustive switch over Obligation in user code. One namespaced variant, { kind: 'app', name, detail? }, keeps the list closed and lets the application own the names.
  • explain is a method, with the trace off by default. decide was first documented as enough on its own: denials names every consulted role and describe(decision) turns it into prose, so explain was 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 every Decision would cost every can an allocation and would push grant internals into the decision log, so the trace is opt-in (explain: true, or explain), computed in process, and never emitted. The name follows what every other engine calls it.

Last updated on

On this page