# Errors

Source: https://permdock.com/docs/concepts/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 [#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`](/docs/adapters/server-kernel#connections) 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 [#permdockdeniederror]

```ts
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;
}
```

```ts
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](/docs/concepts/decisions).

## PermDockApprovalRequiredError [#permdockapprovalrequirederror]

```ts
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](/docs/security/approvals).

### The digest [#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`](/docs/adapters/next#permissionboundary) from `permdock/next/client` is built on it.

## PermDockValidationError [#permdockvalidationerror]

```ts
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](/docs/concepts/validation).

## PermDockRevokedError [#permdockrevokederror]

```ts
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](/docs/concepts/streams).

## When each is thrown [#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 [#how-adapters-map-them]

```text
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-denied
```

`toProblemDetails()` 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/hono` and siblings) call `problemFromError(error, { credentials })` from their error hook and set `Content-Type: application/problem+json`. Nothing else is needed in your handlers. Over plain HTTP the `approval-required` body is the whole surface: the caller orchestrates the wait and retries with the `PermDock-Approval` header ([approvals adapter](/docs/adapters/approvals)).
* `permdock/next`: `assert` inside a Server Component or Action runs your `onDenied` handler first (typically `redirect()` or `notFound()`); in a Route Handler the error becomes Problem Details.
* `permdock/mcp`: denied and validation errors become a tool result with `isError: true`, the `message` as text content and the Decision fields as `structuredContent`; a missing scope also produces the `scopeChallenge` step-up; approval becomes an `input_required` URL request when `approval.at` is set and the client takes URL elicitations, otherwise a refusal carrying the token.
* `permdock/ai-sdk`: never throws into the model loop; `toolApproval` returns `denied` with the message as the reason, or `user-approval`.
* `permdock/claude-agent`: `canUseTool` returns a deny result with the message; approval returns an ask.
* `permdock/authzen`: never throws to the client; `decision: false` with the Decision in `context`.
* `permdock/trpc`, `permdock/orpc`: mapped to the framework's `FORBIDDEN` and `BAD_REQUEST` error codes with the Problem Details object as `cause`; an ended subscription or event iterator ends with `UNAUTHORIZED` or `FORBIDDEN`.

## Problem Details shape [#problem-details-shape]

All `toProblemDetails()` results share the RFC 9457 members plus PermDock extensions:

```json
{
  "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](/docs/concepts/wire-formats); the standard itself is described under [Problem Details](/docs/standards/problem-details).

## Writing error text for models [#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:

```text
<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.
