# Wire formats

Source: https://permdock.com/docs/concepts/wire-formats

The JSON shapes PermDock reads and writes, permission leaves, conditions, snapshots, memberships and custom roles, AuthZEN messages, the catalog, Decisions and Problem Details, with an example of each.

Everything PermDock puts on a wire, in a file or in a log is plain JSON with a documented shape. No superjson, no class instances, no private compact encodings. This page is the reference for those shapes; the concept pages explain the semantics.

## Permission leaf [#permission-leaf]

`JSON.stringify(permissions.post.update)`. The schema is not included; it lives on the resource node. See [permissions](/docs/concepts/permissions).

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

`key` is the identity. A leaf that comes back from `JSON.parse` resolves to the same grant as the original. Collection actions look identical; arity is a property of the resource definition, visible in the catalog as `"arity": "collection"`. `meta.x` holds the application's own data: plain JSON, at most eight levels deep, checked by the `definePermissions(…, { x: { permission } })` schema when one is given. PermDock carries it and never reads it ([extend PermDock](/docs/guides/extending)).

## Condition [#condition]

The normalised form of `{ where: { authorId: principal.id, teamId: { in: context.teamIds } } }`. See [conditions](/docs/concepts/conditions).

```json
{
  "op": "and",
  "conditions": [
    { "op": "eq", "field": "authorId", "value": { "ref": "principal.id" } },
    { "op": "in", "field": "teamId", "value": { "ref": "context.teamIds" } }
  ]
}
```

| Node | Shape |
| --- | --- |
| Comparison | `{ "op": "eq" \| "ne" \| "gt" \| "gte" \| "lt" \| "lte" \| "contains", "field": string, "value": Value }` |
| Membership | `{ "op": "in" \| "notIn", "field": string, "value": Value[] \| Ref }` |
| Null test | `{ "op": "isNull", "field": string, "value": boolean }` |
| Compound | `{ "op": "and" \| "or", "conditions": Condition[] }`, `{ "op": "not", "condition": Condition }` |
| Reference | `{ "ref": "principal.<field>" }` or `{ "ref": "context.<key>" }`. No other prefix resolves. |
| Date | `{ "date": "2026-09-06T10:15:00Z" }` |
| Scope (from scoped roles) | `{ "op": "memberOf", "scope": string, "field": string, "roles": string[], "resource"?: string, "parents"?: (string \| { "field": string, "resource": string })[] }`. `scope` is a declared scope name (or the `tenant` / `team` alias) or `"resource"`. A keyed parent matches only a membership on that resource; a bare field name matches any ([tenancy](/docs/concepts/tenancy)) |
| SQL function | `{ "op": "sqlFunction", "name": string, "args": array, "twin": Condition }`. Each arg is a value or `{ "field": "id" }`. |
| Graph (from relation grants) | `{ "op": "related", "resource": string, "relation": string, "field": string, "depth": integer, "parent"?: true, "restricted"?: string, "restrictedAncestors"?: object, "passRestricted"?: true }`: the subject holds `relation` on the `resource` instance the row's `field` names (its parent when `parent` is set), or on an ancestor within `depth` hops. Produced from a `relation(..., { through: 'parent' })` or edge-table grantee; it appears in an in-process `Decision.matched.where`, never in a snapshot, a policy document or a hosted grant ([relationships](/docs/concepts/relationships)) |
| Live session | `{ "op": "liveSession" }`: the Auth server still holds the subject's session ([live sessions](/docs/concepts/conditions#live-sessions)). Bound to a constant in a snapshot |
| Opaque (imported) | `{ "op": "opaque", "sql": "...", "fingerprint": "sha256:..." }` |

Literals are JSON literals. Single-child compounds are collapsed and nested same-operator compounds are flattened when the grant is defined, so consumers see a canonical tree. Field names `op`, `field`, `value`, `ref` and `date` are final.

## Snapshot [#snapshot]

Output of `permdock.snapshot({ include: [permissions.post] })`. See [snapshots](/docs/concepts/snapshots). The TypeScript type is `Snapshot`; `v` is the format major, and `parseSnapshot` accepts only major 1. The subject carries `principal.memberships` and `principal.tenant`; each grant carries its grantee union `to`, a `scope` and a `membership`; the top level carries `tenants`, `simulated` and `vocabulary.roles` / `vocabulary.plans`. The optional `scopes` field lists the policy's [named scopes](/docs/concepts/scopes) in declaration order, each `{ name, key, within?, resources?, fields? }`, where `resources` names the resources whose `memberOf` relations partition rows by that key (absent when the policy declares no scopes) and `fields` maps a resource to the field that holds the scope's id when it is not `key`, such as `{ "organization": "id" }` for a table whose own id is the instance. A reader without `fields` support checks `key` on those rows, finds none, and denies. A client checks the row against each grant's `membership` along the scope's chain, so a snapshot client never grants what the server denies. The principal carries no `claims`: a grant's `where` or `check` that reads `principal.claims.*` (or `principal.claim.*`) holds the subject's value as a literal instead of the `ref`, bound when the snapshot is built (a missing claim binds to `null`, or `[]` for `in` / `notIn`, as in `where()`).

```json
{
  "v": 1,
  "issuedAt": 1788999900,
  "subject": {
    "principal": {
      "id": "u_1",
      "roles": [],
      "plans": ["pro"],
      "tenant": "o_1",
      "memberships": [
        { "scope": "tenant", "id": "o_1", "roles": ["member"] },
        {
          "scope": "team",
          "id": "t_design",
          "within": { "tenant": "o_1" },
          "roles": ["lead"],
          "via": "group:9f2c"
        }
      ]
    },
    "delegation": { "scopes": ["post:read", "post:update"] },
    "context": {}
  },
  "roles": ["member", "lead"],
  "vocabulary": {
    "roles": {
      "member": { "key": "member", "assignable": true },
      "lead": { "key": "lead", "on": "team", "assignable": true }
    },
    "plans": { "pro": { "key": "pro" } }
  },
  "scopes": [
    { "name": "tenant", "key": "orgId", "resources": ["post"] },
    { "name": "team", "key": "teamId", "within": "tenant" }
  ],
  "grants": [
    {
      "permission": "post.read",
      "effect": "allow",
      "role": "member",
      "scope": "tenant",
      "to": { "kind": "role", "role": "member", "scope": "tenant" },
      "membership": { "scope": "tenant", "id": "o_1", "roles": ["member"] }
    },
    {
      "permission": "post.update",
      "effect": "allow",
      "role": null,
      "to": { "kind": "relation", "resource": "post", "relation": "author" },
      "where": {
        "op": "eq",
        "field": "authorId",
        "value": { "ref": "principal.id" }
      }
    },
    {
      "permission": "post.delete",
      "effect": "allow",
      "role": "member",
      "scope": "tenant",
      "approval": "human",
      "to": { "kind": "role", "role": "member", "scope": "tenant" }
    }
  ],
  "tenants": ["o_1"],
  "include": ["post"],
  "assignable": [
    {
      "tenant": "o_1",
      "roles": ["member"],
      "permissions": [
        {
          "key": "post.read",
          "resource": "post",
          "action": "read",
          "scope": "post:read",
          "meta": {}
        }
      ]
    }
  ]
}
```

A grant entry has `permission`, `effect` (`allow` or `deny`), `to` (the [policies](/docs/concepts/policies) grantee), optional `role` (string or `null` for non-role selectors), optional `where`, `check`, `approval`, `fields` (schema keys the grant covers; omitted means every field), `validity` (`{ from?, until? }` in Unix seconds when the grant has a `validFrom` or `validUntil`; the client evaluator checks it against its clock), `scope` (a scope name or a resource object) with the `membership` that supplied the role, and `portable: false` when the server-side grant is a closure or opaque, needs the relation graph (a `through: 'parent'` or edge-table relation), or reads a relation `period`; such an entry carries no `where` or `check`, so the client sends the check to the decision endpoint. A snapshot has one entry per membership of the grant's scope that holds its roles (no cascade between scopes), and a client denies when a `portable: false` deny applies. A resolved [custom-role](/docs/concepts/custom-roles) grant is an ordinary entry whose `role` is the custom role name, whose `to` is that role in its scope, and whose `membership` is the membership holding it. `roles` is ranked by the policy's `assigns` graph when it has one, and `audiences` lists the distinct `meta.audience` values of those roles in the same order (absent when there are none). `assignable` has one `{ tenant, roles, permissions, levels? }` entry per tenant in `tenants`: the declared role names and the permission leaves the subject may hand out there, with `permissions` trimmed by `include`, and `levels` mapping a permission key to the [levels](/docs/concepts/custom-roles#levels) the subject may hand out (absent when no resource declares levels); it is absent when `tenants` is empty. `notEntitled` lists `{ permission, role, to }` for each allow grant whose roles the subject holds and whose `plan` grantee it lacks, one per permission and role; it grants nothing and is absent when empty. `tenants` lists the tenants whose grants are included; `simulated: true` marks a preview snapshot the decision endpoint must refuse. `parseSnapshot(json)` is the public reader: it rejects an unknown major `v` and any object key in the forbidden set (`__proto__`, `constructor`, `prototype`). Readers must reject a `v` they do not know.

An optional top-level `ids` maps a resource to its row id field when the resource's `id` option is not `id`. It was added within `v: 1`; a reader that predates it reads `id`, which matches no row of such a resource, so it denies.

Three optional grant fields were added within `v: 1`. They carry app data and change no outcome. `name` is the grant's declared name. `meta` is its `{ description?, x? }`. `obligations` lists the `{ kind: "app", name, detail? }` obligations an allow declares, and the client evaluator emits them on a granted decision as the server does. A membership in `principal.memberships` may carry `x`, and `subject.context` carries what an adapter's `context` hook returned. All of these are visible to the client that holds the snapshot, so they must never hold a secret ([extend PermDock](/docs/guides/extending)). A reader that predates them ignores them.

```json
{
  "permission": "invoice.pay",
  "effect": "allow",
  "role": "clerk",
  "to": { "kind": "role", "role": "clerk", "scope": "tenant" },
  "name": "clerk-pays",
  "meta": { "description": "Clerks pay invoices", "x": { "ticket": "FIN-1" } },
  "obligations": [{ "kind": "app", "name": "watermark" }]
}
```

## Membership and custom role [#membership-and-custom-role]

Carried inside the principal and exchanged with `MembershipSource` and `RoleSource` implementations ([tenancy](/docs/concepts/tenancy)). PermDock publishes both as Standard JSON Schema for validating edits at a boundary.

```json
{
  "scope": "customer",
  "id": "c_7",
  "within": { "organization": "o_1" },
  "roles": ["contact"],
  "via": "contact",
  "expiresAt": 1789000000
}
```

```json
{
  "scope": "organization",
  "id": "o_1",
  "roles": ["member"],
  "via": "group:g_eng",
  "managedBy": "idp",
  "entitlements": ["dev-mode"]
}
```

```json
{ "on": { "resource": "document", "id": "d_9" }, "roles": ["editor"] }
```

```json
{
  "tenant": "o_1",
  "name": "billing-manager",
  "includes": ["billing-viewer"],
  "grants": [
    { "permission": "invoice.pay" },
    { "permission": "invoice.refund", "effect": "deny" }
  ],
  "meta": { "title": "Billing Manager" }
}
```

A membership names one scope instance (`scope`, `id`, and in `within` the id of every ancestor scope) or one resource (`on`). Readers also accept the input form `{ tenant, team? }` for the first and second scope and normalise it; everything PermDock writes (snapshots, claims, events) uses the canonical form. The same shape is the `memberships` claim RLS reads in `jwt` mode, with a membership's custom roles as an optional compact `grants` map and platform custom roles in the top-level `role_grants` claim, keyed by role name ([custom roles](/docs/concepts/custom-roles)). A custom role has `tenant` (the owning instance of the first scope), `name`, optional `scope` (the scope it is held at, default the first, or `global` for a platform role, which has no `tenant`, `team` or `id`) and `id` (one instance of it), `includes` (declared role names) and `grants` (declared permission keys with `effect` `allow`, the default, or `deny`, an optional `level` on an allow, and no other field). In the compact `grants` claim a leveled allow is `key@level`. Both are bounded by the ceiling of assignable declared roles in the role's scope; entries outside it are dropped, never widened ([custom roles](/docs/concepts/custom-roles)).

`x` on a membership is the application's own data about it (a department, a seat label), plain JSON from a trusted membership source and checked by `definePolicy(…, { x: { membership } })`. An invalid `x` is dropped with an `on('auth')` event of reason `schema`; the membership stays. Conditions and RLS never read `x`, and the token hook never writes it into claims. A custom role's `meta` takes the same `x`, checked by the role tree's `defineRoles(…, { x })` schema, and lands on the role leaf `assignableRoles()` returns.

`via` is the membership kind (`staff`, `contact`, `group:<id>`) and `expiresAt` its expiry in seconds since the epoch; both round-trip through the claim, `subjectFromSupabase` and the snapshot, so a client can show "invited, expires in 3 days". `managedBy: "idp"` marks a membership the identity provider owns (the application must not edit it), `entitlements` lists the seats it holds (`plan()` grantees match a seat only inside the active tenant), and `member: { group }` names the subgroup a membership source's `group` fills, a non-empty string that round-trips through `subjectFromSupabase`. `keep` appears only on a membership whose instance or an ancestor instance is suspended and whose scope keeps permissions (`suspension.scopes.<scope>.keep`): a sorted list of the permission keys it still grants. Every other grant ignores such a membership, allow or deny, and so do role listings; a `keep` that is not a list of non-empty strings drops the membership, and an empty list grants nothing. An `act` claim is the RFC 8693 actor chain: its `sub` is the current actor and each nested `act` a prior one, and every level needs a non-empty `sub`. The Supabase token hook adds three top-level claims next to `memberships`: `memberships_truncated: true` when the size budget cut the list, `authz_ver` (the principal's authorization version, an integer) and `attrs` (allow-listed server-owned columns and `app_metadata` keys, which attribute conditions read as `principal.claims.attrs.<key>`; `user_metadata` never). The budget covers `attrs` and `memberships` together, and `memberships_truncated` also marks `attrs` dropped for size. `subjectFromSupabase` maps the first two to `principal.membershipsTruncated` and `principal.authzVersion`; `attrs` stays under `principal.claims` ([Supabase token hook](/docs/adapters/supabase-hook)).

## Decision [#decision]

The return value of `decide`, and the payload inside AuthZEN `context` and Problem Details. See [decisions](/docs/concepts/decisions).

```json
{
  "outcome": "granted",
  "matched": {
    "role": "member",
    "permission": "post.update",
    "to": { "kind": "role", "role": "member", "scope": "global" }
  },
  "token": "pd1.…",
  "subject": { "principal": { "id": "u_1", "roles": ["member"] } }
}
```

```json
{
  "outcome": "granted",
  "matched": { "role": "member", "permission": "report.export" },
  "token": "pd1.…",
  "subject": { "principal": { "id": "u_1", "roles": ["member"] } },
  "quota": { "remaining": 0, "resetsAt": 1789002000 },
  "obligations": [{ "kind": "over-limit" }]
}
```

```json
{
  "outcome": "granted",
  "matched": {
    "role": "clerk",
    "permission": "invoice.pay",
    "name": "clerk-pays",
    "meta": { "x": { "ticket": "FIN-1" } }
  },
  "token": "pd1.…",
  "subject": { "principal": { "id": "u_1", "roles": ["clerk"] } },
  "obligations": [
    { "kind": "app", "name": "mfa-reprompt", "detail": { "maxAge": 300 } }
  ]
}
```

```json
{
  "outcome": "denied",
  "permission": "post.update",
  "denials": [
    { "role": "member", "reason": "condition" },
    { "role": null, "reason": "not-delegated" }
  ],
  "alternatives": ["post.read"]
}
```

```json
{
  "outcome": "approval-required",
  "grant": { "role": "member", "permission": "post.delete" },
  "reason": "human",
  "token": "pd1.…"
}
```

In JSON, `alternatives` is an array of permission keys; in memory it is an array of leaves. Remote PDP denials use `pdp-denied`, `pdp-unavailable` or `pdp-invalid-response`. A remote grant sets `matched.provider` to `pdp`. Quota denials use `limit` (exhausted) or `limit-unavailable` (no store, throw, or thenable); a `limit` denial's `detail` is `{ count, window, resetsAt }`, the grant's count, its window in seconds and the Unix second it resets. A `granted` decision whose matched allow has a `limit` carries `quota`: `remaining` is what is left once this call counts (for `can`, `filter` and `simulate`, which only peek, what it would leave), and `resetsAt` is the Unix second the window ends. It may also carry `obligations`, an array of `{ kind }` objects the caller owes alongside the action: `over-limit` when a `mode: 'soft'` limit granted past its count (`remaining` is then `0`), and `near-limit` when usage reached the limit's `alertAt` fraction. Both fields are absent otherwise, and only `granted` carries them. Snapshots never carry `quota`, because it is live store state. An Arazzo hole uses `undocumented` (missing operation or no `x-permdock-permissions`) or `unsupported` (AsyncAPI source). A refused API key creation (`decideCredential`) uses `exceeds-creator` or `credential-policy`, with `detail` naming the offending role, permission, tenant or rule ([API keys](/docs/concepts/credentials)). A `deny` denial from a named deny grant carries `detail: { name }`, and an `inactive-grant` denial carries the allow's window, `{ from?, until? }` in Unix seconds; the `DenialDetails` type maps each of these reasons to its detail. `matched` and `grant` carry the grant's `name` and `meta` when it has them. An allow's app obligations appear in `obligations` as `{ kind: "app", name, detail? }`: `name` is lower case letters, digits, `_` and `-`, starting with a letter, and `detail` is plain JSON. PermDock never acts on them ([decisions](/docs/concepts/decisions#obligations)). A `denied` decision carries `permission`, the key that was checked, so a fallback can look up the leaf and its `meta`; it is absent when the check named no declared permission. A decision from `explain` (or `decide` with `explain: true`) adds `trace`, `{ evaluated, allows, denies, skipped }` ([decisions](/docs/concepts/decisions#explain)); `trace` never appears on a decision event, in Problem Details or in an AuthZEN response.

## Decision event [#decision-event]

Emitted by `on('decision')`. See [audit and observability](/docs/concepts/audit-and-observability).

```json
{
  "type": "decision",
  "at": "2026-09-06T10:15:00Z",
  "outcome": "denied",
  "permission": "post.delete",
  "scope": "post:delete",
  "resource": { "type": "post", "id": "42" },
  "subject": {
    "principal": { "id": "u_1", "roles": [], "tenant": "o_1" },
    "actor": { "id": "mcp-client-7", "kind": "mcp-client" },
    "delegation": { "scopes": ["post:read", "post:update"] }
  },
  "tenant": "o_1",
  "membership": { "tenant": "o_1", "roles": ["member"] },
  "via": null,
  "denials": [{ "role": null, "reason": "not-delegated" }],
  "alternatives": ["post.read", "post.update"],
  "trusted": false,
  "source": "adapter",
  "adapter": "mcp"
}
```

`matched` carries the grant's `name` and `meta` when it has them, and a granted event carries the decision's `obligations` when there are any. `subject.credential` is `{ id, kind }` when the subject came from an API key ([API keys](/docs/concepts/credentials)), and absent otherwise. `tenant` is the active tenant, `membership` the entry that supplied the matched role (absent for a global role) and `via` its inheritance path (`group:<id>`, `team:<id>`, `credential`) when the provider recorded one; a tenant-scoped audit log is a filter on `tenant` ([audit and observability](/docs/concepts/audit-and-observability)). A membership also carries `grantedBy` (who wrote it) and `reason` (why), and a break-glass decision adds `matched.breakGlass: true`, `purpose` and `reason` to the event ([elevated access](/docs/concepts/elevated-access)).

Each entry in `denials` is a `WireDenial`, `{ role, reason, to?, detail? }`: `to` is the grantee of the grant that denied (a role, relation or attribute grantee), and `detail` is a JSON value (a string, or `{ count, window, resetsAt }` on `limit`). On `closure-error` and `validation`, `detail` holds what a closure threw or the validation error; it stays in process and never leaves in JSON, so the event, a Problem Details body, an MCP or WebMCP refusal and the decision endpoint drop it. A `detail` that is an `Error` or does not serialize to JSON is dropped the same way. `WireDenial` and `WireDecision` (a `Decision` with those denials) are exported from `permdock`.

Two documented projections leave the event unchanged and exist so `DecisionSink` implementations agree with each other ([audit and observability](/docs/concepts/audit-and-observability)):

* **OCSF.** `toOcsf(event)` from `permdock` maps a decision or approval event onto the [OCSF](https://schema.ocsf.io/) 1.3.0 Authorize Session class (`class_uid` 3003, category Identity and Access Management, `activity_id` 1, `type_uid` 300301), pinned as `OCSF_VERSION`. `outcome` sets `status_id` (1 `Success` for `granted`, 2 `Failure` for `denied`, 99 `Other` for `approval-required`, 0 `Unknown` for an outcome this build does not know); `status_detail` is the comma-joined `denials[].reason` or `approval-required`; `permission` is the one entry in `privileges`; `subject.principal.id` is `user.uid` and `actor.user.uid` (an anonymous subject is `user: { name: 'anonymous' }`, since the class requires `user`); `subject.actor.id` is `actor.app_name`; `adapter` is `metadata.product.feature.name`; `tenant` is `metadata.tenant_uid`; the approval `token` is `metadata.correlation_uid`. Authorize Session has no resource object, so `scope`, `resource`, `source`, `phase`, `matched.role` and `via` travel under `unmapped`, with `grant` (the matched grant's `name`) and `obligations` (the names of its app obligations) when present, alongside `breakGlass`, `purpose` and `reason` for a break-glass decision (which is raised to high severity). `accessToOcsf(event)` projects a support-access lifecycle event onto the OCSF Account Change class (`class_uid` 3001, `activity_id` 2 Enable and `type_uid` 300102 for `started`, `activity_id` 5 Disable and `type_uid` 300105 for `ended` / `revoked`, high severity). A version bump of the projection is a wire-format change.
* **CSV.** `toCsvRow(event)` from `permdock` writes the columns in `CSV_COLUMNS` (`time`, `principal`, `actor`, `tenant`, `permission`, `outcome`, `matched.role`, `via`, `denials.reason` joined with `;`, `token`), absent values empty and RFC 4180 quoting ([audit and observability](/docs/concepts/audit-and-observability) "Exports").
* **CloudEvents 1.0 envelope.** On the wire, each event is `{ specversion: '1.0', type: '<type>', source: '<service>', subject: '<permission key or resource id>', id, time, datacontenttype: 'application/json', data: <event> }`. PermDock Cloud webhooks, its exports and any queue or HTTP sink that forwards events to another system use it. The `permdock/cloud` sink itself posts the raw events (`{ events: SinkEvent[] }`, unsigned, to `POST /v1/environments/:env/decisions`), and the Cloud wraps each one in this envelope when it stores, exports or delivers it, so `id` and `source` are the Cloud's. `source` is the emitting service (the application's `source` on the event, or the Cloud environment URL); `subject` is the permission key for decision and approval events, the SCIM resource id for directory events, the principal id for membership events, the credential id for credential events and the catalog fingerprint for catalog events.

The CloudEvents `type` values are a closed list, exported as `CLOUD_EVENT_TYPES` from `permdock` with `CatalogEventData` for the catalog payload; adding one is a wire-format change:

| `type` | `data` | Emitted by |
| --- | --- | --- |
| `dev.permdock.decision` | A decision event (above) | Every `DecisionSink` |
| `dev.permdock.approval` | An approval event (`phase: requested` or `phase: resolved`) with the `ApprovalRequest` below | Every `DecisionSink`; the Cloud webhook and paging connectors |
| `dev.permdock.directory` | A `directory` event: the SCIM operation (`User` or `Group`, method, tenant, the affected `id`s, `active` after the change) with no attribute values beyond identifiers | `scimHandler` through its `sink`; the Cloud relay's sync log ([SCIM adapter](/docs/adapters/scim)) |
| `dev.permdock.membership` | A `membership` event: who gained or lost roles, from SCIM, Better Auth, Clerk or the app | `membershipEvent()`, `scimHandler` group changes, `onRoleChange({ sink })` |
| `dev.permdock.credential` | A `credential` event: an API key `created`, `used` (sampled, with `sample`), `rotated` or `revoked` (below) | `credentialEvent()`, `memoryCredentials({ sink })`, `subjectFromApiKey({ sink })` |
| `dev.permdock.catalog` | `{ kind: 'publish' \| 'drift', fingerprint, previous?, findings? }`: a catalog publish (the new fingerprint and the one it replaced) or the drift that publish caused against live hosted grants, with `findings` as `CatalogFinding` objects `{ code, permission, grant? }` (below) | The Cloud on `permdock cloud push` and on scheduled drift checks ([Cloud integrations](/docs/adapters/cloud-integrations)) |
| `dev.permdock.access.started` / `.ended` / `.revoked` | An `access` event: a support-access session began, lapsed or was revoked, with `tenant`, `principal`, `via`, `roles`, `member`, `expiresAt`, `grantedBy`, `reason` and `actor` ([elevated access](/docs/concepts/elevated-access)) | `accessEvent()`; `accessToOcsf` projects it onto OCSF Account Change (`class_uid` 3001) |

Changing either projection is a wire-format change and follows the versioning rule below.

A drift `finding` names why a published catalog broke a hosted grant, which the Cloud then suspends and leaves out of the next policy document. `code` is a closed list, exported as `CatalogFindingCode`: `permission-removed` (the key is gone), `not-hostable` (the key is no longer `hostable`), `grantee-removed` (a role, plan or relation the grant targets is no longer declared) and `approval-tightened` (a code allow now requires an approval the hosted grant does not meet). `permission` is the key and `grant` the hosted grant id when one broke. `parseCloudEvent` rejects any other code and the earlier free-text form.

```json
{
  "kind": "drift",
  "fingerprint": "1jqQ…",
  "previous": "QD-Y…",
  "findings": [
    {
      "code": "not-hostable",
      "permission": "auditLog.read",
      "grant": "hg_01J8…"
    }
  ]
}
```

## Membership event [#membership-event]

A `membership` event records a role change that is not a SCIM resource write. Apps emit it with `membershipEvent()`; SCIM group membership changes and Better Auth `onRoleChange({ sink })` emit it automatically.

```json
{
  "type": "membership",
  "at": "2026-09-06T10:20:31Z",
  "source": "better-auth",
  "operation": "changed",
  "principal": { "id": "u_1" },
  "scope": "team",
  "id": "t_1",
  "within": { "tenant": "o_1" },
  "roles": { "added": ["admin"], "removed": ["member"] },
  "by": { "id": "u_9", "kind": "user" }
}
```

The event names the scope instance the roles are held in, the same way a [membership](#membership-and-custom-role) does. `scope` is a declared scope name (or the `tenant` / `team` alias, which readers resolve against the policy's scopes). `id` is the instance, and `within` holds the id of every ancestor scope. `scope` and `id` appear together or not at all; without them the event records a change to global roles. `expiresAt` (seconds since the epoch) is set when the membership lapses on its own. A contact added to a site three levels down is `{ "scope": "site", "id": "s_1", "within": { "organization": "o_1", "customer": "c_1" } }`. `membershipEvent()` throws a `TypeError` when `scope` comes without `id`, `id` without `scope`, or `within` without either.

`source` is `scim`, `better-auth`, `clerk`, `app`, `cloud` (an assignment in the PermDock Cloud directory, with `by` set to the Cloud admin who made it), or another string. `via` is `group:<id>` when the change came from a group. See [audit and observability](/docs/concepts/audit-and-observability).

## Credential event [#credential-event]

A `credential` event records an API key's lifecycle ([API keys](/docs/concepts/credentials)). It carries identifiers only, never the key or its hash.

```json
{
  "type": "credential",
  "at": "2026-09-29T10:15:00Z",
  "source": "api",
  "operation": "used",
  "credential": { "id": "svc_01J8", "kind": "service" },
  "principal": { "id": "ci-deploy" },
  "tenant": "o_1",
  "expiresAt": 1792592000,
  "sample": 0.1,
  "by": { "id": "u_1", "kind": "user" }
}
```

`operation` is `created`, `used`, `rotated` or `revoked`. `principal` is the owner of a user-bound key or the service principal; `tenant` is a service key's tenant; `by` is who made a change, when the application knows. `sample` is present on `used` events only, a number in `(0, 1]`: the fraction of uses reported, so `1 / sample` estimates the uses each event stands for. `parseCloudEvent` rejects any other `operation`, a `credential.kind` other than `user` or `service`, or a missing `principal`.

## Credential [#credential]

The record an API key stands for. The key itself (`pdk_<id>_<secret><checksum>`) is opaque; the application stores this record with the key's base64url SHA-256 hash and never the key.

```json
{
  "v": 1,
  "id": "svc_01J8",
  "kind": "service",
  "principal": "ci-deploy",
  "tenant": "o_1",
  "roles": ["developer"],
  "permissions": [
    { "permission": "repo.read" },
    { "permission": "repo.write", "ids": ["r_1", "r_2"] }
  ],
  "createdBy": "u_1",
  "createdAt": 1790000000,
  "expiresAt": 1792592000,
  "name": "deploy pipeline"
}
```

`kind` is `user` (the key acts as `principal`, its owner) or `service` (the key is the `service` principal `principal`, holding `roles` in `tenant`); `tenant` and `roles` are required for a service credential. A user credential never carries `roles`; its optional `tenant` holds the key to the owner's memberships inside that instance of the first scope, with no global role. `permissions` holds at least one entry, each a permission key with an optional 1 to 64 resource `ids`. The list has no upper bound: a credential is read from the application's own store through a verifier, never from a token a client holds, so a read-and-write key over every permission of a large catalog is valid. `createdAt` and `expiresAt` are NumericDate seconds; `expiresAt` is absent only where a tenant's settings allow it. `parseCredential` rejects a `v` other than `1` or a wrong type in any field, reads own properties only, and drops unknown fields. The resolver turns `permissions` into `delegation.scopes` (entries without `ids`) and RFC 9396 `authorizationDetails` `{ type: <resource>, actions: [<action>], identifier: <id> }` (one per id).

## Approval request [#approval-request]

Stored by an `ApprovalStore` and exchanged with PermDock Cloud. See the [approvals adapter](/docs/adapters/approvals) and [approval security](/docs/security/approvals). Its JSON Schema is `schemas/approval-request-v1.json` in the `permdock` package; it allows unknown properties and checks timestamps with `pattern`, so pg\_jsonschema can enforce it in a check constraint (`rls.jsonSchema`). Records set `v: 1`; `approvers` is optional. An approver in `by`, a stage or `escalation.to` is a grantee, `{ kind: 'user', id }`, `{ kind: 'permission', permission }` (a `holder()`) or `{ kind: 'any-of', of: [...] }`, whose items are approvers or all-of lists; a stage may carry its own `escalation: { after, to }`. These additions are optional fields of `v: 1`; a reader that does not know a kind treats the approver as matching nobody.

```json
{
  "v": 1,
  "token": "pd1.…",
  "permission": "filing.pay",
  "scope": "filing:pay",
  "resource": { "type": "filing", "id": "42" },
  "subject": {
    "principal": { "id": "u_1", "roles": ["clerk"], "tenant": "o_1" },
    "actor": { "id": "eve:app", "kind": "eve" },
    "session": "sid-1",
    "delegation": { "scopes": ["filing:pay"] }
  },
  "approvers": {
    "by": [{ "kind": "role", "role": "admin", "scope": "tenant" }],
    "distinct": true,
    "quorum": 2,
    "escalation": {
      "after": "4h",
      "to": { "kind": "role", "role": "owner", "scope": "tenant" }
    }
  },
  "detail": "filing.pay requires approval from admin.",
  "adapter": "eve",
  "createdAt": "2026-09-06T10:15:00Z",
  "expiresAt": "2026-09-06T10:45:00Z",
  "status": "approved",
  "approvals": [
    { "by": "u_7", "at": "2026-09-06T10:18:02Z" },
    { "by": "u_9", "at": "2026-09-06T10:20:31Z" }
  ],
  "resolvedAt": "2026-09-06T10:20:31Z",
  "resolvedBy": "u_9",
  "note": "Confirmed with the author"
}
```

`status` is `pending`, `approved`, `rejected` or `expired`; `resolvedAt`, `resolvedBy` and `note` appear only once resolved. `vouched` appears on a request the application's own rules resolved (`vouchApproval`), naming the rule, and on the approval it recorded; it is an optional `v: 1` field, absent on every other request. `consumedAt` appears once an approved request has resumed its call: an approval resumes exactly one call, and `status` stays `approved`. The `token` is `pd1.` followed by the base64url SHA-256 of the permission key, the resource id, the principal's `id`, `tenant` and `issuer`, the actor's `id` and `kind`, and the condition fingerprint; roles, memberships, assurance and claims are not hashed, so a session refresh keeps an outstanding approval valid. When the grant's approval sets `staleOn: 'resource-change'`, the hash input also carries `version`, the value of the resource's `version` field (ISO text for a date, `null` when the row lacks it); the prefix stays `pd1.` and every other token is unchanged, because the field is left out of the input rather than set to `null`. `approvers` carries `staleOn` when the grant sets it. `approvers` is copied from `grant.approval` when it is the object form. `approvers.distinct` defaults to `true`: a record without `approvers`, or with `approvers` but no `distinct`, refuses its principal as approver, and only `distinct: false` lets the principal resolve it. `approvers.quorum` (default 1) is how many distinct approvers the request needs; each one is appended to `approvals` as `{ by, at }`, oldest first, and `status` turns `approved` with the one that meets the quorum, who is also `resolvedBy`. `approvers.escalation` names who else may approve once `after` (a duration) has passed since `createdAt`. With `approvers.mode` `all` or `sequential`, `approvers.stages` lists `{ by, quorum? }` sets in order and there is no top-level `by` or `quorum`; each `approvals` entry then carries `stage`, the index of the stage it counted towards. An approver in `by` may also be `{ "kind": "user", "id": "u_7" }`, or a relation grantee, which a store matches only through the verdict's `relations`: keys from `approverRelationKey`, computed by the handler and never stored on the record. `approvers` already includes the stages added by matching approval policy entries. The grant's `approval.ttl` is not copied: it is already applied to `expiresAt`, which is the shorter of the store's window and the grant's. `subject.session` is the OIDC `sid` when the subject carried one. The record never carries the resource object, the policy, or tokens from `authInfo`. On the wire, the HTTP resume header is `PermDock-Approval: <token>`.

## Policy document [#policy-document]

Hosted grants travel as a `PolicyDocument` under the `policy` claim of a `permdock-policy+jwt` ([hostable permissions](/docs/concepts/policies)). Each grant uses the same `Grantee` and `Condition` JSON as a snapshot grant, names its permission by `key`, and carries a stable `id`.

```json
{
  "v": 1,
  "id": "pol_01J8…",
  "fingerprint": "Ux3f…",
  "catalog": "b41c…",
  "issuedAt": 1788999900,
  "grants": [
    {
      "id": "g_pro_audit",
      "permission": "auditLog.read",
      "to": { "kind": "plan", "plan": "pro" }
    },
    {
      "id": "g_owner_export",
      "permission": "invoice.export",
      "effect": "allow",
      "to": { "kind": "relation", "resource": "invoice", "relation": "owner" },
      "where": { "op": "eq", "field": "locked", "value": false },
      "approval": {
        "by": { "kind": "role", "role": "finance", "scope": "global" },
        "distinct": true
      }
    }
  ]
}
```

`effect` defaults to `allow`. `to` is one grantee or an array (an intersection) of `role`, `plan` and `relation` grantees; a hosted `relation` is declared on the permission's own resource, and it may carry `through: "parent"` (with an integer `depth` from 0 to 32) only when that resource parents itself, so a hosted grant never walks further than a code grant could; `where` and `check` are portable conditions only; `approval` is `"human"` or the object form, with `distinct` defaulting to `true` as on a code grant, so a hosted `distinct: false` on a permission a code allow guards with an approval is dropped as `weaker-approval`; `fields` is an optional string list. `catalog` is the fingerprint of the `permdock collect` catalog the document was authored against, and `fingerprint` is what `matched.hosted.document` records. `parsePolicyDocument` rejects a `v` other than `1`, a missing envelope field and any `__proto__`, `constructor` or `prototype` key; grant-level problems drop only that grant.

## Capability [#capability]

A share link travels as a `Capability` under the `capability` claim of a `permdock-capability+jwt` ([link capabilities](/docs/concepts/capabilities)). `on` has the shape of a resource membership's `on`, and the resolver turns the object into exactly that membership.

```json
{
  "v": 1,
  "id": "lnk_01J8…",
  "holder": "link",
  "on": { "resource": "quote", "id": "q_1" },
  "roles": ["guest"],
  "permissions": ["quote.read"],
  "redeemer": "anyone",
  "once": true,
  "expiresAt": 1791600000
}
```

`id` is the link id and equals the token's `sub`; `holder` is `link` or the reserved `key`; `roles` holds 1 to 64 role names and `permissions`, when present, 1 to 64 permission keys; `redeemer` is `"anyone"`, `"signed-in"`, `{ "user": "<id>" }` or `{ "scope": "<name>", "id": "<id>" }`; `once` is `true` or absent; `expiresAt` is NumericDate seconds. `parseCapability` rejects a `v` other than `1`, a wrong type in any of these fields, and reads own properties only (never `__proto__`, `constructor` or `prototype`); unknown fields are dropped. The same object is the `capability` claim of the Supabase access token `exchangeCapability` mints, which the generated `permdock_capability_ids` helper reads.

## Signed outputs [#signed-outputs]

Five artefacts can leave the process as compact JWS (RFC 7515) signed by a `TokenSigner` ([extension interfaces](/docs/concepts/extension-interfaces), [JOSE](/docs/standards/jose)). Signing is additive: the JSON object above is placed unchanged under one private claim next to registered JWT claims, so no `v` changes and a reader that already understands the JSON form understands the payload. Anything that can verify a JWS against a JWK Set, in any language, can verify these.

The envelope is the same for all five:

* **Header**: exactly `alg`, `kid` and `typ`. `alg` is one of the [`permdock/jwt`](/docs/adapters/jwt) allow-list (`ES256`, `PS256`, `Ed25519`; `RS256` outside `profile: 'fapi2'`); never `none`. `kid` names a key in the signer's JWK Set. `typ` is one of the five values below and a verifier rejects any other. No `crit`, no `jku`, no `x5u`, no embedded `jwk`: the verifier is configured with the key set, never told where to fetch it by the token.
* **Registered claims**: `iss` (the signing service's URL), `aud` (the intended consumer), `iat`, `exp`, `jti` (RFC 7519 section 4.1). `sub` is present when the artefact is about one principal and equals `subject.principal.id` (for a capability, the link id, which becomes the link principal's id). All times are NumericDate seconds.
* **One private claim** carrying the artefact: `snapshot`, `approval`, `events`, `policy` or `capability`. Nothing PermDock-specific appears at the top level; PermDock never registers a claim name in the IANA JWT registry for these.

| `typ` | Private claim | Producer | Consumer |
| --- | --- | --- | --- |
| `permdock-snapshot+jwt` | `snapshot`: a Snapshot object | `permdock.snapshot({ signer, audience })`; a `SnapshotSource` (`permdock/cloud` always signs) | The client `PermDockProvider` through `joseTokenVerifier`, or any JOSE library |
| `permdock-approval+jwt` | `approval`: `{ "token": "pd1.…", "permission", "resource": { "type", "id" }, "status" }` | `approvalsHandler({ signer })` on resolve, for a resume that crosses services | The resuming adapter, which still re-runs `decide` and recomputes the bound `token` |
| `permdock-decisions+jwt` | `events`: an array of CloudEvents-enveloped events (decision and approval from a sink; any of the five types in a Cloud webhook delivery) | A `DecisionSink` with a `signer` (`memorySink({ signer })` or `signDecisionBatch`) ([audit](/docs/concepts/audit-and-observability)); `permdock/cloud` exports | A SIEM, an auditor, a compliance tool verifying provenance offline |
| `permdock-policy+jwt` | `policy`: a PolicyDocument | PermDock Cloud, for each published hosted-grant revision | `cloud().policies.refresh()` through the application's `TokenVerifier`, with `iss` and `aud` checked against the environment URL |
| `permdock-capability+jwt` | `capability`: a Capability | `signCapability(input, signer, { audience })` in the application | `subjectFromCapability` in `permdock/jwt`, with `iss` and `aud` required |

Signed snapshot, decoded:

```json
{
  "alg": "Ed25519", "kid": "2026-09", "typ": "permdock-snapshot+jwt"
}
.
{
  "iss": "https://app.example.com",
  "aud": "https://app.example.com",
  "sub": "u_1",
  "iat": 1788999900,
  "exp": 1789000000,
  "jti": "snap_01J8…",
  "snapshot": { "v": 1, "issuedAt": 1788999900, "expiresAt": 1789000000, "subject": { "…": "…" }, "roles": ["member"], "grants": ["…"], "tenants": ["o_1"], "include": ["post"] }
}
```

Rules that hold for every signed output:

* `exp` equals the artefact's own expiry (`snapshot.expiresAt`, `approval` request `expiresAt`, `capability.expiresAt`) when it has one; otherwise the signer's default (one hour for `permdock-decisions+jwt`). A verifier applies `exp` before reading the private claim.
* `iat` equals `snapshot.issuedAt` for snapshots. A tampered persisted snapshot fails signature verification, so `issuedAt` needs no separate protection.
* `sub` is redundant with `snapshot.subject.principal.id` by design: a consumer can route or cache on registered claims without parsing the private claim.
* A signed approval never replaces the bound hash `token`: the resuming adapter verifies the JWS, then compares `approval.token` with a freshly recomputed token exactly as it would for a bare `PermDock-Approval` header. The JWS proves who resolved it; the token proves what was approved.
* A signed decision batch is evidence, not input: no PermDock code path reads `events` back to make a decision.
* A signed capability is a subject input, like a verified access token: it reaches a decision only as the link subject `subjectFromCapability` returns, after `typ`, `iss`, `aud`, `exp`, the `sub` binding, revocation and one-time use are checked, and it can only ever hold resource-scoped roles on its one resource.
* A signed policy document is the one signed policy input. It reaches a decision only through `PolicySource.current()`, only for `hostable` permissions, and only after `typ`, `iss`, `aud` and `exp` verify; an unverifiable document is absent. Its `iss` and `aud` are both the Cloud environment URL `<PERMDOCK_CLOUD_URL>/v1/environments/<env>` (the document is addressed to every instance of the environment, not to one application), and `exp` is `iat` plus 24 hours: an instance that stops refreshing falls back to its code policy within a day.
* Keys are published as a JWK Set at `/.well-known/jwks.json` by any signer that serves consumers outside its process; PermDock Cloud publishes one per environment at `<PERMDOCK_CLOUD_URL>/v1/environments/<env>/.well-known/jwks.json` (`cloud().jwks`), which verifies every artefact that environment signs. Rotation is the JWKS rule consumers already apply to identity providers: publish the new `kid`, keep the old key until every artefact signed with it has expired.
* These five `typ` values are recorded on [OpenAPI registry](/docs/standards/openapi-registry); an artefact is never emitted with a `typ` that page does not list.

JWE is not used for any output: snapshots are UI-only and contain nothing the subject may not see, approval tokens are bound hashes, decision batches go to destinations that already hold the events, a policy document holds grants the application's own code bounds, and a capability names only a resource id and role names the link holder is meant to use. A team that needs confidentiality wraps the JWS in JWE on its own transport.

`permdock/testing` ships one fixture per `typ` (a key pair, the signed compact form, the decoded payload) and `testTokenSigner` asserts that a custom signer's output for the fixture payload verifies with the fixture public key and carries exactly the three header parameters.

## AuthZEN [#authzen]

The decision endpoint, `permdock/authzen` and the `pdp` provider speak the OpenID AuthZEN Authorization API 1.0 ([spec](https://openid.net/specs/authorization-api-1_0.html)). PermDock maps `principal` to `subject`, the permission leaf to `resource.type` plus `action.name`, puts `actor` and `delegation` in the request `context`, and answers with PermDock's data under the response `context.permdock`: the full `Decision` from the application's own endpoint, and only `outcome`, denial reasons and `token` from `permdock/authzen`. See [AuthZEN](/docs/standards/authzen) and the [adapter](/docs/adapters/authzen).

### Evaluation [#evaluation]

`POST /access/v1/evaluation`

```json
{
  "subject": {
    "type": "user",
    "id": "u_1",
    "properties": { "memberships": [{ "tenant": "o_1", "roles": ["member"] }] }
  },
  "resource": {
    "type": "post",
    "id": "42",
    "properties": { "authorId": "u_1", "orgId": "o_1", "published": false }
  },
  "action": { "name": "update" },
  "context": {
    "tenant": "o_1",
    "actor": { "id": "mcp-client-7", "kind": "mcp-client" },
    "delegation": { "scopes": ["post:update"] }
  }
}
```

Memberships travel as a subject property and the active tenant as request context ([AuthZEN](/docs/standards/authzen) mapping table).

```json
{
  "decision": true,
  "context": {
    "permdock": {
      "outcome": "granted",
      "matched": { "role": "member", "permission": "post.update" },
      "token": "pd1.…"
    }
  }
}
```

That is the application's own endpoint. `permdock/authzen` answers another PEP with `{ "decision": true, "context": { "permdock": { "outcome": "granted" } } }`, or `outcome`, `denials` and `token` for the other outcomes.

`resource.properties` is the instance and is treated as boundary data: validated against the resource schema before evaluation. The server derives `subject` from the caller's credentials and ignores a mismatching body `subject` unless the caller is a trusted PDP client.

### Evaluations (boxcar) [#evaluations-boxcar]

`POST /access/v1/evaluations`: the wire form of `simulate` and of the React provider's batched requests.

```json
{
  "subject": { "type": "user", "id": "u_1" },
  "evaluations": [
    {
      "resource": {
        "type": "post",
        "id": "42",
        "properties": { "authorId": "u_1" }
      },
      "action": { "name": "update" }
    },
    {
      "resource": {
        "type": "post",
        "id": "42",
        "properties": { "authorId": "u_1" }
      },
      "action": { "name": "delete" }
    },
    { "resource": { "type": "post" }, "action": { "name": "create" } }
  ]
}
```

```json
{
  "evaluations": [
    {
      "decision": true,
      "context": {
        "permdock": {
          "outcome": "granted",
          "matched": { "role": "member", "permission": "post.update" }
        }
      }
    },
    {
      "decision": false,
      "context": {
        "permdock": {
          "outcome": "approval-required",
          "reason": "human",
          "token": "pd1.…"
        }
      }
    },
    {
      "decision": true,
      "context": {
        "permdock": {
          "outcome": "granted",
          "matched": { "role": "member", "permission": "post.create" }
        }
      }
    }
  ]
}
```

`approval-required` is `decision: false` at the AuthZEN level, because the action must not proceed yet; the PermDock outcome in `context` tells a PermDock-aware client to start the approval flow.

### Search [#search]

`POST /access/v1/search/action` answers "what can this subject do to this resource" (the `alternatives` computation), `search/resource` answers "which posts may this subject read" (the portable `where` compiled and executed by your resolver, or the snapshot filter), `search/subject` answers "who may do this" when a subject directory is configured.

```json
{
  "subject": { "type": "user", "id": "u_1" },
  "resource": {
    "type": "post",
    "id": "42",
    "properties": { "authorId": "u_1" }
  }
}
```

```json
{
  "results": [{ "name": "read" }, { "name": "update" }],
  "page": { "next_token": "" }
}
```

`GET /.well-known/authzen-configuration` lists the supported endpoints.

## Catalog [#catalog]

Output of `permdock collect` and `permdock catalog --format json`. The same list is available at runtime from `listPermissions(permissions)`; the catalog adds usage sites and the resource JSON Schema, embedded per resource. See [catalog](/docs/cli/catalog).

```json
{
  "$schema": "https://permdock.com/schemas/catalog-v1.json",
  "version": 1,
  "generatedAt": "2026-09-06T10:15:00Z",
  "generator": "permdock@0.1.0",
  "fingerprint": "1jqQg7VKp6ix0_g5wSeOLvfUOEsELf0m-_v4rPKvGxc",
  "resources": {
    "post": {
      "id": "id",
      "schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "authorId": { "type": "string" },
          "published": { "type": "boolean" }
        },
        "required": ["id", "authorId", "published"]
      },
      "definedIn": "src/features/posts/permissions.ts"
    }
  },
  "permissions": [
    {
      "key": "post.update",
      "scope": "post:update",
      "resource": "post",
      "action": "update",
      "arity": "instance",
      "meta": { "title": "Edit post" },
      "usages": [
        { "file": "src/app/posts/[id]/page.tsx", "line": 22, "call": "assert" }
      ],
      "rowConditions": true
    },
    {
      "key": "post.create",
      "scope": "post:create",
      "resource": "post",
      "action": "create",
      "arity": "collection",
      "meta": {},
      "usages": [],
      "rowConditions": false
    },
    {
      "key": "auditLog.read",
      "scope": "auditLog:read",
      "resource": "auditLog",
      "action": "read",
      "arity": "instance",
      "meta": {},
      "usages": [],
      "hostable": true,
      "rowConditions": false
    },
    {
      "key": "invoice.refund",
      "scope": "invoice:refund",
      "resource": "invoice",
      "action": "refund",
      "arity": "instance",
      "meta": {},
      "usages": [],
      "rowConditions": false,
      "approvals": ["human"]
    }
  ],
  "scopes": [
    { "name": "tenant", "key": "orgId" },
    { "name": "team", "key": "teamId", "within": "tenant" }
  ],
  "roles": [
    {
      "key": "admin",
      "on": "tenant",
      "assignable": true,
      "min": 1,
      "assigns": ["admin", "member"],
      "for": ["staff"],
      "audience": "staff"
    },
    {
      "key": "member",
      "on": "tenant",
      "assignable": true,
      "for": ["staff"],
      "audience": "staff"
    }
  ],
  "plans": [{ "key": "pro" }],
  "grants": [
    {
      "permission": "post.update",
      "effect": "allow",
      "role": "member",
      "to": { "kind": "role", "role": "member", "scope": "tenant" },
      "scope": "tenant",
      "where": {
        "op": "eq",
        "field": "authorId",
        "value": { "ref": "principal.id" }
      }
    },
    {
      "permission": "post.update",
      "effect": "deny",
      "role": "member",
      "to": { "kind": "role", "role": "member", "scope": "tenant" },
      "scope": "tenant",
      "where": { "op": "eq", "field": "published", "value": true },
      "name": "published"
    },
    {
      "permission": "invoice.refund",
      "effect": "allow",
      "role": "admin",
      "to": { "kind": "role", "role": "admin", "scope": "tenant" },
      "scope": "tenant",
      "approval": "human",
      "validity": { "until": 1775001600 }
    }
  ]
}
```

`version` is the format major and `$schema` names the matching JSON Schema. A resource with a `restricted` column carries it as `restricted`, and `restrictedStops` lists the paths it closes when the resource declares `stops`. Every permission carries `rowConditions`; a catalog built without a `policy` cannot know the conditions, so it marks each one `true`, and a reader treats a missing value as invalid, never as `false`. `roles` and `plans` list the declared names the scan found and are omitted when there are none. When `permdock.config.ts` names a `policy`, the catalog lists the policy's `scopes` in order (`{ name, key, within? }`, omitted when it declares none), each role also carries `on` (a scope name or `resource`, omitted for a global role), `assignable` and, when set, its [ownership rules](/docs/concepts/ownership) `min` (omitted when 0), `max`, `transferOnly`, `assigns`, `for`, `exclusiveWith` and `audience`, a permission the policy lists in `hostable` carries `hostable: true`, `rowConditions` is `true` when a code grant for a permission depends on more than the role and the scope, so the SQL helpers alone cannot enforce it ([RLS](/docs/adapters/rls#sql-helper-contract)), and a permission whose code allows require an approval carries `approvals`: the distinct `approval` values of those allows (`"human"` or `{ by?, mode?, stages?, distinct?, staleOn?, quorum?, ttl?, escalation? }`), absent when none requires one. A permission renamed with `definePermissions(..., { renamed })` carries `renamedFrom`, its sorted former keys, an instance permission whose resource declares levels carries `levels`, the level names in declaration order, and `meta.x` appears inside `meta` as written. A resource that declares `version` carries it (the row field a `staleOn: 'resource-change'` approval binds to), one that declares `restricted` carries that column name, and one that declares `meta` carries it as `{ title?, description?, x? }`. With a `policy`, `grants` lists every code grant in canonical order (by permission, allows before denies, then role, then canonical JSON): `{ permission, effect, role, to, scope, where?, check?, approval?, fields?, validity?, name?, meta?, purpose?, requires?, limit?, portable? }`, where `meta` is the grant's `{ description?, x? }`, where `role` is `null` for a top-level grant, `scope` is `global`, a scope name or `{ resource }`, `validity` is `{ from?, until? }` in Unix seconds, `requires` is the permission key a [`requires`](/docs/concepts/policies#requires) allow needs, or the array of keys when it needs several (a snapshot grant carries it already folded into `where`), and `portable: false` marks a closure, graph or opaque grant whose `where` and `check` are omitted. Hosted grants are not listed. When the policy declares `delegations`, the catalog lists them sorted by canonical JSON as `{ from, to: { kind, id?, client? }, permissions, validity? }`, with `permissions` the sorted keys ([policy delegations](/docs/security/delegation#policy-delegations)). [`permdock diff`](/docs/cli/diff) compares both sections between two catalogs. `relations` maps each relation name to its declared shape: `{ field, memberOf? }`, `{ edge, object?, subject?, expiresAt? }` or `{ principal, period?: { startsAt?, expiresAt? } }`. A relation grantee (`to`) in a snapshot, a decision event or a catalog may carry `through: "parent"` and `depth` ([relationships](/docs/concepts/relationships)). PermDock Cloud reads these to offer only assignable roles, to apply `assigns`, `min`, `max`, `transferOnly` and `for` in its assignment checks, to offer only hostable permissions, and to refuse a hosted grant whose approval is weaker than any entry in `approvals` (`weaker-approval`); an approval without `staleOn` is weaker than one with it. `definedIn` and the entries of `usages` come from the source scan; `usages` is always present and empty when no call site was found. `parseCatalog` in `permdock/catalog` validates a document against this schema and returns it frozen ([reading a catalog](/docs/cli/catalog#reading-a-catalog)). `schema` is produced through Standard JSON Schema where the validator supports it and omitted otherwise. `permdock catalog --format json-schema` emits a JSON Schema document whose `enum` of permission keys and `$defs` of resources can be referenced from OpenAPI or MCP tool definitions. `permdock collect --check` compares this file with a fresh run, ignoring `generatedAt` and `generator`.

`fingerprint` identifies the catalog's contract and is what a hosted policy document pins as `catalog`. It is `catalogFingerprint(catalog)` from `permdock`: the base64url SHA-256 of the canonical JSON (object keys sorted by UTF-16 code unit, no whitespace, as in RFC 8785) of the document without `generatedAt`, `generator`, `fingerprint` and every `permissions[].usages`. The clock, the CLI version and call sites therefore never change it; any change to a permission, resource, role, plan, grant, `hostable`, `rowConditions` or `approvals` does, including a change to their `meta`. The catalog fingerprint is separate from the policy fingerprint that decision and approval tokens bind to, which leaves grant `meta` out. A reader recomputes it with the same function and rejects a document whose `fingerprint` disagrees.

### `x-permdock-catalog` in OpenAPI documents [#x-permdock-catalog-in-openapi-documents]

`permdock openapi emit` writes a root-level extension that ties a document to the catalog it was generated from ([OpenAPI registries](/docs/standards/openapi-registry)):

```json
{
  "x-permdock-catalog": {
    "v": 1,
    "generator": "permdock@0.x",
    "catalog": "sha256:...",
    "drafts": {
      "oas": "3.3-dev@<commit>",
      "securityProfiles": "oai-discussion-5304@2026-09-01",
      "overlay": "1.2-dev@<commit>"
    }
  }
}
```

`drafts` appears only when the output depends on an unfinished specification, today `--target 3.3` (`oas`, `securityProfiles`; [OpenAPI 3.3](/docs/standards/openapi)) and `--overlay 1.2` (`overlay`; [OpenAPI Overlay](/docs/standards/openapi-overlay)), and carries only the keys that apply; each value names the pinned revision. `permdock openapi emit --check` fails when a committed document's `drafts` differ from the installed CLI's pins. The shape is versioned by `v` like every other extension.

## Supabase claims [#supabase-claims]

The claims the Supabase token hook writes have a JSON Schema, `schemas/supabase-claims-v1.json` in the `permdock` package, and a Standard Schema twin, `supabaseClaims()` in `permdock/supabase`, which a session library can validate tokens with instead of copying the shape. Both cover `user_role`, `roles`, `memberships`, the tenant claim (`tenant_id` by default), `attrs`, `authz_ver` and `memberships_truncated`, at the top level or under `app_metadata`, plus the OAuth claims `client_id`, `scope` and `act`. The outer `act` level may carry `kind` (`support` or `impersonation`), and a support level needs `session_id` and may carry `read_only` and `reason`, as better-supabase writes them. Both pass other claims through. The TypeScript types are `SupabaseClaims<TenantClaim>` and `SupabaseMembershipClaim`. `supabaseClaimFixtures` in `permdock/testing` passes both, and the test suite checks that the two agree on every fixture and on a set of malformed claims.

## Supabase hook manifest [#supabase-hook-manifest]

Output of `permdock supabase inspect --json`, and the file `inspect --out permdock.manifest.json` writes ([supabase](/docs/cli/supabase)): what the generated hook and SQL helpers expect, for a package that writes policies or claims next to them. Its JSON Schema is `schemas/supabase-manifest-v1.json` in the `permdock` package, and the file names it in `$schema`. `version` is the format major, as in the catalog; `permdock/testing` exports `supabaseHookManifestFixture`, and `permdock/supabase` the `SupabaseHookManifest` type.

```json
{
  "$schema": "https://permdock.com/schemas/supabase-manifest-v1.json",
  "version": 1,
  "hook": {
    "schema": "public",
    "function": "custom_access_token_hook",
    "out": "supabase/permdock-hook.sql"
  },
  "helpers": {
    "schema": "public",
    "functions": [
      "permdock_has",
      "permitted_tenant_ids",
      "member_tenant_ids",
      "member_tenant_ids_for"
    ]
  },
  "tenantClaim": "tenant_id",
  "budget": {
    "bytes": 1024,
    "measure": "octet_length(memberships::text) + octet_length(attrs::text)"
  },
  "claims": [
    { "name": "memberships", "source": "permdock", "budget": true },
    {
      "name": "features",
      "source": "public.feature_claims",
      "budget": false
    }
  ],
  "authzVersion": true,
  "authzVersionBump": {
    "schema": "permdock",
    "function": "permdock_bump_authz_version_for",
    "args": "p_users uuid[]"
  },
  "memberships": [
    {
      "table": "public.memberships",
      "user": { "column": "user_id" },
      "scope": { "column": "scope" },
      "id": { "column": "scope_id" },
      "role": { "column": "role" },
      "columns": ["user_id", "scope", "scope_id", "role"]
    }
  ],
  "rls": {
    "schema": "public",
    "mode": "jwt",
    "tenantClaim": "tenant_id",
    "scopes": [{ "name": "tenant", "type": "uuid" }],
    "helpers": [
      {
        "name": "permitted_tenant_ids",
        "args": "p_grant text",
        "returns": "setof uuid",
        "execute": ["authenticated"]
      },
      {
        "name": "member_tenant_ids_for",
        "args": "p_user uuid",
        "returns": "setof uuid",
        "execute": ["supabase_auth_admin"]
      }
    ],
    "memberships": [
      {
        "table": "public.memberships",
        "user": { "column": "user_id" },
        "scope": { "column": "scope" },
        "id": { "column": "scope_id" },
        "role": { "column": "role" },
        "columns": ["user_id", "scope", "scope_id", "role"]
      }
    ],
    "customRoles": false,
    "roles": {
      "table": "permdock.user_roles",
      "user": { "column": "user_id" },
      "role": { "column": "role" }
    }
  },
  "decidingColumns": [
    "public.memberships.role",
    "public.memberships.scope",
    "public.memberships.scope_id",
    "public.memberships.user_id"
  ],
  "markers": { "hook": "v1", "grants": "v1" },
  "requires": {
    "matrix": "capability-matrix-v1.12.0",
    "capabilities": ["auth.session.get_claims"]
  }
}
```

(`claims` and `rls.helpers` are shortened here.)

* `helpers.functions` are `permdock_has(p_grant text)` and, per declared scope, `permitted_<scope>_ids(p_grant text)` and `member_<scope>_ids()`, plus `member_<scope>_ids_for(p_user uuid)` for each scope with a membership source, in `helpers.schema`. `rls.helpers` gives each one's arguments, return type and the roles granted `execute` (a [field view](/docs/adapters/rls#field-security) `anon` reads adds `anon` to the `authenticated` ones). What they answer, and what a policy calling them may rely on, is the [SQL helper contract](/docs/adapters/rls#sql-helper-contract); the claims they read have a JSON Schema, `schemas/supabase-claims-v1.json`.
* `hook.before`, present when `supabase.hook.before` is set, lists the functions the hook calls first, in order ([checks before the hook](/docs/adapters/supabase-hook#checks-before-the-hook)).
* `budget.measure` is the SQL the hook sums against `budget.bytes`. Each claim's `source` is `permdock` or the `<schema>.<function>` of a `supabase.hook.claims` entry, and `budget` says whether it counts toward the budget.
* `memberships` is one entry per `supabase.hook.memberships` source, in the order the hook reads them. Each of `scope`, `role` and `via` is `{ "column": "<name>" }` or `{ "value": ... }`, a value every row has: `fromJunction` has a fixed `scope`, and fixed roles are `{ "value": ["contact"] }`. A `role` or `user` column that references another table adds `through` (`{ "table": "public.contact_profiles", "id": "id", "column": "user_id" }`): the value is `column` of `table`, matched on `id`. `within` is a `jsonb` column (`fromTable`) or `{ "columns": { "<scope>": "<column>" } }` (`fromJunction`). `columns` are the table's columns that decide the membership.
* `authzVersionBump`, present when `authzVersion` is true, names the function a trigger outside the hook calls to bump `authz_ver` for a list of users. No client role may execute it ([Supabase token hook](/docs/adapters/supabase-hook#authorization-version)).
* `rls.helpers` also lists what `rls generate` writes for trusted SQL and for packages that check assignments, each with an empty `execute` when no client role may call it: in `database` mode `permdock_has_for(p_user uuid, p_grant text)` and `permitted_<scope>_ids_for(p_user uuid, p_grant text)`; when a role declares `assigns`, `permdock_can_assign`, `permdock_can_assign_any(p_role text, p_tenant, p_scope text, p_scope_id text)` (one check for declared and custom roles) and, with custom roles, `permdock_can_assign_custom_role`, each with its `_for` form where `rls generate` writes one ([ownership rules](/docs/cli/rls#ownership-rules)). It always lists `permdock_user_id()`, which returns the caller's user id as `auth.uid()` does and null for an empty `sub`; SQL next to the helpers reads the subject through it ([dialects](/docs/cli/rls#generate)). `helpers.functions` keeps listing only the helpers the hook's claims feed, which `permdock doctor` PD039 requires.
* `rls.customRoles` says whether custom roles live in the helpers' tables. `rls.roles` is the global-roles table the hook and the `database` mode helpers read, in the `memberships` column shape. `rls.suspension` gives the `users` and per-scope tables of `rls.suspension` (`table`, `id`, and `disabledAt` or `status` with `active`), and for a scope that keeps permissions its `keep` keys. `rls.assignments.tables` lists the tables whose client writes the [assignment triggers](/docs/cli/rls#assignment-triggers) check, the global-roles table among them when `rls.roles` or `supabase.hook.roles` names one, so a package writing rows there leaves the role ceiling to them. `rls.apiKeys` gives the claim and field names of [`rls.apiKeys`](/docs/cli/rls#api-keys) (`claim`, `scopes`, `tenant`, `roles`) and its `serviceRoles`, so a package that issues keys writes the claim the helpers read; `rls.helpers` then lists `permdock_api_key_allows(p_grant text)`. Each is present only when it applies.
* `rls.mode` is where the helpers read roles and memberships: `jwt` (the claims) or `database` (the tables). It is `rls.authorize`, else what `rls generate` picks with no flags, so set `rls.authorize` when you pass `--authorize` or `--rbac` to `rls generate`. `rls.scopes` gives each declared scope's id type. `rls.memberships` lists the tables `member_<scope>_ids_for` reads, in the `memberships` shape: for each scope, the `rls.memberships` table mapped for it, else the `rls.membershipSources` that can hold it, else the hook's sources. A reader that resolves a user's memberships outside the hook, such as better-supabase's entitlements, reads this list and not the hook's `memberships`.
* `decidingColumns` is every `schema.table.column` a membership or an `attrs` claim is computed from: the columns `permdock doctor` PD028 requires clients cannot write.
* `requires` names the [Supabase capability matrix](https://github.com/supabase/sdk/tree/main/packages/capability-matrix) features the setup depends on, as of the `matrix` release tag. `auth.session.get_claims` is always there. A grant with a [live-session condition](/docs/concepts/conditions#live-sessions) adds `auth.session.get_user`, `rls.realtime` adds `realtime.subscriptions.private_channel`, and `rls.storage` adds the `storage.file_buckets.*` operations its buckets' policies cover. A client SDK that lacks one of them cannot serve the policy. Manifests written before the field existed omit it.
* `markers` are the majors of the `-- permdock:hook` and `-- permdock:grants` lines. The generated migration starts with `-- permdock:hook v1 schema=<schema> tenant=<claim> budget=<bytes> claims=<names>`, which `supabase hook generate --check` compares first, and `parseHookMarker` / `parseGrantsMarker` in `permdock/cli` read.

**Why.** A package that writes Storage or Realtime policies, or a claim function, next to the generated SQL needs to know what that SQL reads: which helpers exist with which signature, which tables and columns decide a membership, and whether the helpers read the token or the tables. Reading the manifest instead of PermDock's config or its generated SQL keeps that package on a versioned contract. A committed file lets it read the facts without running the CLI, and `inspect --check` in CI fails when the file falls behind the config. The check compares JSON, not text, so a formatter that reflows the file is not drift. Fields are only added within v1; a removed or changed field is a new major.

## Problem Details [#problem-details]

`application/problem+json` bodies from the HTTP adapters. See [errors](/docs/concepts/errors) and [Problem Details](/docs/standards/problem-details).

```json
{
  "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"]
}
```

```json
{
  "type": "https://permdock.com/problems/approval-required",
  "title": "Approval required",
  "status": 403,
  "detail": "post.delete requires human approval (human). Token: pd1.…",
  "permission": "post.delete",
  "scope": "post:delete",
  "resource": { "type": "post", "id": "42" },
  "reason": "human",
  "token": "pd1.…"
}
```

```json
{
  "type": "https://permdock.com/problems/validation",
  "title": "Invalid resource data",
  "status": 400,
  "detail": "post.update: invalid post data at authorId: Expected string, received number.",
  "permission": "post.update",
  "resource": { "type": "post" },
  "issues": [
    { "path": ["authorId"], "message": "Expected string, received number" }
  ]
}
```

The base URI in `type` is the fixed identifier `https://permdock.com/problems`; it is not configurable, so every deployment emits the same `type` values. Each URI dereferences to its section on [Problem Details](/docs/standards/problem-details).

## RFC 9396 authorization\_details [#rfc-9396-authorization_details]

What PermDock emits for a consent screen and verifies on a token for `permissions.post.update` on post 42. See [subject](/docs/concepts/subject).

```json
[{ "type": "post", "actions": ["update"], "identifier": "42" }]
```

## Versioning [#versioning]

* The snapshot `v`, the approval request `v`, the policy document `v`, the capability `v` and the catalog `version` follow the format, not the package version, and are all `1`. A breaking change to a shape bumps the major. Readers reject unknown majors.
* The JWS envelope of a signed output is versioned by its `typ`: a change to the header set, the registered claims or the private claim name is a new `typ` (`permdock-snapshot.v2+jwt`), never a silent change, so a verifier configured for one `typ` keeps rejecting what it does not understand.
* Everything PermDock writes names a scope instance the same way: `scope`, `id` and `within`. Memberships, snapshot grants, the `memberships` claim and `membership` events all use this shape. A fixed `tenant` / `team` pair could not describe a third level, and one shape lets the Cloud and a sink join an event to the membership it changed without a per-format mapping. Only input readers (`subject`, a `MembershipSource`) still accept `{ tenant, team }`.
* Condition, Decision, event and Problem Details shapes are additive within a major: new optional fields may appear, existing fields keep their meaning. App data (`meta.x`, `Membership.x`, grant `meta`, app obligations) was added this way: every field is optional, plain JSON, and changes no outcome. New condition `op` values (such as `sqlFunction`) are additive; readers that do not know an op deny.
* AuthZEN messages follow the 1.0 spec; PermDock-specific data lives only under the evaluation `context.permdock`: from `permdock/authzen`, `outcome`, `denials` as `{ role, reason }`, `token` on `approval-required`, and `reason: 'unknown-permission'`; from the application's own decision endpoint (`permdockHandler`), the full Decision with its denials as `WireDenial`. `alternatives` reach another PEP only through `search/action`.
