0009: Validate at the boundary by default
Why resource schemas run only on data that crossed a trust boundary, why the default is boundary rather than always or never, and why validation is synchronous.
Status
Accepted, September 2026.
Context
PermDock resources are defined over Standard Schema validators, so every can(permission, data) call could validate data against the resource schema. @zap-studio/permit does exactly that and pays for it on every check; permix never validates and infers nothing; CASL detects the subject type but never validates the instance.
Which data needs validation depends on where it came from:
- A row loaded by the server from its own database is already typed and trusted; validating it again costs time and adds nothing.
- An HTTP body, an MCP tool argument object, a client
refreshpayload or anything a model produced is untrusted. A condition likewhere: { authorId: subject.id }evaluated against unvalidated input is a bypass waiting to happen: an agent can pass{ authorId: <its user id> }and "own" any post.
Standard Schema's validate may return a Promise. PermDock's can is synchronous and runs on the client, so async validation cannot be awaited inside a check.
Decision
definePolicy accepts validate: 'boundary' | 'always' | 'never', default 'boundary'.
boundary: the resource schema runs on data that PermDock knows crossed a trust boundary: request bodies in HTTP adapters, tool arguments inpermdock/mcp,permdock/ai-sdkandpermdock/claude-agent, the AuthZEN endpoint'sresourceobject, and client-initiatedrefreshcalls to the decision endpoint. Trusted server rows passed directly tocanare not re-validated.always: everycan/decide/filtervalidates. For development and for policies that cannot tell trusted from untrusted data.never: no runtime validation; types only.
Validation is synchronous. If a schema's validate returns a Promise, PermDock throws PermDockValidationError with a typed message naming the resource, instead of silently denying or awaiting. Validation failure is a denial with reason: 'invalid-resource' plus the schema issues, never an exception on the hot path.
Consequences
- The MCP, AI SDK and HTTP adapters validate model- and client-supplied arguments before a single condition is evaluated, closing the ownership-spoofing hole by default.
- Server code paths keep the cost of a check at a frozen index lookup plus condition evaluation.
- Adapters must mark boundary data explicitly (the
dataloader inprotectis trusted; the raw request body is not). This is part of the adapter contract and is checked by@permdock/testing. - Schemas with async refinements (database uniqueness checks, remote lookups) are unusable as resource schemas; the error says so at definition time in development.
validate: 'never'is a documented footgun andpermdock doctorwarns when it is used in a policy that also has MCP or HTTP adapters.
Alternatives considered
- Validate always (
@zap-studio/permit). Safest, rejected as the default for cost on trusted rows and for double-validating data that a framework validator already checked. - Never validate; types only (permix, CASL). Rejected: leaves the agent-supplied-argument bypass open.
- Per-call
{ validate: true }flag. Kept as an override, rejected as the primary mechanism because call sites forget flags; boundary classification belongs in the adapter. - Await async schemas. Rejected: would make
canasync on the client and infilter, and Standard Schema documents the "throw on Promise" pattern for sync consumers.
Related
0008: Plain JSON leaves, identity by key
Why a permission reference is a frozen JSON object with the schema on the resource node, and why two leaves are the same permission when their keys match.
0010: Policy as data with portable conditions
Why roles are arrays of allow and deny grants whose conditions are a JSON AST, why closures are branded non-portable, and why one JSON format serves snapshots, catalogs and RLS.