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
JSON.stringify(permissions.post.update). The schema is not included; it lives on the resource node. See permissions.
{
"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).
Condition
The normalised form of { where: { authorId: principal.id, teamId: { in: context.teamIds } } }. See conditions.
{
"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) |
| 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) |
| Live session | { "op": "liveSession" }: the Auth server still holds the subject's session (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
Output of permdock.snapshot({ include: [permissions.post] }). See 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 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()).
{
"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 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 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 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). A reader that predates them ignores them.
{
"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
Carried inside the principal and exchanged with MembershipSource and RoleSource implementations (tenancy). PermDock publishes both as Standard JSON Schema for validating edits at a boundary.
{
"scope": "customer",
"id": "c_7",
"within": { "organization": "o_1" },
"roles": ["contact"],
"via": "contact",
"expiresAt": 1789000000
}{
"scope": "organization",
"id": "o_1",
"roles": ["member"],
"via": "group:g_eng",
"managedBy": "idp",
"entitlements": ["dev-mode"]
}{ "on": { "resource": "document", "id": "d_9" }, "roles": ["editor"] }{
"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). 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).
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).
Decision
The return value of decide, and the payload inside AuthZEN context and Problem Details. See decisions.
{
"outcome": "granted",
"matched": {
"role": "member",
"permission": "post.update",
"to": { "kind": "role", "role": "member", "scope": "global" }
},
"token": "pd1.…",
"subject": { "principal": { "id": "u_1", "roles": ["member"] } }
}{
"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" }]
}{
"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 } }
]
}{
"outcome": "denied",
"permission": "post.update",
"denials": [
{ "role": "member", "reason": "condition" },
{ "role": null, "reason": "not-delegated" }
],
"alternatives": ["post.read"]
}{
"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). 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). 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); trace never appears on a decision event, in Problem Details or in an AuthZEN response.
Decision event
Emitted by on('decision'). See audit and observability.
{
"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), 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). 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).
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):
- OCSF.
toOcsf(event)frompermdockmaps a decision or approval event onto the OCSF 1.3.0 Authorize Session class (class_uid3003, category Identity and Access Management,activity_id1,type_uid300301), pinned asOCSF_VERSION.outcomesetsstatus_id(1Successforgranted, 2Failurefordenied, 99Otherforapproval-required, 0Unknownfor an outcome this build does not know);status_detailis the comma-joineddenials[].reasonorapproval-required;permissionis the one entry inprivileges;subject.principal.idisuser.uidandactor.user.uid(an anonymous subject isuser: { name: 'anonymous' }, since the class requiresuser);subject.actor.idisactor.app_name;adapterismetadata.product.feature.name;tenantismetadata.tenant_uid; the approvaltokenismetadata.correlation_uid. Authorize Session has no resource object, soscope,resource,source,phase,matched.roleandviatravel underunmapped, withgrant(the matched grant'sname) andobligations(the names of its app obligations) when present, alongsidebreakGlass,purposeandreasonfor 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_uid3001,activity_id2 Enable andtype_uid300102 forstarted,activity_id5 Disable andtype_uid300105 forended/revoked, high severity). A version bump of the projection is a wire-format change. - CSV.
toCsvRow(event)frompermdockwrites the columns inCSV_COLUMNS(time,principal,actor,tenant,permission,outcome,matched.role,via,denials.reasonjoined with;,token), absent values empty and RFC 4180 quoting (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. Thepermdock/cloudsink itself posts the raw events ({ events: SinkEvent[] }, unsigned, toPOST /v1/environments/:env/decisions), and the Cloud wraps each one in this envelope when it stores, exports or delivers it, soidandsourceare the Cloud's.sourceis the emitting service (the application'ssourceon the event, or the Cloud environment URL);subjectis 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 ids, active after the change) with no attribute values beyond identifiers | scimHandler through its sink; the Cloud relay's sync log (SCIM adapter) |
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) |
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) | 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.
{
"kind": "drift",
"fingerprint": "1jqQ…",
"previous": "QD-Y…",
"findings": [
{
"code": "not-hostable",
"permission": "auditLog.read",
"grant": "hg_01J8…"
}
]
}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.
{
"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 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.
Credential event
A credential event records an API key's lifecycle (API keys). It carries identifiers only, never the key or its hash.
{
"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
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.
{
"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
Stored by an ApprovalStore and exchanged with PermDock Cloud. See the approvals adapter and approval security. 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.
{
"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
Hosted grants travel as a PolicyDocument under the policy claim of a permdock-policy+jwt (hostable permissions). Each grant uses the same Grantee and Condition JSON as a snapshot grant, names its permission by key, and carries a stable id.
{
"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
A share link travels as a Capability under the capability claim of a permdock-capability+jwt (link capabilities). on has the shape of a resource membership's on, and the resolver turns the object into exactly that membership.
{
"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
Five artefacts can leave the process as compact JWS (RFC 7515) signed by a TokenSigner (extension interfaces, 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,kidandtyp.algis one of thepermdock/jwtallow-list (ES256,PS256,Ed25519;RS256outsideprofile: 'fapi2'); nevernone.kidnames a key in the signer's JWK Set.typis one of the five values below and a verifier rejects any other. Nocrit, nojku, nox5u, no embeddedjwk: 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).subis present when the artefact is about one principal and equalssubject.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,policyorcapability. 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); 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:
{
"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:
expequals the artefact's own expiry (snapshot.expiresAt,approvalrequestexpiresAt,capability.expiresAt) when it has one; otherwise the signer's default (one hour forpermdock-decisions+jwt). A verifier appliesexpbefore reading the private claim.iatequalssnapshot.issuedAtfor snapshots. A tampered persisted snapshot fails signature verification, soissuedAtneeds no separate protection.subis redundant withsnapshot.subject.principal.idby 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 comparesapproval.tokenwith a freshly recomputed token exactly as it would for a barePermDock-Approvalheader. 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
eventsback 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
subjectFromCapabilityreturns, aftertyp,iss,aud,exp, thesubbinding, 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 forhostablepermissions, and only aftertyp,iss,audandexpverify; an unverifiable document is absent. Itsissandaudare 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), andexpisiatplus 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.jsonby 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 newkid, keep the old key until every artefact signed with it has expired. - These five
typvalues are recorded on OpenAPI registry; an artefact is never emitted with atypthat 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
The decision endpoint, permdock/authzen and the pdp provider speak the OpenID AuthZEN Authorization API 1.0 (spec). 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 and the adapter.
Evaluation
POST /access/v1/evaluation
{
"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 mapping table).
{
"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)
POST /access/v1/evaluations: the wire form of simulate and of the React provider's batched requests.
{
"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" } }
]
}{
"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
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.
{
"subject": { "type": "user", "id": "u_1" },
"resource": {
"type": "post",
"id": "42",
"properties": { "authorId": "u_1" }
}
}{
"results": [{ "name": "read" }, { "name": "update" }],
"page": { "next_token": "" }
}GET /.well-known/authzen-configuration lists the supported endpoints.
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.
{
"$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 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), 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 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). permdock 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). 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). 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
permdock openapi emit writes a root-level extension that ties a document to the catalog it was generated from (OpenAPI registries):
{
"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) and --overlay 1.2 (overlay; 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
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
Output of permdock supabase inspect --json, and the file inspect --out permdock.manifest.json writes (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.
{
"$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.functionsarepermdock_has(p_grant text)and, per declared scope,permitted_<scope>_ids(p_grant text)andmember_<scope>_ids(), plusmember_<scope>_ids_for(p_user uuid)for each scope with a membership source, inhelpers.schema.rls.helpersgives each one's arguments, return type and the roles grantedexecute(a field viewanonreads addsanonto theauthenticatedones). What they answer, and what a policy calling them may rely on, is the SQL helper contract; the claims they read have a JSON Schema,schemas/supabase-claims-v1.json.hook.before, present whensupabase.hook.beforeis set, lists the functions the hook calls first, in order (checks before the hook).budget.measureis the SQL the hook sums againstbudget.bytes. Each claim'ssourceispermdockor the<schema>.<function>of asupabase.hook.claimsentry, andbudgetsays whether it counts toward the budget.membershipsis one entry persupabase.hook.membershipssource, in the order the hook reads them. Each ofscope,roleandviais{ "column": "<name>" }or{ "value": ... }, a value every row has:fromJunctionhas a fixedscope, and fixed roles are{ "value": ["contact"] }. Aroleorusercolumn that references another table addsthrough({ "table": "public.contact_profiles", "id": "id", "column": "user_id" }): the value iscolumnoftable, matched onid.withinis ajsonbcolumn (fromTable) or{ "columns": { "<scope>": "<column>" } }(fromJunction).columnsare the table's columns that decide the membership.authzVersionBump, present whenauthzVersionis true, names the function a trigger outside the hook calls to bumpauthz_verfor a list of users. No client role may execute it (Supabase token hook).rls.helpersalso lists whatrls generatewrites for trusted SQL and for packages that check assignments, each with an emptyexecutewhen no client role may call it: indatabasemodepermdock_has_for(p_user uuid, p_grant text)andpermitted_<scope>_ids_for(p_user uuid, p_grant text); when a role declaresassigns,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_forform whererls generatewrites one (ownership rules). It always listspermdock_user_id(), which returns the caller's user id asauth.uid()does and null for an emptysub; SQL next to the helpers reads the subject through it (dialects).helpers.functionskeeps listing only the helpers the hook's claims feed, whichpermdock doctorPD039 requires.rls.customRolessays whether custom roles live in the helpers' tables.rls.rolesis the global-roles table the hook and thedatabasemode helpers read, in themembershipscolumn shape.rls.suspensiongives theusersand per-scope tables ofrls.suspension(table,id, anddisabledAtorstatuswithactive), and for a scope that keeps permissions itskeepkeys.rls.assignments.tableslists the tables whose client writes the assignment triggers check, the global-roles table among them whenrls.rolesorsupabase.hook.rolesnames one, so a package writing rows there leaves the role ceiling to them.rls.apiKeysgives the claim and field names ofrls.apiKeys(claim,scopes,tenant,roles) and itsserviceRoles, so a package that issues keys writes the claim the helpers read;rls.helpersthen listspermdock_api_key_allows(p_grant text). Each is present only when it applies.rls.modeis where the helpers read roles and memberships:jwt(the claims) ordatabase(the tables). It isrls.authorize, else whatrls generatepicks with no flags, so setrls.authorizewhen you pass--authorizeor--rbactorls generate.rls.scopesgives each declared scope's id type.rls.membershipslists the tablesmember_<scope>_ids_forreads, in themembershipsshape: for each scope, therls.membershipstable mapped for it, else therls.membershipSourcesthat 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'smemberships.decidingColumnsis everyschema.table.columna membership or anattrsclaim is computed from: the columnspermdock doctorPD028 requires clients cannot write.requiresnames the Supabase capability matrix features the setup depends on, as of thematrixrelease tag.auth.session.get_claimsis always there. A grant with a live-session condition addsauth.session.get_user,rls.realtimeaddsrealtime.subscriptions.private_channel, andrls.storageadds thestorage.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.markersare the majors of the-- permdock:hookand-- permdock:grantslines. The generated migration starts with-- permdock:hook v1 schema=<schema> tenant=<claim> budget=<bytes> claims=<names>, whichsupabase hook generate --checkcompares first, andparseHookMarker/parseGrantsMarkerinpermdock/cliread.
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
application/problem+json bodies from the HTTP adapters. See errors and Problem Details.
{
"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"]
}{
"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.…"
}{
"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.
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.
[{ "type": "post", "actions": ["update"], "identifier": "42" }]Versioning
- The snapshot
v, the approval requestv, the policy documentv, the capabilityvand the catalogversionfollow the format, not the package version, and are all1. 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 newtyp(permdock-snapshot.v2+jwt), never a silent change, so a verifier configured for onetypkeeps rejecting what it does not understand. - Everything PermDock writes names a scope instance the same way:
scope,idandwithin. Memberships, snapshot grants, themembershipsclaim andmembershipevents all use this shape. A fixedtenant/teampair 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, aMembershipSource) 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, grantmeta, app obligations) was added this way: every field is optional, plain JSON, and changes no outcome. New conditionopvalues (such assqlFunction) 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: frompermdock/authzen,outcome,denialsas{ role, reason },tokenonapproval-required, andreason: 'unknown-permission'; from the application's own decision endpoint (permdockHandler), the full Decision with its denials asWireDenial.alternativesreach another PEP only throughsearch/action.
Last updated on
Errors
PermDock throws three evaluation error classes and ends long-lived connections with a fourth, each carrying the Decision or issues that caused it, and adapters map them to Problem Details and model-readable refusals.
Extend PermDock
Attach typed app data to permissions, resources, roles, plans, grants and memberships, add request data, declare app obligations, set UI defaults and replace adapter responses, without a plugin system and without changing what PermDock decides.