# Policies

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

Grants name a grantee with to selectors; definePolicy binds typed vocabulary, a principal mapper, and portable conditions.

A policy answers "who may do what, under which conditions". In PermDock it is data: typed roles and plans sit next to permissions, grants are `allow` or `deny` of a permission reference with a `to:` selector, and `definePolicy` binds the vocabulary to the function that turns your user into a principal. Because the policy is data, it can be snapshotted to the client, compiled to SQL, printed as a matrix in tests, and diffed in review.

The policy is server-only. It is the one file in a PermDock app that must never reach a client bundle; `permdock doctor` checks for it.

## Shape [#shape]

```ts
import {
  definePolicy,
  defineRoles,
  role,
  allow,
  deny,
  principal,
  relation,
  anyone,
} from "permdock";
import { permissions } from "./permissions";

export const roles = defineRoles({ member: {}, admin: {} });

const member = role(roles.member, [
  allow(permissions.post.read),
  allow(permissions.post.list),
  allow(permissions.post.create),
  allow(permissions.post.update, { to: relation(permissions.post, "author") }),
  allow(permissions.post.delete, {
    where: { authorId: principal.id },
    approval: "human",
  }),
]);

const admin = role(roles.admin, [
  ...member.grants,
  allow(permissions.post.delete),
  deny(permissions.post.publish, { to: anyone(), where: { published: true } }),
]);

export const policy = definePolicy(
  { permissions, roles },
  {
    roles: [member, admin],
    principal: (user: User | null) =>
      user && { id: user.id, orgId: user.orgId, roles: user.roles },
    context: async (user) => ({ teamIds: await loadTeamIds(user.id) }),
    validate: "boundary",
  },
);
```

## Roles [#roles]

`role(name, grants, options?)` returns a `RoleBinding`: `{ name, grants, on?, assignable, exclusiveWith?, min?, max?, transferOnly?, assigns?, for?, meta? }`. `exclusiveWith` is a static-separation list for PD018 and `decideRoleChange`; it does not change evaluation. `min`, `max`, `transferOnly`, `assigns` and `for` are the role's [ownership rules](/docs/concepts/ownership): `for` lists the membership kinds (`via`) that may hold the role, and a role held through any other kind grants nothing. `meta` is a `RoleMeta`, whose `audience` feeds `permdock.audiences()`. A role is looked up by name from `subject.roles` or from a membership, so role names are the only strings in a policy, and they are data you already store (in your users table, JWT claims or Better Auth roles). Roles are not Postgres roles; when RLS is generated, app roles become claims and every policy targets `TO authenticated` (see [RLS](/docs/adapters/rls)).

### Typed vocabulary [#typed-vocabulary]

Roles and plans are declared the same way permissions are. `defineRoles` and `definePlans` produce trees of frozen JSON leaves: a `Role` leaf is `{ key, on?, assignable, meta }`, a `Plan` leaf is `{ key, meta }`, and identity is by `key`. `definePolicy` takes the vocabulary object `{ permissions, roles?, plans? }` as its first argument (a bare permission tree still works), and `role()` accepts a `Role` leaf or a name string; the leaf's `on` and `assignable` are the defaults for the binding.

The instance exposes the same trees as `permdock.permissions`, `permdock.roles` and `permdock.plans`, and `heldRoles({ tenant?, scope?, id? })` and `assignableRoles()` return `Role[]`, ranked by the `assigns` graph when the policy has one. `audiences()` lists the distinct `meta.audience` values of the roles held in the active tenant, and `decideRoleChange(change)` checks an assign, revoke or transfer ([ownership](/docs/concepts/ownership)). A name that only exists in a `RoleSource` or a token becomes a synthesised leaf with `assignable: false`. The reason is the same as for permissions: `roles.admin` autocompletes, a typo is a compile error, the catalog can list roles and plans, and a `pla` or `entitlements` claim has a typed place to land. The leaves carry no grants or closures, so they are client-safe and ship in the snapshot.

`defineRoles(tree, { x })` and `definePlans(tree, { x })` take a Standard Schema for every leaf's `meta.x`. It validates at definition time, throws on invalid data, and types `roles.admin.meta.x`. On a role tree the same schema checks the `meta.x` of [custom roles](/docs/concepts/custom-roles) a `RoleSource` returns; an invalid one is dropped, not the role.

### Scoped roles [#scoped-roles]

The third argument says where a role applies. Without it the role is global and is selected by `principal.roles`, as in the example above. With `on`, the role is selected by a membership and its grants match only rows in that scope:

```ts
const viewer = role(
  "viewer",
  [allow([permissions.post.read, permissions.post.list])],
  { on: "tenant" },
);
const lead = role("lead", [allow(permissions.post.publish)], { on: "team" });
const editor = role("editor", [allow(permissions.document.update)], {
  on: permissions.document,
});

export const policy = definePolicy(permissions, {
  roles: [viewer, lead, editor, admin],
  scopes: {
    tenant: { key: "orgId" },
    team: { key: "teamId", within: "tenant" },
  },
  subject: subjectFromClerk,
});
```

`scopes` declares the policy's [named scopes](/docs/concepts/scopes) in order; each `key` names the row field holding that scope's id, and every resource a scoped grant touches declares it as a `memberOf` relation. It replaces `where: { orgId: principal.orgId }` on each grant. `assignable` (default `true` for scoped roles) marks roles a tenant admin may hand out and compose into tenant-defined custom roles. The full model, memberships, evaluation rules, custom roles and the `RoleSource` and `MembershipSource` interfaces, is on [tenants, teams and scoped roles](/docs/concepts/tenancy).

### Spreading grants [#spreading-grants]

`...member.grants` copies a role's grants into another. It is plain array spread, so the result is visible, reviewable and needs no hierarchy resolver. The admin above has every member grant plus unconditional delete.

### Role fragments [#role-fragments]

Roles may be declared in several files and passed together to `definePolicy`. Roles with the same name merge their grants in declaration order:

```ts
export const policy = definePolicy(permissions, {
  roles: [...postRoles, ...billingRoles], // two 'member' fragments become one role
  subject,
});
```

Merging is a concatenation of grant arrays; the evaluation rules below make order irrelevant to the outcome. A fragment that references a leaf outside `permissions` is a type error, which is what keeps a feature from granting a permission the app never merged. See [larger apps](/docs/getting-started/larger-apps).

## Grants [#grants]

`allow(permission, condition?)` and `deny(permission, condition?)` are the only two grant constructors. The first argument is a reference or an array of references (`allow([permissions.post.read, permissions.post.list])`, `allow(listPermissions(permissions.post))`); an array is declaration sugar that becomes one grant per leaf in the normalised policy, the catalog and the snapshot.

| Second argument | Meaning |
| --- | --- |
| `{ to }` | Grantee selector: `anyone()`, `authenticated()`, `relation(resource, name)`, `inherit(permission, { through })`, a `Role` or `Plan` leaf, `actor(kind)`, `assurance({ acr })`, or an array (intersection). Required on top-level `grants`. `role()` bindings fill a role selector. |
| `{ where }` | Portable condition on the current row (RLS `USING`) |
| `{ check }` | Portable condition on the next row (RLS `WITH CHECK`) for `create` and `update` |
| `{ where, check }` | Both, for `update` |
| `(data, ctx) => boolean` | Closure: runtime only, branded non-portable |
| `{ ..., approval: 'human' }` or `{ ..., approval: { by, distinct } }` | Grant is valid but the decision is `approval-required`. `'human'` lets any authenticated person other than the actor and the principal approve. `{ by }` names eligible approvers with the same grantee selectors as `to`. `distinct` defaults to `true`; `distinct: false` lets the principal approve their own request (doctor PD024 lists each one). `staleOn: 'resource-change'` binds the approval to the row's `version` field, so it no longer applies once the row changes ([approval security](/docs/security/approvals#approvals-that-go-stale)) |
| `{ ..., limit: { count, per, mode?, alertAt? } }` | Quota grant: non-portable, needs a `LimitStore`. `mode: 'hard'` (default) denies past `count`; `mode: 'soft'` grants with an `over-limit` obligation; `alertAt` adds a `near-limit` obligation |
| `{ ..., fields: ['title', 'body'] }` | Schema-aware field list: keys of the resource type; omitted means every field; empty never matches |
| `{ ..., validFrom, validUntil }` | When the grant applies, as RFC 3339 strings or Unix seconds; `validFrom` inclusive, `validUntil` exclusive. Either bound alone is allowed ([validity](#validity)) |
| `{ ..., requires: permissions.file.read }` | An allow on an instance action counts only on rows whose scope instance (or the whole app) is one where the subject also holds that permission through a role grant without a row condition, minus a deny of it there; a list requires each of them ([requires](#requires)) |
| `{ ..., group: 'sent' }` | A stable name for the grant's condition group in generated SQL: its grant key is `<permission>#sent` instead of a positional `#n`, so hand-written SQL that repeats the condition keeps the key when other grants change ([grant keys](/docs/adapters/rls#sql-helper-contract)). Lower case letters, digits, `_` and `-`, starting with a letter; ignored in process |
| `{ ..., name: 'lock' }` | A stable name for the grant. A `breakGlass` override lifts a named deny; on any grant it is reported on `decision.matched.name`, decision events, snapshots and the catalog |
| `{ ..., meta: { description, x } }` | Display text and app data for the grant, reported on `decision.matched.meta`, decision events, snapshot grants and catalog grants. Plain JSON; `x` is checked by `definePolicy(…, { x: { grant } })`. Left out of the policy fingerprint, so editing it invalidates no token. Never read in evaluation |
| `{ ..., obligations: ['watermark'] }` | Allows only. Each entry, a name or `{ name, detail }`, becomes a `{ kind: 'app', name, detail? }` obligation on the granted decision ([obligations](/docs/concepts/decisions#obligations)) |

Conditions are covered on their own page: [conditions](/docs/concepts/conditions). Collection actions accept no `where` (there is no current row). They may carry `check` on the proposed body; without a body a `check` grant does not match. Closures over `ctx` only remain valid on collection grants.

## Validity [#validity]

```ts
role(roles.contractor, [
  allow(permissions.doc.read, {
    validFrom: "2026-03-01T00:00:00Z",
    validUntil: "2026-04-01T00:00:00Z",
  }),
  deny(permissions.doc.delete, { validUntil: launch, name: "freeze" }),
]);
```

`validFrom` and `validUntil` bound a grant in time without a membership: a contractor's read access for the length of an engagement, a change freeze that lifts itself at launch, a permission that opens on a release date. `definePolicy` normalises both to Unix seconds on `Grant.validity` as `{ from?, until? }`, and throws on a bound that does not parse or a window that ends before it starts, so a typo never widens a grant.

Outside its window a grant contributes nothing. An inactive allow adds an `inactive-grant` denial carrying the window as `detail: { from, until }`, so a UI can say "from 1 March" instead of "no access"; an inactive deny does not apply, and `explain` lists it under `skipped` with `why: 'validity'`. The window is read against the decision clock: `now` on `decide`, `can` and `simulate` (`simulate(checks, { now })` reads a whole batch as of one instant), so a test or a review screen asks "what will this role do after launch?" without changing the policy. Validity is portable: `where()` and `filter` drop inactive grants, the [snapshot](/docs/concepts/snapshots) carries `validity` and the client evaluator checks it against its own clock, and `permdock rls generate` ANDs `now() >= to_timestamp(from) and now() < to_timestamp(until)` into the grant's access check, so the database agrees with the application to the second.

### Why [#why]

Validity is a field of the grant, not a `where` operator on a clock ref, because the two mean different things in a policy diff and in the catalog: a condition tells you which rows a grant reaches, a window tells you when it exists at all. Keeping it separate lets `explain` report an inactive allow as `inactive-grant` rather than as a failed `condition`, lets `where()` drop the grant entirely instead of compiling a tautology, and lets a reviewer see "expires 1 April" next to the grant without reading its condition. Only a fixed range is supported: a recurring window (office hours, weekdays) is a business rule the application knows better than the policy, and encoding time zones and calendars in a portable condition would have cost every compiler a dependency for one case. A membership's `expiresAt` remains the tool for "this person's access ends"; validity is for "this rule's life".

## Requires [#requires]

```ts
allow(permissions.drive.read, {
  to: relation(permissions.drive, "viewer"),
  requires: permissions.file.read,
});
```

`requires` puts a ceiling on a grant: a drive share counts only while the user also holds `file.read` in the drive's organization. The requirement is met when a role grant of the named permission without a row condition (no `where`, `check`, closure or `requires`, and inside its validity window) reaches the subject globally, or reaches it on the row's instance of a scope the row's resource is partitioned by, with no deny of that permission at the same instance. Declared roles and custom roles count alike. It is allowed on an `allow` of an instance action only; `definePolicy` rejects it on a `deny`, on a collection action and for an undeclared permission.

A list requires every permission in it: `requires: [permissions.file.read, permissions.file.download]` counts a share only on rows whose organization grants the subject both. Each permission is checked on its own, so the subject may hold one globally and the other in the row's organization. An empty list is rejected.

The check folds into the grant's row condition as `{ op: 'in', field: '<scope key>', value: [<instance ids>] }`, so `where()`, `filter` and the [snapshot](/docs/concepts/snapshots) carry it, and a client decides it like any portable condition. `permdock rls generate` ANDs `(select permdock_has_permission('<key>')) or "<scope key>" in (select permitted_<scope>_ids_by_permission('<key>'))` into the grant's access check, one such check per listed permission, and the grant's key counts as conditioned in `grant_keys`.

A delegated caller (an OAuth client, an API key) uses an allow with `requires` only when its scopes cover every required permission, as the database decides it: the requirement is checked through helpers that apply the same key ceiling. Its scopes must also cover the granted permission, with one exception: a read-only granted permission (`meta.readOnly`, or the `read` and `list` actions) on an allow that is not a role grant. A key stored with the feature scope `file:read` therefore keeps reading the drives its shares reach after `drive.read` moves to relation grants with `requires: file.read`, without reissuing keys, and it reaches no other grant of `drive.read`. It cannot use an editor share's `node.update` grant that requires `file.read`, nor a grant with `requires: [file.read, file.update]`, unless it also names `node.update` and every required key. A key scoped to `drive:read` alone reaches the other grants of `drive.read` but not the shares that require `file.read`. `can()`, `where()`, snapshots and `rls.apiKeys` apply the same rule.

### Why [#why-1]

An organization ceiling is a statement about the subject's roles, not about the row's columns, so it cannot be a `where` the author writes. Reading it from the same helpers by permission key that SQL callers use (`permdock_has_permission`, `permitted_<scope>_ids_by_permission`) keeps the in-process and database answers on one definition: unconditional role grants minus denies at that instance. Grants that carry their own row condition or `requires` never satisfy a requirement, so requirements cannot chain or loop.

## Evaluation semantics [#evaluation-semantics]

The rules are short and they are the whole story:

1. Collect every grant from top-level `grants` and from role bindings whose name the subject holds, then keep those whose `to:` selector matches (`anyone()`, `authenticated()`, a role, a plan, a relation, an actor, an assurance check, or an intersection). A relation selector also contributes a portable `where`.
2. Drop a scoped grant whose scope does not match the request: the row's tenant key must equal the membership's tenant and that tenant must be the active one; the row's team key must equal the membership's team; a resource role must be held on that row or an ancestor through a declared `parent`. Expired memberships contribute nothing. Global grants skip this step ([tenancy](/docs/concepts/tenancy)). Drop a grant outside its `validFrom` / `validUntil` window as of the decision clock ([validity](#validity)); an inactive allow records an `inactive-grant` denial.
3. If any `deny` matches, the outcome is `denied`. Deny overrides allow, regardless of role, scope or declaration order.
4. Otherwise, if any `allow` matches, the outcome is `granted` (or `approval-required` if the matched allow carries `approval`). Allows OR together, across scopes.
5. Otherwise the outcome is `denied` with an empty match. Nothing granted means denied; there is no `not-applicable`. Denial reasons include `tenant-mismatch`, `no-membership`, `scope` and `expired-membership` so the UI can say why.
6. For an agent subject, the result is then intersected with the delegated authority (see [subject](/docs/concepts/subject)).

A grant "matches" when its condition evaluates to `true` for the given data and subject. An unconditional grant always matches. A closure that throws counts as not matching and is reported in the decision's `denials` with a reason. Evaluation never throws; `can` and `decide` are safe to call from render paths.

Because deny is absolute, the compiled SQL form is simple: each allow becomes a `PERMISSIVE` policy, each deny becomes a `RESTRICTIVE` policy with `NOT (condition)`, and Postgres computes the same result.

### What a missing grant means [#what-a-missing-grant-means]

There is no default-allow anywhere. A permission with no grant in any of the subject's roles is denied, and `permdock usage` reports it as granted-by-no-role so unreachable permissions are visible in CI.

### Type safety [#type-safety]

* `allow(permissions.post.archive)` fails to compile if `archive` was never defined.
* `allow(permissions.post.update, { where: { autorId: principal.id } })` fails to compile: `autorId` is not a field of `Post`.
* `roles: user.roles` where `roles` contains a name no `role()` declared is a runtime deny for that role plus a `permdock doctor` warning, not a throw. On a membership, such a name is first resolved as a tenant-defined custom role through the `RoleSource` and dropped if that fails ([tenancy](/docs/concepts/tenancy)).
* `role('viewer', [...], { on: 'customers' })` fails to compile when the policy declares `customer`, and `definePolicy` throws when a resource a scoped grant touches has no `memberOf` relation on the scope's key.
* A permission held as plain `Permission` at run time (from `listPermissions`, a catalog or `findPermission`) does not say whether it is an instance or a collection action, so `can`, `decide`, `assert` and `explain` take it with an explicit `data` argument: `permdock.can(permission, row)`, or `permdock.can(permission, undefined)` for a check without a row. A literal reference keeps its narrower overload, so `permdock.can(permissions.post.update)` without a row still fails to compile.
* `definePolicy` returns `Policy<TUser, TPrincipal>`, typed from the `principal` mapper. `createPermDock` in core and in every adapter is generic over both, so a typed policy passes to any adapter without a cast, and the adapter's `subject` option is checked against `TUser`. `Policy.subject` and `Policy.context` are declared as methods so a typed policy stays assignable to helpers that only read roles and grants. `TUser` never defaults to `any`, which would hide real mismatches.

### Derived instances [#derived-instances]

`permdock.tenant(id)` and `permdock.team(id)` narrow the same subject to one tenant or team. `permdock.derive({ customRoles?, approvalPolicies?, relations? })` keeps the subject, its active tenant and team, the actor, the delegation, the sink and the limit store, and swaps the sources it names: a server action that edits roles derives an instance whose `customRoles` reads every role of the tenant, and one that runs a write derives an instance with the tenant's `approvalPolicies`. Only the sources it names are read again, for the subject's tenants; it returns a promise when one of them answers asynchronously. The original instance is unchanged. A [`fromSnapshot`](/docs/concepts/snapshots) instance returns itself, because a snapshot carries its grants already resolved.

### Listing hints [#listing-hints]

`mayUse(permdock, permission)` from `permdock` answers whether a permission could be granted to the instance's subject for some row: a grant in its snapshot for the active tenant, no unconditional deny, and a delegation that could cover it. A tool list, a command palette or a skill index filters with it; the call itself still runs `can` or `decide`, because a row condition or an approval decides only there. `mayAccess` is the policy-level twin for a proxy that has no instance yet.

`permittedIds(permdock, permission, scope, { within?, conditioned? })` lists the instances of `scope` in which the subject holds `permission` with no row condition: an unconditional allow on a live membership of exactly that scope, minus the instances a deny of the permission reaches, within its delegation. A `where`, a `check`, a validity window or a non-portable test is a row condition; a `fields` list is not, so an allow limited to some fields counts, as it does in SQL. With `conditioned: true` it also lists the instances where an allow with a row condition applies and subtracts only unconditional denies, the twin of `permitted_<scope>_ids_by_permission(p_permission, true)`: the list says where some rows may be allowed, and each row is still checked. It is the in-process mirror of the SQL `permitted_<scope>_ids_by_permission`, for a portal that lists the customers a contact may open or a query that filters by them; `within` keeps the instances of one tenant. Like `mayUse` it lists and never decides. The permissions a subject holds in a tenant are `listPermissions(policy.permissions).filter((permission) => permdock.tenant(id).can(permission, undefined))`.

### Evaluation is synchronous [#evaluation-is-synchronous]

`can`, `decide`, `assert`, `filter`, `where` and `snapshot()` (without a `signer`) never await. A client, a `filter` over a list and a router guard need an answer on the first frame, and a Promise is truthy, so an async check that a caller forgot to await would fail open. Asynchronous work has one home: `context`, `MembershipSource` and `RoleSource` run once inside `createPermDock`, which may itself be async, and their output is frozen into the instance. Each check is then a pure function of frozen data.

Closure grants are typed `(data, ctx) => boolean`. A closure that returns a thenable is a `closure-error` denial reported to `on('error')`; the value is never awaited. There are no `decideAsync` or `canAsync` twins, because two evaluation paths can disagree and the synchronous one is easy to call by mistake, and PermDock does not infer asynchrony from the policy, because one async closure would make every `can` in the UI async. `explain` is `decide` with a `trace` attached, computed in process; `describe(decision)` turns a decision into prose ([decisions](/docs/concepts/decisions#explain)).

## Approval [#approval]

`approval: 'human'` marks a grant whose match is necessary but not sufficient. When it is the matched allow, `decide` returns `{ outcome: 'approval-required', grant, reason, token }` instead of `granted`. The token binds the pending approval to the permission key, resource id, subject and actor so an approved reply cannot be replayed against different arguments.

Adapters translate this outcome: the Vercel AI SDK gets `user-approval`, `WorkflowAgent` suspends through `needsApproval`, MCP answers `input_required` or a refusal carrying the token, HTTP returns 403 Problem Details with an `approval-required` type. See [decisions](/docs/concepts/decisions) and [approvals](/docs/security/approvals).

## Feature flags and entitlements are context, not grants [#feature-flags-and-entitlements-are-context-not-grants]

An `EntitlementSource` passed as `entitlements` to `createPermDock` (`fromStripeEntitlements` for Stripe) adds plan names for the active tenant to `principal.plans`, so `plan('<lookup_key>')` grantees apply without a hand-written `subject` step; seats on a membership (`Membership.entitlements`) match the same grantee inside the active tenant only ([Supabase token hook](/docs/adapters/supabase-hook)).

Flags (Vercel Flags SDK, PostHog, LaunchDarkly) and billing entitlements (Stripe Entitlements, a `plan` column) answer "is this capability on for this subject", and it is tempting to make them grants. They are inputs to the subject instead. Resolve them in the policy's `subject` function (or `context` when they need a call) and let them select roles; the grants stay attached to roles:

```ts
const pro = role("pro", [
  allow(permissions.report.export, { where: { ownerId: principal.id } }),
]);
const newCheckout = role("new-checkout", [
  allow(permissions.billing.invoice.pay, { where: { orgId: principal.orgId } }),
]);

export const policy = definePolicy(permissions, {
  roles: [member, pro, newCheckout],
  subject: (user) =>
    user && {
      id: user.id,
      orgId: user.orgId,
      roles: [
        ...user.roles,
        ...(user.plan === "pro" || user.plan === "team" ? ["pro"] : []),
        ...(user.flags.newCheckout ? ["new-checkout"] : []),
      ],
    },
});
```

The same rule decides which roles a tenant may *assign*: a plan that lacks "Billing Manager" is expressed as `RoleSource.assignable(tenant)` returning a smaller set, never as a grant ([tenancy](/docs/concepts/tenancy), custom roles).

Three reasons to keep the split. The policy stays the single statement of who may do what; a flag flipping in a dashboard can only select a role the policy already declares, never widen a grant. Roles reach the snapshot, the SQL `where` and generated RLS the same way as any subject field, so the UI and the database agree, and `permdock usage` can show which grants a flag unlocks. And a flag is never a security control: a `denied` decision cannot be turned into `granted` by a flag, only a matching `allow` can, and `deny` still overrides. A flag evaluated asynchronously belongs in `context` with the role selection done there; the result is the same frozen subject. Provider permission arrays (WorkOS `permissions`, Kinde `permissions`, Auth0 `permissions`) and billing claims (Clerk Billing `fea`, Frontegg entitlements) follow the same rule as `delegation.scopes`, `context` or roles ([authentication](/docs/concepts/authentication), provider recipes; [Clerk provider](/docs/adapters/clerk)). No flag SDK gets an adapter; the vendor-neutral way to read a flag is [OpenFeature](https://openfeature.dev), the CNCF incubating standard whose JavaScript SDK evaluates `getBooleanValue(flag, default, evaluationContext)` against whichever provider is installed (LaunchDarkly, PostHog, Statsig, Unleash, Flagsmith, GrowthBook, Vercel Flags through community providers). A `subject` or `context` function that calls OpenFeature with the subject's id and organisation as the evaluation context works unchanged when the flag vendor changes, and the vendors named here are examples, not a list PermDock maintains.

## Limits [#limits]

`limit: { count, per }` is a quota grant. It is non-portable (it cannot appear as an evaluable snapshot grant or an RLS policy). Pass `limits: memoryLimitStore()` (or your store) to `createPermDock`. `can`, `filter`, `simulate` and the `alternatives` of a denial never consume; they peek `remaining`. `decide` and `assert` consume, except when the matched grant is `approval: 'human'`. Exhausted remaining is `denied` with reason `limit`. A missing store, a throw, or a thenable from `consume` / `remaining` is `limit-unavailable`. A Promise is never treated as granted. The [OWASP Agentic Top 10](/docs/security/owasp-agentic) asks for a maximum rate per tool, which is the use case. See [extension interfaces](/docs/concepts/extension-interfaces).

```ts
allow(permissions.report.export, {
  limit: { count: 100, per: "day", mode: "soft", alertAt: 0.8 },
});
```

* `mode` is `'hard'` (the default, the behaviour above) or `'soft'`. A soft limit grants past `count` and adds the obligation `{ kind: 'over-limit' }`, so the app can bill the overage, warn, or queue the work. The store still counts only up to `count`.
* `alertAt` is a fraction of `count`, above `0` and at most `1`. Once this call brings usage to that fraction, the decision carries `{ kind: 'near-limit' }`. An `over-limit` call carries only `over-limit`.
* A granted decision under a limit carries `quota: { remaining, resetsAt }`: what is left once this call counts, and the Unix second the window ends. `can`, `filter` and `simulate` report what the call would leave. `approval-required` and `denied` never carry `quota`.
* A `limit` denial carries `detail: { count, window, resetsAt }` (`LimitDetail`): the grant's count, its window in seconds and the Unix second it resets. HTTP adapters render it as `429` with `Retry-After` and the `RateLimit` / `RateLimit-Policy` fields ([RateLimit header fields](/docs/standards/ratelimit-headers)); `limit-unavailable` renders as `503`.
* A soft limit is still fail-closed: no store, a throw or a thenable denies with `limit-unavailable`, exactly as in hard mode.
* An unknown `mode`, or an `alertAt` outside that range, throws when the grant is defined.

### Why [#why-2]

Quotas in SaaS plans are often soft: the customer keeps working past the included amount and pays for the overage, or gets a warning first. Modelling that as a second allow without a limit would grant silently, and the app could not tell an included call from an overage. An obligation on a `granted` decision keeps the three outcomes and says what the caller owes. The remaining count is already known to the `LimitStore`, so returning it costs nothing and lets an API send rate-limit headers without a second store read. The snapshot leaves it out because it changes on every call. Soft mode is not fail-open: it only changes what happens past the count, never what happens when the count cannot be read.

A denial's `alternatives` peek rather than consume because they are advice, not a call: a client that is refused `report.delete` would otherwise spend its `report.export` quota just by asking.

## Field-level grants [#field-level-grants]

`allow` and `deny` accept `fields`, a list of keys of the resource's schema output, so `allow(permissions.post.read, { fields: ['title', 'body'] })` is a type error when `title` is not on the schema. An omitted list means every field. An empty list never matches, and forbidden keys (`__proto__`, `constructor`, `prototype`) are dropped; if that empties the list, the grant never matches.

* A field-restricted allow still grants the row, so `can(permission, row)` is `true` and `filter` keeps the record.
* `can(permission, row, { field: 'title' })` checks one key: an allow or deny matches when it has no list or its list includes the field. Deny still overrides allow per field, and a field-only deny does not deny the row.
* `permdock.pick(permission, row)` returns the own keys the subject may read; a denied row yields an empty object. Redact with `pick` after `filter`.
* Collection grants take no `fields`, and there are no `*` or `**` patterns.

`fields` is plain JSON on the grant and in the snapshot, so a client can redact without asking the server. `where` stays row-level, and so does generated RLS by default. `permdock rls generate --fields views` adds a `security_invoker` view `<table>_visible` whose restricted columns are `case when <permitted> then col end`, the database form of `pick`; `--revoke-columns` also closes those columns on the table itself, so a client that skips the view cannot read them ([field security](/docs/adapters/rls#field-security)). Postgres column privileges were rejected for this: they belong to a database role, not to a row or a tenant, so they cannot say that finance reads `amount` only in its own organization. A permission per field (`post.read.title`) was rejected because it multiplies catalog and RLS keys and `post.read` would stop meaning the row; CASL-style globs were rejected because they are string keys that can grant fields nobody listed, when the schema already names them.

## definePolicy options [#definepolicy-options]

| Option | Type | Notes |
| --- | --- | --- |
| `roles` | `RoleBinding[]` | Optional when `grants` is set. Fragments with the same name merge; if one allows and another denies the same permission, the deny wins as it does everywhere. Scoped roles (`on`) and global roles mix freely. |
| `grants` | `Grant[]` | Top-level grants. Each entry needs a real `to:` selector. |
| `scopes` | `{ tenant?: { key }, team?: { key } }` | Required when any role is scoped to `'tenant'` or `'team'`. Names the field on every scoped resource that holds the tenant or team id; checked against each resource schema ([tenancy](/docs/concepts/tenancy)). |
| `principal` | `(user) => Principal \| null` | Required (`subject` is accepted as an alias). Returns the values referenced as `principal.<field>` in conditions, plus `roles`, `plans`, `memberships` and `tenant`. `null` means anonymous. Any `subjectFrom*` provider satisfies it. |
| `context` | `async (user) => Record<string, unknown>` | Optional. Loads relations (org settings, flags, legacy team id arrays) once per `createPermDock`; referenced as `context.<key>`. Declaring it makes `createPermDock` async. A thrown or rejected loader is fail-closed: empty context and an `on('auth')` event with `reason: 'source-threw'`. Team and resource memberships belong in `memberships`, not here, so scoped roles and `memberOf` can use them. |
| `validate` | `'boundary'`, `'always'` or `'never'` | Default `'boundary'`. Controls when resource schemas run; see [validation](/docs/concepts/validation). |
| `onDenied` | `(decision) => never \| void` | Optional default unauthorized handler for `assert`; runs last in the layered chain. |
| `fresh` | `(Permission \| PermissionTree)[]` | Optional. Sensitive permissions that deny with `stale-credentials` when the subject's memberships come from a token behind the `MembershipSource` authorization version ([Supabase token hook](/docs/adapters/supabase-hook)). Live memberships are never stale. |
| `delegations` | `{ from, to, permissions, validFrom?, validUntil? }[]` | Optional. Standing delegations: holders of `from` (a role, `authenticated()`, a plan, `assurance()`) let actors matching `to` (`actor('eve')`, a kind, or `{ kind, id }`) use `permissions` for them without a token saying so. A ceiling, never a grant: the principal's grants still decide, and a token delegation on the call must also cover ([delegation](/docs/security/delegation#policy-delegations)). Part of the fingerprint and the catalog. |
| `x` | `{ grant?, membership? }` | Optional Standard Schemas for app data. `grant` checks every grant's `meta.x` at `definePolicy` and throws on invalid data. `membership` checks `Membership.x` as each subject resolves; an invalid `x` is dropped, never the membership, with an `on('auth')` event of reason `schema` ([extend PermDock](/docs/guides/extending)). |

The `principal` function is the only place PermDock touches your user object. It is called once per `createPermDock` and its return value is frozen. Anything not returned from it is invisible to conditions, which is deliberate: conditions can only reference values that also exist in the snapshot and in the SQL session.

## Closures [#closures]

A closure grant is `(data, ctx) => boolean`, where `ctx` contains `subject`, `actor`, `delegation` and the loaded `context`. A thenable returned by a closure is a `closure-error` denial. Closures are branded as non-portable at the type level:

* `permdock.where(permission)` returns a condition that excludes closure grants and marks the result `partial`.
* `permdock.snapshot()` serialises the grant as `{ portable: false }` so the client knows to ask the decision endpoint.
* `permdock rls generate` reports the grant as not generated.

Use a closure when the check needs something a portable condition cannot express (a call to another service, a computed value). Prefer loading the data in `context` and writing a portable `where` when you can, so the UI, the query layer and the database all agree.

## Testing a policy [#testing-a-policy]

`describePolicy` from `permdock/testing` asserts a matrix of subjects by permissions, with the outcome for representative fixtures:

```ts
import { describePolicy } from "permdock/testing";

describePolicy(policy, {
  subjects: {
    member: { id: "u1", roles: ["member"] },
    admin: { id: "u2", roles: ["admin"] },
  },
  fixtures: { ownPost, otherPost },
  matrix: {
    [permissions.post.update.key]: {
      ownPost: { member: "granted", admin: "granted" },
      otherPost: { member: "denied", admin: "granted" },
    },
  },
});
```

Every permission must have a row, so a new permission cannot ship untested, and a change in who can do what shows up in review as a changed cell. [Testing](/docs/adapters/testing) lists the options.

## Hostable permissions [#hostable-permissions]

An application can opt specific permissions into grants authored in PermDock Cloud without a deploy. `hostable` lists the subtrees or leaves hosted grants may touch; the default is none, so a policy without `hostable` ignores every hosted grant.

```ts
export const policy = definePolicy(vocabulary, {
  roles: [member, admin],
  hostable: [permissions.invoice, permissions.auditLog.read],
});

const permdock = await createPermDock(policy, user, {
  policies: permdockCloud.policies,
});
```

Hosted grants arrive as a [`PolicyDocument`](/docs/concepts/wire-formats) through a [`PolicySource`](/docs/concepts/extension-interfaces) whose `current()` is read once by `createPermDock`, and merge under rules that keep code in charge:

* Allows OR together and any matching deny wins, so a code deny always beats a hosted allow. A hosted grant may be a deny.
* A hosted allow on a permission a code grant guards with `approval` must carry an approval at least as strict (the same `by`, and no `distinct: false` unless the code grant sets it too), or it is dropped as `weaker-approval`. Hosted grants can add friction, never remove it.
* The grantee is a declared role, plan or relation on the permission's resource. `anyone()`, `authenticated()`, `actor()` and `assurance()` are code-only selectors; a hosted grant naming them, or an undeclared name, is dropped as `unknown-grantee`.
* Only portable conditions are accepted: no closures, no `opaque`, no `sqlFunction`, no path through `__proto__`, `constructor` or `prototype` (`non-portable`).
* A grant on a permission that is not `hostable` (`not-hostable`), on an unknown key (`unknown-permission`) or with a malformed shape (`invalid`) is dropped too. Each drop is a `hosted-grant-dropped` value on `on('error')` carrying the document fingerprint, the grant id and the reason; it never makes `can()` throw.
* A decision a hosted grant matched carries `matched.hosted` with the document `fingerprint` and the grant `id`, and so does its decision event. The merged instance's `fingerprint` hashes the code fingerprint with the document's, so an approval token issued under one document does not resume under another.
* `permdock rls`, `permdock openapi` and `permdock collect` keep reading the code policy. A permission compiled into generated RLS should not be `hostable`, because the database never sees the hosted grant; `permdock doctor` reports it as `PD020`.
* A snapshot built from an instance reflects the hosted grants that instance merged, because it is the instance's computed answer. A `SnapshotSource` never delivers hosted grants of its own.

The opt-in exists because a hosted grant is an allow no code reviewer saw. Listing `hostable` permissions in code bounds what a Cloud admin, or an attacker holding a Cloud admin session, can widen.

## Protected queries [#protected-queries]

Authorizing a data fetch takes the three instance methods you already have, with no wrapper API: `where` narrows the query before it runs, `filter` drops rows a closure or a non-portable grant refuses, and `pick` redacts fields.

```ts
import { toWhere } from "permdock/drizzle";

export async function listPosts(permdock: PermDock) {
  const read = permissions.post.read;
  const rows = await db
    .select()
    .from(posts)
    .where(toWhere(permdock.where(read), posts)); // before: the database only returns candidate rows
  return permdock.filter(read, rows).map((row) => permdock.pick(read, row)); // after: closures and fields
}
```

For a single record, load it and call `assert` (or `protect` in an HTTP adapter) before returning it.

### Field-level responses [#field-level-responses]

Returning a whole row after an allowed read leaks every column the grant's `fields` leave out (OWASP API3, broken object property level authorization). Pass the response through `pick`, and check each field a write body sets against the row it changes:

```ts
const loadInvoice = (c: Context) => invoices.find(c.req.param("id"));

app.get(
  "/invoices/:id",
  protect(permissions.invoice.read, loadInvoice),
  (c) =>
    c.json(
      c.get("permdock").pick(permissions.invoice.read, c.get("permdockData")),
    ), // only the fields the grant lists
);

app.patch(
  "/invoices/:id",
  protect(permissions.invoice.update, loadInvoice),
  async (c) => {
    const permdock = c.get("permdock");
    const row = c.get("permdockData");
    const changes: Partial<Invoice> = await c.req.json();
    const refused = Object.keys(changes).filter(
      (field) =>
        !permdock.can(permissions.invoice.update, row, {
          field: field as keyof Invoice,
        }),
    );
    if (refused.length > 0) return c.json({ refused }, 403);
    return c.json(
      permdock.pick(
        permissions.invoice.read,
        await invoices.update(row.id, changes),
      ),
    );
  },
);
```

`pick` returns only own keys the grant lists, so a column added to the table later stays out of the response until a grant names it. The write check runs against the stored row, not the body, so the grant's `where` sees the real owner. `permdock rls generate --fields views` gives a direct database read the same answer as `pick` ([field security](/docs/adapters/rls#field-security)).

## Design rationale [#design-rationale]

**No protected-query helper.** A `protect(queryFn, { before, after })` wrapper, as Kilpi's `$query` offers, was considered and rejected. `where` already runs before the fetch and reaches the database, which a post-fetch hook cannot; `filter` and `pick` already cover the after step. A wrapper would add a fourth way to say the same thing and hide which of the three steps ran, so it is the recipe under Protected queries instead.

**Policy as data.** One check has to give the same answer in the browser, in an array filter, in an ORM `where`, in a Postgres RLS policy and in an audit log. Closures can serve only the first two: they cannot be hydrated to a client, compiled to SQL or read by a reviewer or an agent. So roles are arrays of grants, grants are data, and conditions are a small JSON AST with one interpreter per target (the in-memory evaluator, `toWhere` for Drizzle, Prisma and Kysely, and the RLS generator). The same JSON appears in the snapshot, the catalog and the AuthZEN `context`. The operator set is deliberately small because every compiler must implement every operator. A closure is still available when a check needs something the portable subset cannot express, but it is branded non-portable and the type system marks it server-only. MongoDB-style query syntax carries operators that do not compile to RLS, and a policy language such as Cedar, Rego or Polar is a second language with no inference over resource fields.

**Grantees, not only roles.** A grant names who it applies to with `to:`, not only a role name, because plans, ownership, assurance and agent-versus-human are different dimensions. Folding them into roles produced fake roles for each. An array selector is an intersection (`to: [roles.member, plans.pro]` requires both), while separate `allow` grants still OR together. Relations are declared on the resource node and compile to portable `where` conditions, so relation grants reach `filter`, `where` and RLS without a graph store; `relation(resource, name, { through: 'parent', depth })` walks a parent chain through a `RelationSource` in process and a closure table in RLS ([relationships](/docs/concepts/relationships)). `anyone()` is the only selector that matches a `null` principal; there is no magic `anonymous` role. A Cedar-style `permit` builder and a Zanzibar tuple store were both rejected: the first is a DSL, the second leaves the subset the database can enforce, which parent chains over a closure table stay inside. For the same reason there is no fluent policy builder, in core or in a companion package.
