PermDock
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

ClassThrown byCarriesHTTP
PermDockDeniedErrorassert when the outcome is denied; protect, protectServer and the agent adapters on your behalfthe denied Decision403, type /denied
PermDockApprovalRequiredErrorassert when the outcome is approval-requiredthe approval-required Decision including token403, type /approval-required
PermDockValidationErrorboundary validation when data fails its resource schema, when a schema is async, or when a schema is missing in 'always' modecode, issues, permission, resource, boundary400, 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

SituationdecideassertAdapter behaviour
No grant, deny matched, condition falsedeniedthrows PermDockDeniedError403 Problem Details; 404 /not-found on a row of a disclosure: 'hide' resource; MCP isError refusal; AI SDK denied
No subjectdenied with anonymousthrows PermDockDeniedError401 /unauthenticated with WWW-Authenticate: Bearer
Every failing grant is out of quotadenied with limitthrows PermDockDeniedError429 /rate-limited with Retry-After and the RateLimit fields; 503 /limit-unavailable when the LimitStore failed
Delegation does not cover the permissiondenied with not-delegatedthrows PermDockDeniedError403 with WWW-Authenticate: Bearer error="insufficient_scope"; MCP adds a scopeChallenge so the client can step up
Only subject.assurance conditions faileddenied with insufficient-user-authenticationthrows PermDockDeniedError401 /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 helddenied with not-entitledthrows PermDockDeniedError403 /not-entitled with plans; describe returns kind: 'upgrade'
Token present but unverifiabledenied with anonymous; on('auth') carries invalid-token and a causethrows PermDockDeniedError401 /unauthenticated with WWW-Authenticate: Bearer error="invalid_token"; the cause is audit-only
Grant with approval: 'human' matchedapproval-requiredthrows PermDockApprovalRequiredError403 /approval-required; AI SDK user-approval; MCP input_required or refusal with token
Untrusted data fails its schemadenied with reason validationthrows PermDockValidationError400 /validation with issues
Schema returns a Promise at a boundarythrows PermDockValidationError (async-schema)same500; this is a configuration bug
Closure grant throwsdenied with closure-errorthrows PermDockDeniedError403; 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 changesa later connection.check is denied with no-grant and detail: 'connection-revoked'n/asignal aborts with PermDockRevokedError; the stream or socket closes with its Problem Details
Unknown permission string in findPermissionreturns undefinedn/aYour 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-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).
  • 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

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

On this page