PermDock
Concepts

Validation

PermDock validates resource data against its Standard Schema unless the caller marks it as a row the server loaded, synchronously, with a typed error.

A permission check is only as good as the data it checks. can(permissions.post.update, post) with a post that a client made up is a check against fiction. PermDock attaches each resource's Standard Schema to the resource node and runs it on every row it is handed, unless the caller marks the row trusted: true because the server loaded it itself. This page defines the modes, what counts as a boundary, and why validation is synchronous.

Modes

definePolicy takes a validate option:

ModeBehaviourWhen to use
'boundary' (default)Validate every row unless the call passes trusted: true. HTTP bodies, MCP tool arguments, decision-endpoint requests and agent-supplied objects are validated; a row your server code loaded and marks trusted: true is not.Almost always
'always'Validate every instance passed to can, decide, assert, filter, regardless of origin.Development, test suites, while migrating an untyped codebase
'never'Never run schemas. Types are still enforced at compile time.Hot paths where every row came from your own database and the type is trusted

The two comparison points that shaped the default: @zap-studio/permit validates every can() call, which is wasted work for rows the server just read; Kilpi's decision endpoint accepted z.any() and never validated, so a client could fabricate the object the server decided on (landscape).

What a boundary is

Data has crossed a trust boundary when it was constructed by a party the policy does not trust to describe the resource honestly:

SourceBoundary?Who marks it
Row loaded from your database in a Route HandlerNoYou pass it to decide with trusted: true; without the marker it is validated, which is safe but costs a schema call
JSON body of an HTTP requestYesprotect(permission, load?) in HTTP adapters validates load's result unless protect is called with { trusted: true }
MCP tool argumentsYesprotectServer validates data(args) output against the resource schema before deciding
AI SDK or Claude Agent SDK tool call argumentsYestoolApproval, canUseTool validate the object built from args
Decision endpoint request from the React clientYespermdockHandler and the authzen handler validate the resource object
Persisted or cached snapshot on a deviceYes, but UI-onlyThe client PermDock validates shape before use; it never authorises writes
Objects created inside a closure grantNoClosures run on the server with trusted inputs

Data is untrusted unless the call says otherwise. Mark a row your server loaded with trusted: true to skip the schema call:

permdock.decide(permissions.post.update, body); // validated
permdock.decide(permissions.post.update, row, { trusted: true }); // a row you loaded

A row-less check, such as can(permissions.post.read), has nothing to validate and is not validated in 'boundary' mode.

Validation happens before evaluation. Its output (the schema's parsed value, with defaults and transforms applied) is what conditions read, so the type the grant was written against is the type that runs.

Example: HTTP body

// permdock/hono
app.patch(
  "/posts/:id",
  protect(permissions.post.update, async (c) => {
    const current = await loadPost(c.req.param("id")); // trusted: from your database
    const next = { ...current, ...(await c.req.json()) }; // untrusted: merged from the body
    return { current, next };
  }),
  handler,
);

protect knows the body was involved, validates next against Post, and evaluates where against current and check against the validated next. A body that sets authorId to another user fails check and returns 403; a body with authorId: 42 (a number) fails validation and returns 400.

Example: MCP tool arguments

guarded.registerTool(
  "update_post",
  {
    permission: permissions.post.update,
    inputSchema,
    data: (args) => loadPost(args.id),
  },
  handler,
);

inputSchema validates the arguments as the MCP SDK always does. The object returned by data is what PermDock decides on: because it derives from model-supplied args, protectServer treats it as boundary data and validates it against Post too. A model that invents a post object cannot pass a check by shaping it well; the server loaded the row.

filter

filter(permission, rows) validates each row like any other check. Pass { trusted: true } when the rows came from your own query and you want to skip the schema calls.

Choosing a mode per environment

EnvironmentRecommendedWhy
Production server'boundary'Validates exactly the inputs that can lie
Tests and CI'always'Catches fixtures that drift from the schema
Local development'always'Surfaces mismatches between database rows and schema early
Edge or high-throughput read path with trusted rows only'never'Saves the validator call; types still hold
Client (snapshot-backed PermDock)fixed to shape checksThe client never authorises writes; validation there is for UX consistency only

The mode is a policy option, so set it from an environment variable in definePolicy if it differs per environment.

Synchronous schemas

Boundary validation is synchronous. can and decide never await, snapshots are evaluated in render, and Expo Router guards need an answer on the first frame. Standard Schema allows validate to return a Promise, so PermDock checks the result: if a schema returns a thenable, PermDock throws PermDockValidationError with code: 'async-schema' and a message naming the resource, instead of denying silently the way permit's sync-only rule comparison does.

Async refinements belong in your API's input validation, before PermDock sees the object. Resource schemas describe shape and identity; they should not fetch.

// good: shape only
const Post = z.object({
  id: z.string(),
  authorId: z.string(),
  published: z.boolean(),
});

// throws PermDockValidationError { code: 'async-schema' } at the first boundary check
const Post = z
  .object({ id: z.string() })
  .refine(async (p) => await exists(p.id));

PermDockValidationError

class PermDockValidationError extends Error {
  readonly name: "PermDockValidationError";
  readonly code: "invalid-data" | "async-schema" | "no-schema";
  readonly permission: string; // 'post.update'
  readonly resource: string; // 'post'
  readonly issues: StandardSchemaV1.Issue[];
  readonly boundary: string; // 'http-body' | 'mcp-args' | 'decision-endpoint' | 'tool-args' | 'manual'
}
  • invalid-data carries the schema's issues (path, message) in the Standard Schema issue format, unchanged, so you can hand them to whatever renders your validator's errors.
  • async-schema is a configuration error and should fail loudly in development. PermDock settles the returned Promise itself, so a validator that rejects (a row whose getter throws, for instance) never surfaces as an unhandled rejection.
  • no-schema is thrown when validate: 'always' meets a schema-less resource with instance actions; schema-less resources are allowed only for collection actions or with 'boundary' and trusted data.

Adapters catch it and respond in their own vocabulary: HTTP adapters return 400 application/problem+json with type ending in /validation and the issues; MCP returns isError: true with the issues as structuredContent; the AI SDK adapter returns denied with a model-readable list of invalid fields so the model can fix its call. decide itself does not throw on invalid data: it returns denied with reason validation and attaches the error under denials[0].detail. assert throws. See errors.

Types are still enforced

Validation modes only change runtime behaviour. At compile time:

  • can(permissions.post.update, data) requires data to be assignable to the schema output type.
  • can(permissions.post.create, data) is an error: collection actions take no instance.
  • Conditions reference only fields that exist on the schema output.

'never' is therefore not "untyped"; it is "trust the type".

permdock doctor warns when a policy uses 'never' together with an HTTP, MCP or agent adapter, because those adapters exist precisely to receive untrusted data.

Why untrusted is the default

If data were trusted unless marked trusted: false, every adapter and every caller would have to remember the marker, and one forgotten marker lets a model-built or PEP-built object reach conditions unvalidated. An unvalidated value of the wrong type, such as amount: '50000' against amount: { gt: 1000 }, makes a deny condition silently stop matching, which breaks deny-overrides-allow. With untrusted as the default, a forgotten marker costs a schema call on a row that was already valid instead of skipping a check. The cost is one ~standard.validate per row for callers that do not mark their own rows trusted: true.

When an AuthZEN resource loader throws, the handler answers decision: false with no-grant and detail: 'resource-unavailable'. It no longer evaluates the request's own { id } stub, which would have let a deny-guarded row pass while the loader was failing.

Standard JSON Schema

Because resources are Standard Schema values, PermDock can also ask them for JSON Schema through the Standard JSON Schema interface where the validator supports it. That feeds:

  • the catalog: permissions.catalog.json includes a JSON Schema per resource next to each permission;
  • OpenAPI emission: resource schemas become components, x-permdock-permissions references them;
  • MCP tool inputSchema validation when a tool's arguments are the resource itself.

No validator-specific converter is involved. See Standard Schema.

Performance

Boundary validation runs once per untrusted object, not once per check. Mark rows your server loaded trusted: true on hot paths to skip it. In 'always' mode the parsed value is cached per object identity for the lifetime of the PermDock, so filter over a thousand rows validates each row once. Schema cost is the validator's; PermDock adds a single ~standard.validate call.

The client instance built from a snapshot never runs schemas: its answers are UI hints, and any object it sends to the decision endpoint is validated there. Async schemas are caught at runtime (async-schema), not at the type level, because Standard Schema types every validate as Result | Promise<Result>.

Last updated on

On this page