Errors
PermDock throws three evaluation error classes and ends long-lived connections with a fourth, each carrying the Decision or issues that caused it, and adapters map them to Problem Details and model-readable refusals.
PermDock's evaluation path never throws: can, decide, filter, where, simulate and snapshot return values for every input, including anonymous subjects, unknown roles and invalid data. Throwing is reserved for assert and for configuration mistakes, and it uses exactly three classes; a fourth, PermDockRevokedError, is never thrown by a check but is the reason of an aborted connection signal. Each carries the structured object that explains it, so an adapter, a test or a log never has to parse a message.
The three classes
| Class | Thrown by | Carries | HTTP |
|---|---|---|---|
PermDockDeniedError | assert when the outcome is denied; protect, protectServer and the agent adapters on your behalf | the denied Decision | 403, type /denied |
PermDockApprovalRequiredError | assert when the outcome is approval-required | the approval-required Decision including token | 403, type /approval-required |
PermDockValidationError | boundary validation when data fails its resource schema, when a schema is async, or when a schema is missing in 'always' mode | code, issues, permission, resource, boundary | 400, type /validation |
| PermDockRevokedError | never thrown by a check; the reason of a Connection signal when a stream or socket must end, and what tRPC and oRPC subscriptions end with | code (session-revoked, expired, denied, subject-changed), permission, the re-denying decision for denied | 403 /denied for denied, otherwise 401 /unauthenticated with detail set to the code |
All four extend Error, set name to the class name, and are exported from permdock. They are ordinary subclasses with no prototype tricks, so instanceof works across the package's own entry points; across bundle copies, check error.name.
HTTP adapters add one kernel rejection that is not an evaluation class: InvalidSignatureError from permdock/server when webBotAuth is set and a claimed RFC 9421 signature fails. protect returns the Problem Details response (type .../invalid-signature); it is never downgraded to an anonymous actor.
PermDockDeniedError
class PermDockDeniedError extends Error {
readonly name: "PermDockDeniedError";
readonly decision: Extract<Decision, { outcome: "denied" }>;
readonly permission: string; // 'post.delete'
readonly scope: string; // 'post:delete'
readonly resource: { type: string; id?: string };
readonly subject: Subject; // principal may be null
readonly disclosure?: "hide" | "reveal"; // the resource's, when assert was given a row
readonly digest: string; // 'PERMDOCK_DENIED;post.delete'
toProblemDetails(options?: {
instance?: string;
disclosure?: "hide" | "reveal";
}): ProblemDetails;
}try {
permdock.assert(permissions.post.delete, post);
} catch (error) {
if (error instanceof PermDockDeniedError) {
error.decision.denials; // [{ role: 'member', reason: 'condition' }]
error.decision.alternatives; // [permissions.post.read, permissions.post.update]
error.message; // 'post.delete denied for subject u_1: member (condition). Alternatives: post.read, post.update.'
}
}message is deterministic and model-readable (below). assert throws it only after the layered unauthorized handlers have run; if one of them threw (a Next.js redirect(), for example) that error propagates instead. See decisions.
PermDockApprovalRequiredError
class PermDockApprovalRequiredError extends Error {
readonly name: "PermDockApprovalRequiredError";
readonly decision: Extract<Decision, { outcome: "approval-required" }>;
readonly permission: string;
readonly scope: string;
readonly resource: { type: string; id?: string };
readonly token: string; // bind the approval reply to these arguments
readonly reason: string; // 'human'
readonly digest: string; // 'PERMDOCK_APPROVAL_REQUIRED;post.delete;pd1.…'
toProblemDetails(options?: { instance?: string }): ProblemDetails;
}It is a distinct class rather than a flag on the denied error because callers handle it differently: a denial ends the request, an approval requirement starts a workflow. The AI SDK adapter turns it into user-approval, MCP into an input_required URL request or a refusal carrying the token, WorkflowAgent into a durable suspend, and HTTP into a 403 with the token. See approvals.
The digest
Both classes set digest, the property React keeps when an error crosses from a Server Component to the client, where production builds replace the message with a generic one. The format is stable: PERMDOCK_DENIED;<permission> and PERMDOCK_APPROVAL_REQUIRED;<permission>;<token>. parsePermDockDigest(error.digest) from permdock returns { outcome, permission, token? } or null for any other error, so a client error boundary or an error.tsx can tell a refusal from a crash. The digest names the permission key, which the catalog already publishes, and the approval token, which is bound to the same subject and actor and approves nothing by itself; it never carries denials, alternatives, the resource or the subject. Next.js still logs the error on the server. PermissionBoundary from permdock/next/client is built on it.
PermDockValidationError
class PermDockValidationError extends Error {
readonly name: "PermDockValidationError";
readonly code: "invalid-data" | "async-schema" | "no-schema";
readonly permission: string;
readonly resource: string;
readonly issues: StandardSchemaV1.Issue[]; // empty for 'async-schema' and 'no-schema'
readonly boundary: string; // 'http-body' | 'mcp-args' | 'tool-args' | 'decision-endpoint' | 'manual'
toProblemDetails(options?: { instance?: string }): ProblemDetails;
}issues is the Standard Schema issue array unchanged, so it renders with whatever you already use for your validator's errors. code: 'async-schema' and 'no-schema' are configuration errors and should be treated as bugs, not user input problems. Details on modes and boundaries: validation.
PermDockRevokedError
class PermDockRevokedError extends Error {
readonly name: "PermDockRevokedError";
readonly code: "session-revoked" | "expired" | "denied" | "subject-changed";
readonly permission?: string; // the permission the connection was opened for
readonly decision?: Exclude<Decision, { outcome: "granted" }>; // set for 'denied'
toProblemDetails(options?: { instance?: string }): ProblemDetails;
}Adapters send toProblemDetails() in the transport's own close frame: an SSE event: permdock, a WebSocket close 1008 with the type as reason, Nest's permdock:error. problemFromError maps it like the other classes. See streams and sockets.
When each is thrown
| Situation | decide | assert | Adapter behaviour |
|---|---|---|---|
| No grant, deny matched, condition false | denied | throws PermDockDeniedError | 403 Problem Details; 404 /not-found on a row of a disclosure: 'hide' resource; MCP isError refusal; AI SDK denied |
| No subject | denied with anonymous | throws PermDockDeniedError | 401 /unauthenticated with WWW-Authenticate: Bearer |
| Every failing grant is out of quota | denied with limit | throws PermDockDeniedError | 429 /rate-limited with Retry-After and the RateLimit fields; 503 /limit-unavailable when the LimitStore failed |
| Delegation does not cover the permission | denied with not-delegated | throws PermDockDeniedError | 403 with WWW-Authenticate: Bearer error="insufficient_scope"; MCP adds a scopeChallenge so the client can step up |
Only subject.assurance conditions failed | denied with insufficient-user-authentication | throws PermDockDeniedError | 401 /step-up-required with WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values=… (RFC 9470); agent surfaces carry the required acr |
Only a plan grantee failed, on grants whose roles are held | denied with not-entitled | throws PermDockDeniedError | 403 /not-entitled with plans; describe returns kind: 'upgrade' |
| Token present but unverifiable | denied with anonymous; on('auth') carries invalid-token and a cause | throws PermDockDeniedError | 401 /unauthenticated with WWW-Authenticate: Bearer error="invalid_token"; the cause is audit-only |
Grant with approval: 'human' matched | approval-required | throws PermDockApprovalRequiredError | 403 /approval-required; AI SDK user-approval; MCP input_required or refusal with token |
| Untrusted data fails its schema | denied with reason validation | throws PermDockValidationError | 400 /validation with issues |
| Schema returns a Promise at a boundary | throws PermDockValidationError (async-schema) | same | 500; this is a configuration bug |
| Closure grant throws | denied with closure-error | throws PermDockDeniedError | 403; the closure's error is attached as cause on the denial |
| An open connection's session is revoked, its token expires, its opening permission is re-denied or its principal changes | a later connection.check is denied with no-grant and detail: 'connection-revoked' | n/a | signal aborts with PermDockRevokedError; the stream or socket closes with its Problem Details |
Unknown permission string in findPermission | returns undefined | n/a | Your code decides; typed references cannot be unknown |
The only case where decide throws is a misconfigured schema. Everything a request can cause is a Decision.
How adapters map them
PermDockDeniedError → the decision's status: 401, 404, 429, 503 or 403 type .../denied and siblings
PermDockApprovalRequiredError → 403 application/problem+json type .../approval-required
PermDockValidationError → 400 application/problem+json type .../validation
PermDockRevokedError → 401 type .../unauthenticated, or 403 type .../denied when re-deniedtoProblemDetails() on the denied and approval classes uses the same status matrix as protect, so a thrown assert and a protect denial of the same decision answer with the same status, type and WWW-Authenticate challenge.
- HTTP adapters (
permdock/honoand siblings) callproblemFromError(error, { credentials })from their error hook and setContent-Type: application/problem+json. Nothing else is needed in your handlers. Over plain HTTP theapproval-requiredbody is the whole surface: the caller orchestrates the wait and retries with thePermDock-Approvalheader (approvals adapter). permdock/next:assertinside a Server Component or Action runs youronDeniedhandler first (typicallyredirect()ornotFound()); in a Route Handler the error becomes Problem Details.permdock/mcp: denied and validation errors become a tool result withisError: true, themessageas text content and the Decision fields asstructuredContent; a missing scope also produces thescopeChallengestep-up; approval becomes aninput_requiredURL request whenapproval.atis set and the client takes URL elicitations, otherwise a refusal carrying the token.permdock/ai-sdk: never throws into the model loop;toolApprovalreturnsdeniedwith the message as the reason, oruser-approval.permdock/claude-agent:canUseToolreturns a deny result with the message; approval returns an ask.permdock/authzen: never throws to the client;decision: falsewith the Decision incontext.permdock/trpc,permdock/orpc: mapped to the framework'sFORBIDDENandBAD_REQUESTerror codes with the Problem Details object ascause; an ended subscription or event iterator ends withUNAUTHORIZEDorFORBIDDEN.
Problem Details shape
All toProblemDetails() results share the RFC 9457 members plus PermDock extensions:
{
"type": "https://permdock.com/problems/denied",
"title": "Permission denied",
"status": 403,
"detail": "post.delete denied for subject u_1: member (condition). Alternatives: post.read, post.update.",
"instance": "/posts/42",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post", "id": "42" },
"denials": [{ "role": "member", "reason": "condition" }],
"alternatives": ["post.read", "post.update"]
}The approval variant adds reason and token; the validation variant replaces denials with issues and uses status 400. The base URI for type is the fixed identifier https://permdock.com/problems. The HTTP status lives only in toProblemDetails(); the error classes carry no status property, so non-HTTP callers map on the class. Full JSON for each is on wire formats; the standard itself is described under Problem Details.
Writing error text for models
The message and detail strings are consumed by LLMs through MCP refusals and AI SDK tool results as often as by humans. They follow a fixed template so a model learns it once:
<permission> denied for subject <id>: <role> (<reason>)[, <role> (<reason>)]. Alternatives: <key>, <key>.
<permission> requires human approval (<reason>). Token: <token>.
<permission>: invalid <resource> data at <path>: <message>[; ...].Guidance the adapters and your own handlers should follow:
- Lead with the permission key. It is the one identifier the model already has from the tool description.
- Name the reason with the stable reason code, then a short clause. Do not paraphrase the policy.
- Always include alternatives when they exist; a model with alternatives self-corrects, a model without them retries.
- Never include the policy, closure source, other subjects' data or stack traces.
- Keep it to one line per outcome. Multi-paragraph refusals get truncated in tool results.
The same strings appear in on('decision') events, so the audit log and the model see identical text.
Last updated on
Building UI with PermDock
Hidden versus disabled, menus, filtered lists, tenant switchers, role chips, request-access buttons, impersonation banners, view-as previews and role editors, built from the snapshot-backed client instance with usePermission, usePermissions, useFilter, useTenant, useMemberships, useRoles, useAssignableRoles, useAssignablePermissions, useApproval, useSubject, useDescribe and describe(decision), with provider-wide slot defaults; the same names in React, React Native, Vue, Svelte and Solid.
Wire formats
The JSON shapes PermDock reads and writes, permission leaves, conditions, snapshots, memberships and custom roles, AuthZEN messages, the catalog, Decisions and Problem Details, with an example of each.