# Permissions

Source: https://permdock.com/docs/concepts/permissions

Permissions are typed references built from Standard Schema resources; each leaf is plain JSON identified by its key.

A permission in PermDock is a value, not a string. `permissions.post.update` is an object you can go to the definition of, rename safely, pass as a prop, log, and hand to `can`, `allow`, `usePermission` or `registerTool`. This page describes how the tree is built, what a leaf contains, and the invariants every adapter relies on.

## Building the tree [#building-the-tree]

```ts
import { definePermissions, resource } from "permdock";
import { z } from "zod"; // or valibot / arktype / effect — any Standard Schema

const Post = z.object({
  id: z.string(),
  authorId: z.string(),
  orgId: z.string(),
  published: z.boolean(),
});
const Invoice = z.object({
  id: z.string(),
  orgId: z.string(),
  amount: z.number(),
});

export const permissions = definePermissions({
  post: resource(Post, {
    id: "id",
    actions: ["read", "update", "delete", "publish"],
    collection: ["create", "list"],
  }),
  billing: {
    invoice: resource(Invoice, { actions: ["read", "pay"] }),
    plan: resource({ collection: ["view", "change"] }),
  },
});
```

`definePermissions` walks the object eagerly and materialises a frozen tree. There is no Proxy and no lazy path accumulation; every node exists at module load, which is what makes the tree serialisable and cheap to autocomplete.

### resource() [#resource]

`resource(schema?, options)` describes one kind of thing:

| Option | Type | Purpose |
| --- | --- | --- |
| `schema` (first argument) | any Standard Schema | Infers the instance type for `actions` and conditions; validates untrusted input at trust boundaries. Optional: `resource({ collection: [...] })` is schema-less. |
| `id` | key of the schema output | Identity field used for client cache keys, `filter`, decision tokens and RLS row identity. Defaults to `'id'` when the schema has one. |
| `actions` | array or record | Instance-level actions: `can(permissions.post.update, post)` requires a `Post`. |
| `collection` | array or record | Type-level actions: `can(permissions.post.create)` takes no instance. |
| `name` | string | Optional. The resource name leaves, RLS tables, relations, `parent` and AuthZEN types use, in place of the last path segment. A letter, then letters, digits, `_` or `-`. Names are unique across the merged tree. The key keeps the path. |
| `parent` | `{ field, resource }` | Optional. `resource` is the parent **name string** (the last path segment). Exactly one parent. The name is resolved at `definePolicy` after `mergePermissions`. See [tenancy](/docs/concepts/tenancy). |
| `levels` | record of `where` objects | Optional. Named conditions a [custom role](/docs/concepts/custom-roles#levels) picks per permission, such as `own`, `team` and `all`. Names match `^[a-z][a-z0-9_]*$`; the resource needs instance actions. `{}` adds no condition. |
| `meta` | `{ title?, description?, x? }` | Optional. Display text and app data for the resource. Plain JSON, read with `getResource(tree, name)?.meta` and carried on the catalog's `resources.<name>.meta`; PermDock never reads `x`. |
| `disclosure` | `'hide'` or `'reveal'` | Optional, default `'reveal'`. With `'hide'`, an HTTP adapter answers a denied check on a loaded row with `404` `/not-found`, the same body a missing row gets, so an id never confirms that a row exists; the reason stays on the decision event. A step-up or rate-limit denial still answers as usual, because it arises only when the subject holds a matching grant. See the [threat model](/docs/security/threat-model). |

### actions versus collection [#actions-versus-collection]

The split is the answer to CASL's "can I read some Post versus this Post" ambiguity. Arity is part of the type:

```ts
permdock.can(permissions.post.update, post); // ok
permdock.can(permissions.post.update); // type error: instance required
permdock.can(permissions.post.create); // ok: type-level "may I create"
permdock.can(permissions.post.create, body); // ok: optional body; a check grant matches only with a body
```

Collection `check` is [permissions](/docs/concepts/permissions).

An action name may appear in both lists when both readings are meaningful (`read` on an instance and `read` on the collection would be two different leaves: `post.read` and, for example, `post.list`). Prefer distinct names so `.key` values stay unambiguous.

### Presets [#presets]

`crud`, `readable` and `writable` are option factories. They return the same `actions` / `collection` records you can write by hand, including default `meta`. Pass the result into `resource()`; they are not constructors and they do not grant anything.

```ts
import {
  definePermissions,
  resource,
  crud,
  readable,
  writable,
} from "permdock";

export const permissions = definePermissions({
  post: resource(
    Post,
    crud({
      id: "id",
      actions: { publish: { title: "Publish post" } },
    }),
  ),
  report: resource(Report, readable({ id: "id" })),
  setting: resource(Setting, writable({ id: "id" })),
});
```

| Helper | Instance | Collection | Default meta |
| --- | --- | --- | --- |
| `crud()` | `read`, `update`, `delete` | `create`, `list` | `read` and `list` carry `readOnly: true`; `delete` carries `destructive: true` |
| `readable()` | `read` | `list` | both `readOnly: true` |
| `writable()` | `read`, `update` | none | `read` carries `readOnly: true` |

The name is `readable`, not `readOnly`: `readOnly` is already `ActionMeta.readOnly`. Extra names merge into the preset records; overlapping meta fields on the extra win. There is no `omit`: write the lists by hand when the preset is the wrong shape. See [permissions](/docs/concepts/permissions).

### Nested groups [#nested-groups]

Groups nest arbitrarily: feature, then resource, then action is the common shape (`billing.invoice.pay`). A group is any plain object whose values are resources or further groups. Nesting is capped at 10; a deeper tree throws at `definePermissions`. Groups are the namespace mechanism for [larger apps](/docs/getting-started/larger-apps): a shared package owns one top-level group and the app merges it.

### Metadata records [#metadata-records]

`actions` and `collection` accept a record instead of an array when a leaf needs metadata for catalogs, docs, OpenAPI or MCP tool descriptions:

```ts
post: resource(Post, {
  actions: {
    read: { title: "Read post", readOnly: true },
    update: {
      title: "Edit post",
      description: "Change title or body",
      tags: ["editor"],
    },
    delete: { title: "Delete post", destructive: true },
  },
  collection: ["create", "list"],
});
```

Metadata is plain JSON and lands on the leaf as `meta`. `x` holds data the application owns, such as a risk level or an undo window: a JSON object nested at most 8 deep, frozen on the leaf and carried on the catalog and snapshots. PermDock never reads it.

```ts
invoice: resource(Invoice, {
  actions: { send: { title: "Send invoice", x: { risk: "high", undo: "30m" } } },
}),
```

#### Typed app data [#typed-app-data]

Pass a Standard Schema per kind as `definePermissions(tree, { x: { permission, resource } })` to check and type `x`. `permission` checks every action's `meta.x`, `resource` checks every resource's `meta.x`, and each leaf's `meta.x` takes the schema's output type:

```ts
const permissions = definePermissions(
  {
    invoice: resource(Invoice, {
      actions: { send: { x: { risk: "high" } }, read: {} },
      meta: { title: "Invoice", x: { owner: "billing" } },
    }),
  },
  {
    x: {
      permission: z.object({ risk: z.enum(["low", "high"]) }),
      resource: z.object({ owner: z.string() }),
    },
  },
);

permissions.invoice.send.meta.x?.risk; // "low" | "high" | undefined
getResource(permissions, "invoice")?.meta?.x?.owner; // string | undefined
```

A schema must validate synchronously; one that returns a Promise throws. Invalid `x` throws at `definePermissions`, because a bad definition is an author error. `defineRoles(tree, { x })`, `definePlans(tree, { x })` and `definePolicy(…, { x: { grant, membership } })` take the same kind of schema for roles, plans, grants and memberships ([extend PermDock](/docs/guides/extending)).

`readOnly`, `destructive` and `idempotent` become the MCP tool hints `readOnlyHint`, `destructiveHint` and `idempotentHint` in [`permdock/mcp`](/docs/adapters/mcp) (WebMCP reads only `readOnly`); an action named `read` or `list` is read-only unless `readOnly` says otherwise, and `destructive` is ignored on a read-only action. The hints inform the client and never replace the server's decision. The [catalog](/docs/cli/catalog) prints `title` and `description`.

## What a leaf contains [#what-a-leaf-contains]

```ts
permissions.post.update;
// Permission<'post.update', Post>
// {
//   key: 'post.update',
//   scope: 'post:update',
//   resource: 'post',
//   action: 'update',
//   meta: { ... }
// }
```

| Field | Example | Used by |
| --- | --- | --- |
| `key` | `'post.update'` | Grants, snapshots, cache keys, audit events, catalog, `findPermission` |
| `scope` | `'billing:invoice:pay'` | OAuth scopes, MCP `scopeChallenge`, OpenAPI `securitySchemes`, RFC 9396 `authorization_details` |
| `resource` | `'post'` | Lookup of the resource node (schema, `id` field), `alternatives` in denials |
| `action` | `'update'` | RLS command mapping (`update` becomes `USING` plus `WITH CHECK`), tool descriptions |
| `meta` | `{ title, description, tags, readOnly, inferredFrom }` | Catalogs, docs, tool hints; `inferredFrom` is written by `permdock openapi import` with the operation an action came from |

The schema is not on the leaf. It lives on the resource node, reachable through the definition, so a leaf stays JSON-serialisable while `can(permissions.post.update, post)` still knows that `post` must be a `Post`.

### Resource names [#resource-names]

Two resources may share a last path segment when one sets `name`. A platform resource and an organisation resource named `billing` keep their keys and get distinct names:

```ts
definePermissions({
  platform: {
    billing: resource({ name: "platform_billing", actions: ["view"] }),
  },
  billing: resource({ actions: ["view"] }),
});
// platform.billing.view: { key: 'platform.billing.view', resource: 'platform_billing' }
// billing.view:          { key: 'billing.view',          resource: 'billing' }
```

### Type-level view [#type-level-view]

Two phantom type parameters ride along with each leaf: the key literal, the instance type, and the kind.

```ts
type UpdatePost = typeof permissions.post.update; // Permission<'post.update', Post, 'instance'>
type CreatePost = typeof permissions.post.create; // Permission<'post.create', Post, 'collection'>: optional body

function guard<K extends string, T>(permission: Permission<K, T>, data: T) {
  /* ... */
}
```

No template-literal unions are generated from the tree, which keeps TypeScript 7 checking fast and keeps error messages readable. Conditions and field lists are typed from the schema output (`StandardSchemaV1.InferOutput`), not from hand-written flat types.

## Invariants [#invariants]

These hold for every leaf and every adapter depends on them.

1. A leaf is plain, frozen, JSON-serialisable data. It can be passed as a prop across the RSC boundary, posted to the decision endpoint, stored in a queue message, or printed in a log.
2. Identity is by `key`, never by object identity. Two bundle copies of the definition module, a leaf that came back from `JSON.parse`, and the leaf in the merged registry all resolve to the same grant. `mergePermissions` preserves object identity as a convenience, but nothing relies on it.
3. An unknown reference is a type error. `allow(permissions.post.archive)` fails to compile; `findPermission(permissions, 'post.archive')` returns `undefined` at runtime.
4. Leaves are prototype-safe. Keys such as `constructor` or `__proto__` are rejected by `definePermissions`, and lookups use own-property checks.
5. The definition has no rules and no secrets, so it can be imported by client bundles, React Native, MCP servers, the CLI and tests.

## Renamed keys [#renamed-keys]

`definePermissions(tree, { renamed })` maps keys a permission used to have to its current key. Rename the leaf in code and add the old key:

```ts
export const permissions = definePermissions(
  { customer: resource(Customer, { actions: ["read", "update"] }) },
  {
    renamed: {
      "organization.customers.view": "customer.read",
      "organization.customers.edit": "customer.update",
    },
  },
);
```

A former key resolves wherever a key arrives as a string: `findPermission`, stored [custom roles](/docs/concepts/custom-roles#renamed-keys), OAuth scopes in their `:` form, AuthZEN actions and hosted grants. Code, decisions, snapshots and audit events see only the current key. `formerKeys(leaf)` returns a leaf's former keys; they live on a non-enumerable property, so a leaf that crossed `JSON.stringify` has none. The catalog lists them as `renamedFrom`, and [`permdock diff`](/docs/cli/diff) reports a rename as `renamed` and a dropped alias as the breaking `alias-removed`. [`rls migrate`](/docs/cli/rls#migrate) maps former keys without a `keys` entry, and `rls generate` seeds `role_permissions` under them too.

`definePermissions` throws when an old key is still a current key, a target is not a declared key, or an old key is not a valid key. An approval token binds the current key and the policy fingerprint, so an approval pending across a deploy that renames its key no longer resumes and is asked again.

[Existing apps](/docs/getting-started/existing-apps) walks through a rename and its deprecation window.

## Registry helpers [#registry-helpers]

Helpers are functions, not methods on the tree, so a resource named `list` or `find` never collides with them.

```ts
import { listPermissions, findPermission } from "permdock";

listPermissions(permissions);
// [{ key: 'post.read', scope: 'post:read', resource: 'post', action: 'read', meta }, ...]

findPermission(permissions, "post.update"); // Permission | undefined
findPermission(permissions, scopeFromJwt); // strings from a DB, JWT scope or OpenAPI doc
findPermission(permissions, "organization.customers.view"); // a former key: the current leaf
```

`isPermission(value)` narrows an `unknown` to `Permission` without a cast: it checks for string `key`, `scope`, `resource` and `action` and an object `meta`. It accepts a leaf that crossed `JSON.stringify` (which drops the non-enumerable `kind`), and it does not look the key up, so the check that follows still decides by `key`.

`listPermissions` is the runtime catalog. `permdock collect` produces the same list at build time and adds usage information; the two agree by construction because both come from the definition. `findPermission` is the only place a string enters the system, and its result is typed as a union of all leaves so the next call is typed again.

`mergePermissions` combines definitions from several files; see [larger apps](/docs/getting-started/larger-apps).

## Serialised form [#serialised-form]

```json
{
  "key": "post.update",
  "scope": "post:update",
  "resource": "post",
  "action": "update",
  "meta": { "title": "Edit post", "tags": ["editor"] }
}
```

This is the exact object you get from `JSON.stringify(permissions.post.update)`. The [wire formats](/docs/concepts/wire-formats) page lists it next to conditions, snapshots and AuthZEN messages. The RFC 9396 `authorization_details` object a consent screen shows is derived from `resource`, `action` and the resource id, so a leaf carries no example object in `meta`.

## Workflow verbs [#workflow-verbs]

Sensitive money or filing work is usually a chain of leaves, not one `update`. Name each verb on the resource so `permdock doctor` PD017 can see it, and so `approval.by` and `exclusiveWith` attach to the right step.

```ts
const Filing = z.object({
  id: z.string(),
  orgId: z.string(),
  amount: z.number(),
});
const Invoice = z.object({
  id: z.string(),
  orgId: z.string(),
  amount: z.number(),
});
const Claim = z.object({ id: z.string(), orgId: z.string() });

export const permissions = definePermissions({
  filing: resource(Filing, {
    actions: {
      prepare: { title: "Prepare filing" },
      approve: { title: "Approve filing" },
      pay: { title: "Pay filing" },
      settle: { title: "Settle filing" },
    },
  }),
  billing: {
    invoice: resource(Invoice, { actions: ["read", "submit", "pay"] }),
  },
  guarantee: {
    claim: resource(Claim, { actions: ["prepare", "approve", "settle"] }),
  },
});
```

Typical policy:

* `allow(permissions.filing.prepare)` for the clerk role.
* `allow(permissions.filing.approve, { approval: { by: roles.admin } })` so an admin other than the requester approves; neither the principal nor the actor can approve their own request.
* `allow(permissions.filing.pay, { to: [roles.admin, assurance({ amr: ['hwk'], maxAge: 300 })] })` so pay requires a hardware key within five minutes.
* `role(roles.preparer, [allow(permissions.filing.prepare)], { exclusiveWith: ['approver'] })` so PD018 flags a custom role that includes both.

Default PD017 verbs are `approve`, `pay`, `settle`, `submit`, `transfer`, `refund`, `disburse`. Override with `doctor.sensitiveActions` in `permdock.config.ts`. A matching `deny` on the same leaf silences the warning.

## Design rationale [#design-rationale]

**References, not string keys.** A string key has no go-to-definition and no safe rename, and a typo is at best a compile error and at worst a silent deny. Template-literal unions over every resource and action slow the type checker on large catalogs, a string has nowhere to carry a scope or a title, and the union does not exist at runtime, so a catalog would have to be extracted from source. A reference fixes all four: the runtime definition is the catalog, an unknown permission is a type error, and types flow from the reference argument, so hooks and server helpers are direct exports with no per-app generic factory. Runtime subject detection in the CASL style is unnecessary because the reference already names the resource, and a Proxy tree was rejected because it cannot be serialised or enumerated.

**Plain leaves, identity by key.** Leaves cross RSC props, JSON bodies, queue messages and duplicate bundle copies, and Standard Schema validators are neither serialisable nor small, so the schema stays on the resource node and a leaf is only data. Because identity is the `key`, keys are part of the public contract: renaming a resource is a wire-visible change, and `permdock collect --check` reports it. `renamed` keeps stored keys resolving through that change instead of hiding it.

**Arity in the type.** If every action accepted optional data, a handler could check `update` without loading the row and an ownership condition would be evaluated against nothing. That mistake is easiest to make in an agent tool handler, where nothing else flags it. Arity cannot be inferred from the policy instead, because the policy is server-only while the definition is shared with clients. The same split explains why `where`, `filter` and `permdock.where` exist only for instance actions.

**An optional body on collection actions.** `create` stays a collection action so "may I create at all" needs no placeholder row. A collection grant may carry `check` on the proposed body but never `where`, since there is no current row. `can(permissions.post.create, body)` passes the body, typed as the schema output and boundary-validated unless the call passes `trusted: true`. Without a body, a `check` grant does not match: a row condition cannot be true for a row nobody described. An unconditional collection grant still matches with no body.

**One parent, named by a string.** `parent.resource` is a name so a feature package can declare `parent: { field: 'projectId', resource: 'project' }` before the app merges the project definition, and so the option stays plain JSON. A resource has at most one parent and must not name itself, which keeps `memberOf` a finite chain rather than a graph walk in evaluation and in RLS. The name is resolved at `definePolicy`, after `mergePermissions`; an unknown parent or a duplicate resource name in the merged tree throws at policy construction instead of silently never matching at check time.

**Presets are sugar, not defaults.** `resource(schema)` never implies CRUD, because a resource that is only listed would grow `update` and `delete` leaves. There is no closed action union (domain verbs such as `publish` and `pay` are first-class), no `manage` alias (it would silently cover actions the author never listed), and no possession in action names such as `createOwn` (ownership is a `where`; the action stays the verb RLS maps).

## What permissions are not [#what-permissions-are-not]

* Not rules. Who may do what lives in the [policy](/docs/concepts/policies).
* Not classes. There is no subject detection, no `constructor.name`, no tagging of user objects.
* Not strings in the public API. `can('post.update', post)` does not exist; `can(findPermission(permissions, key)!, post)` is the explicit escape hatch.
