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": "sha-256:..." } |
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. issuedAt and expiresAt are RFC 3339 date-times, and subject.delegation is the token's OAuth scope (one space-separated string) and RFC 9396 authorization_details. The JSON Schema is schemas/snapshot-v1.json. 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": "2026-09-09T00:25:00Z",
"expiresAt": "2026-09-09T00:41:40Z",
"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": { "scope": "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? } as RFC 3339 instants when the grant has a validFrom or validUntil; the client evaluator checks it against its clock and treats a malformed bound as not yet active), 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" }]
}Local snapshot manifest
localSnapshotManifest(policy) writes what a device needs to build a snapshot without the policy: v, the scopes, the declared roles and grants, and per custom-role scope the grants each permission, level and include resolves to (custom), with the levels each entry reaches (reach, levelReach, includeReach). assignable holds what the device needs to compute the assignable lists: the declared roles a tenant admin may hand out, with their scope and allow keys; manage, the role names and permission keys with meta.manageRoles: true; assigns; the custom-role ceiling permissions; the level names per permission (levels); and the levels a holder of each allow may hand out (grants). localSnapshot in permdock/react-native reads it (local snapshot); the LocalSnapshotManifest type in permdock describes it.
permdock powersync generate adds rows: the synced tables a device reads the user's memberships, global roles and custom roles from, under the device's table names (PowerSync). powerSyncSource reads them when the app passes no queries.
{
"rows": {
"memberships": [
{
"table": "tenant_users",
"user": { "column": "user_id" },
"scope": { "value": "tenant" },
"id": "tenant_id",
"roles": [{ "column": "tier_role" }]
},
{
"table": "user_roles",
"user": { "column": "user_id" },
"scope": { "value": "tenant" },
"id": "tenant_id",
"roles": [
{
"column": "role_id",
"through": { "table": "roles", "id": "id", "column": "key" }
}
]
}
],
"roles": {
"table": "permdock_user_roles",
"user": "user_id",
"roles": [{ "column": "role" }]
},
"customRoles": {
"permissions": "custom_role_permissions",
"includes": "custom_role_includes"
}
}
}membershipsholds one entry per membership table or membership source.user,scopeandviaare a column or a fixedvalue;roleslists role columns, or a fixedvalueof roles every row holds.- A role column is the key itself, a
throughtable whosecolumnholds the key, or amapfrom the stored value to a role key (nullfor no role). - A row with a value in
expiresAtordisabledAtis no membership on the device. customRolesnames the device'scustom_role_permissionsandcustom_role_includestables.
Fields are only added within v: 1.
Membership and custom role
Carried inside the principal and exchanged with MembershipSource and CustomRoleSource 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, with expiresAt, grantedBy and managedBy written expires_at, granted_by and managed_by, 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; absent on a shared role, which has shared: true and no id either), 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, a NumericDate because SQL reads it from the claim; 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": "sha-256:…",
"subject": { "principal": { "id": "u_1", "roles": ["member"] } }
}{
"outcome": "granted",
"matched": { "role": "member", "permission": "report.export" },
"token": "sha-256:…",
"subject": { "principal": { "id": "u_1", "roles": ["member"] } },
"quota": { "remaining": 0, "resetsAt": "2026-09-10T01:00:00.000Z" },
"obligations": [{ "kind": "over-limit" }]
}{
"outcome": "granted",
"matched": {
"role": "clerk",
"permission": "invoice.pay",
"name": "clerk-pays",
"meta": { "x": { "ticket": "FIN-1" } }
},
"token": "sha-256:…",
"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": "sha-256:…"
}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, per, resetsAt }: the grant's count, its window in seconds and the RFC 3339 instant 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 RFC 3339 instant 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? } as RFC 3339 instants; 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. Every sink event may carry x, the application's own plain-JSON data: on a decision event it is DecideOptions.x, and on the other events the x passed to the builder (membershipEvent, credentialEvent, accessEvent, automationEvent, deliveryEvent). It is never read by a decision.
{
"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": { "scope": "post:read post:update" }
},
"tenant": "o_1",
"membership": { "scope": "tenant", "id": "o_1", "roles": ["member"] },
"via": null,
"denials": [{ "role": null, "reason": "not-delegated" }],
"alternatives": ["post.read", "post.update"],
"trusted": false,
"call": "adapter",
"adapter": "mcp",
"model": "openai/gpt-5"
}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. subject.supportSession is { id } when the subject came from subjectFromSupportSession: the session's support actor and its context.supportSession. The id equals session.id on the session's access events, so a session's decisions match on it exactly (support sessions). 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).
at and every expiresAt on an event are RFC 3339 instants. call names the API that produced the decision (can, decide, assert, adapter, …), and subject.delegation is the wire delegation every format shares: OAuth scope as one space-separated string and RFC 9396 authorization_details. model is the model id when the decision is about a model or a model drove the call: the agent model gate sets it on every decideModel and permittedModels decision, and any call can pass it as DecideOptions.model. PermDock never reads it when deciding, and permdock/otel copies it to gen_ai.request.model. The published JSON Schema of each family is https://permdock.com/schemas/<family>-event-v1.json (decision, directory, membership, credential, access, automation, delivery, catalog), exported as SINK_EVENT_SCHEMAS (every family but catalog), one <FAMILY>_EVENT_SCHEMA constant per family and the URLs in EVENT_SCHEMA_IDS. parseSinkEvent(value) and parseSinkEvents(value) from permdock validate raw events against them and never throw, so a receiver such as PermDock Cloud needs no validator of its own.
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, per, 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.9.0 Authorize Session class (class_uid3003, category Identity and Access Management,activity_id1,type_uid300301), pinned asOCSF_VERSION. An event whoseatdoes not parse projects toundefinedrather than totime: 0.outcomesetsstatus_id(1Successforgranted, 2Failurefordenied, 99 withstatus: "Approval Required"forapproval-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,call,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 User Management class (class_uid3007, which replaces the Account Change class OCSF 1.9.0 deprecates:activity_id4 Enable andtype_uid300704 forstarted,activity_id5 Disable andtype_uid300705 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, source, subject, id, time, datacontenttype: 'application/json', dataschema, traceparent?, tracestate?, data }in the JSON format (application/cloudevents+json; a batch is a JSON array,application/cloudevents-batch+json).toCloudEvent(event, { source })builds it:sourcemust be an absolute URI naming the producer, such as the app's origin, and the function throws aTypeErrorotherwise;timeis the event'sat;dataschemais the family's schema URL;traceparentandtracestateare the CloudEvents distributed-tracing extension (W3C Trace Context). PermDock Cloud webhooks, its exports and any queue or HTTP sink that forwards events to another system use it. A Cloud webhook delivery is a CloudEvents batch body signed with Standard Webhooks: thewebhook-id,webhook-timestampandwebhook-signatureheaders,v1HMAC-SHA256 orv1aEd25519. Thepermdock/cloudsink posts each flush as a CloudEvents batch (application/cloudevents-batch+json, authenticated by the environment key, toPOST /v1/environments/:env/decisions), withsourceset to thesourceoption or the environment URL.subjectis the permission key for decision and approval events, the SCIM resource id for directory events, the principal id for membership and support-session events, the credential id for credential events, the automation id for automation events and the delivery id for delivery events.parseCloudEvent,parseCloudEventBatchandparseCatalogEventDatafrompermdockvalidate envelopes against the closed type list and thedataschema, and never throw;permdock/cloudre-exportsparseCloudEventtyped asPermDockCloudEvent(CloudEvents).
The CloudEvents type values are a closed list, com.permdock.<family>.<operation> (or com.permdock.decision), exported as CLOUD_EVENT_TYPES from permdock with CatalogEventData for the catalog payload; adding one is a wire-format change. RESERVED_CLOUD_EVENT_TYPES names types for a later release and is empty in this one:
type | data | Emitted by |
|---|---|---|
com.permdock.decision | A decision event (above) | Every DecisionSink |
com.permdock.approval.requested / .resolved / .expired / .cancelled | An approval event, phase matching the type, with the ApprovalRequest below | Every DecisionSink; the Cloud webhook and paging connectors |
com.permdock.directory.created / .replaced / .patched / .deleted | 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) |
com.permdock.membership.added / .removed / .changed | A membership event: who gained or lost roles, from SCIM, Better Auth, Clerk or the app | membershipEvent(), scimHandler group changes, onRoleChange({ sink }) |
com.permdock.credential.created / .used / .rotated / .revoked | A credential event: an API key created, used (sampled, with sample), rotated or revoked (below) | credentialEvent(), memoryCredentialStore({ sink }), subjectFromApiKey(key, { sink }) |
com.permdock.catalog.published / .drifted | { 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) |
com.permdock.support-session.started / .ended | An access event: a support-access session began, or lapsed or was revoked (data.operation keeps ended and revoked apart), with tenant, principal, via, roles, member, expiresAt, grantedBy, reason and actor (elevated access) | accessEvent(), supportSessionEvent() (adds session and permissions for a support session); accessToOcsf projects it onto OCSF User Management (class_uid 3007) |
com.permdock.automation.published / .changed / .revoked / .run | An automation event: automation { id, version, mode, name? }, principal, publishedBy, tenant, roles, the delegated permissions, and run { id } on run events only (automations) | automationEvent(); WorkflowPermDock.start writes run to sink |
com.permdock.delivery.sent / .delivered / .bounced / .complained / .failed | A delivery event: one per recipient and channel, with delivery { id, key }, recipient, permission, category, approval, provider, and cause plus retryable on failed only (delivery) | DeliveryPermDock.deliver, deliveryEvent(), resendWebhook; the Cloud delivery log joins them to the approval or notification |
Changing either projection is a wire-format change and follows the versioning rule below.
A drift finding names why a published catalog drifted. code is a closed list, exported as CatalogFindingCode. Four codes mean the catalog broke a hosted grant, which the Cloud then suspends and leaves out of the next policy document: 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). These carry permission, the key, and grant, the hosted grant id when one broke. Two codes compare the catalog's operations by name and only report: operation-risk-raised carries { permission, operation, previous, risk }, where meta.risk rose from previous to risk (an unset risk is low), and operation-approval-removed carries { permission, operation }, an operation whose permission no longer lists approvals. operationFindings(previous, next) in permdock/catalog computes both, and permdock diff prints them. parseCloudEvent rejects any other code, an operation-* finding without operation, 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": "2026-10-22T14:13:20Z",
"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, the service principal or the feed 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, service or feed, 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. The JSON Schema is schemas/credential-v1.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": "2026-09-22T14:13:20Z",
"expiresAt": "2026-10-22T14:13:20Z",
"name": "deploy pipeline"
}kind is user (the key acts as principal, its owner), service (the key is the service principal principal, holding roles in tenant) or feed (the key is a feed principal bound to one resource). tenant and roles are required for a service credential. A user credential never carries roles or on; its optional tenant holds the key to the owner's memberships inside that instance of the first scope, with no global role. A feed carries on ({ resource, id }) and roles, and omits expiresAt. 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 RFC 3339 date-times; expiresAt is absent on a feed, and on a user or service key 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. An older parser that rejects kind: 'feed' fails closed. 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 and an id, which is the jti of the signed approval; 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. x is the application's own plain-JSON data, set through requestApproval(store, decision, { x }).
{
"v": 1,
"id": "apr_01J8…",
"token": "sha-256:…",
"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": { "scope": "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 sha-256: followed by the base64url SHA-256 of the RFC 8785 JSON 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 sha-256: 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 } with at in RFC 3339, oldest first, and a delegated approval also carries onBehalfOf (the delegator's principal id) and delegation (the delegation id). Both are optional v: 1 fields. A verdict's onBehalfOf is server-side input only: the delegator's subject and that subject's relation and permission facts, which the store checks again and does not store. status turns approved with the one that meets the quorum, who is also resolvedBy. approvers.escalation names who else may approve once after seconds have 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, and subject.delegation is the token's scope string and authorization_details. The record never carries the resource object, the policy, or tokens from authInfo. On the wire, the HTTP resume header is PermDock-Approval: <token>.
Access review
Stored by an AccessReviewStore. See access reviews. Both records set v: 1.
An AccessReview holds id, tenant, permissions (permission keys), reviewers (the same approver object an approval request stores), createdBy, createdAt, dueAt, status (open or closed) and complete. complete: false means the population may have missed holders the membership list cannot see.
An AccessReviewItem holds id, review, principal, permission, via (role or relation names), and status (pending, kept or revoked). decidedBy, decidedAt and note appear once decided. appliedAt appears once the application has changed the source for a revoked item.
Policy document
Hosted grants travel as a PolicyDocument (hostable permissions). Signed as a vnd.permdock.policy+jwt, the document's id is the token's jti, issuedAt is its iat, and the policy claim carries the other fields; policyDocumentFromClaims(claims) rebuilds the document. The JSON Schema of the claims is schemas/policy-v1.json. 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": "sha-256:Ux3f…",
"catalog": "sha-256:b41c…",
"issuedAt": "2026-09-09T00:25:00Z",
"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. issuedAt is RFC 3339. parsePolicyDocument rejects a v other than 1, a missing envelope field, a numeric issuedAt and any __proto__, constructor or prototype key; grant-level problems drop only that grant.
Capability
A share link travels as the capability claim of a vnd.permdock.capability+jwt (link capabilities). on has the shape of a resource membership's on, and the resolver turns the claim into exactly that membership. The link id is the token's sub and its expiry the token's exp; neither repeats inside the claim.
{
"iss": "https://app.example.com",
"aud": "https://app.example.com",
"sub": "lnk_01J8…",
"iat": 1791000000,
"exp": 1791600000,
"jti": "0b0c6f0e-…",
"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" },
"capability": {
"v": 1,
"holder": "link",
"on": { "resource": "quote", "id": "q_1" },
"roles": ["guest"],
"permissions": ["quote.read"],
"redeemer": "anyone",
"once": true
}
}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. cnf.jkt (RFC 7800) is optional: it binds the link to one key, and subjectFromCapability then resolves it only with a DPoP proof (RFC 9449) from that key. parseCapabilityClaim rejects a v other than 1 or a wrong type in any of these fields, and reads own properties only (never __proto__, constructor or prototype); unknown fields are dropped. capabilityFromClaims(claims) adds id from sub, expiresAt from exp and cnf, and is undefined when any of them is invalid. The JSON Schema of the claims is schemas/capability-v1.json. The Supabase access token exchangeCapability mints carries the claim with expires_at (NumericDate), which the generated permdock_capability_ids helper reads.
Signed outputs
Seven artefact types leave the process as compact JWS (RFC 7515) signed by a TokenSigner (extension interfaces, JOSE). The registered claims are authoritative: an artefact's issue time, expiry and id move to iat, exp and jti (or sub), and the payload never repeats them. Anything that can verify a JWS against a JWK Set, in any language, can verify these.
The envelope is the same for all of them:
- 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 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,expandjti(RFC 7519 section 4.1), all required.subis present when the artefact is about one principal and equalssubject.principal.id(for a capability, the link id). The actor isact(RFC 8693). Times are NumericDate seconds. - Private claims carrying the artefact, snake_case, never at the IANA JWT registry.
typ | Private claims | Producer | Consumer |
|---|---|---|---|
vnd.permdock.snapshot+jwt | snapshot: the Snapshot without issuedAt and expiresAt | permdock.snapshot({ signer, audience }), signSnapshot(snapshot, signer, { audience }) over snapshotClaims(snapshot); a SnapshotSource (permdock/cloud always signs) | The client PermDockProvider through joseTokenVerifier, or any JOSE library |
vnd.permdock.approval+jwt | act, token, resolved_by, approvals, one authorization_details entry (RFC 9396) for the approved action; jti is the request id | approvalsHandler({ signer }) on resolve and signApproval(request, signer, { audience }), for a resume that crosses services | The resuming adapter, which still re-runs decide and recomputes the bound token |
vnd.permdock.policy+jwt | policy: the PolicyDocument without id and issuedAt | PermDock Cloud, for each published hosted-grant revision, with signPolicyDocument(document, signer, { audience }) | cloud().policies.refresh() through the application's TokenVerifier, with iss and aud checked against the environment URL |
vnd.permdock.capability+jwt | capability, optional cnf | signCapability(input, signer, { audience }) in the application | subjectFromCapability in permdock/jwt, with iss and aud required |
vnd.permdock.run+jwt | v, act, scope, authorization_details and the run fields (run tickets) | signRunTicket | subjectFromRunTicket in permdock/server |
vnd.permdock.support-session+jwt | v, act ({ sub, kind: 'support' }), tenant, reason, permissions, approved_by, reference; sub is the viewed principal, jti the session id | signSupportSession | subjectFromSupportSession in permdock/server (support sessions) |
vnd.permdock.evidence+jwt | events: CloudEvents 1.0 events from a sink | signDecisionBatch, memoryDecisionSink({ signer }) (audit); permdock/cloud exports | A SIEM, an auditor, a compliance tool verifying provenance offline |
at+jwt | roles, memberships, tenant and one permdock_approval authorization_details entry: id, status, permissions, relations, etag?, vouched?; RFC 9068 sub is the approver's principal id, exp is iat + 60 seconds, and act is never present | cloud({ approver }), sent as PermDock-Approver on approvals.resolve (approver token) |
Each signed format has a JSON Schema in the permdock package: schemas/snapshot-v1.json (the unsigned snapshot), schemas/approval-signature-v1.json, schemas/policy-v1.json, schemas/capability-v1.json, schemas/run-v1.json, schemas/support-session-v1.json and schemas/evidence-export-v1.json (the claims sets). schemas/connector-v1.json is the unsigned Cloud connector body. schemas/cloud-api-v1.json is the OpenAPI 3.1 document of the PermDock Cloud API, not a JSON Schema. Import one as permdock/schemas/<name>.json, or fetch its $id, https://permdock.com/schemas/<name>.json, which serves the file of the latest release with Access-Control-Allow-Origin: *. snapshotFromClaims reads a verified snapshot token back into a Snapshot, policyDocumentFromClaims a policy token and parseApprovalRequest (permdock/approvals) an approval request record.
Signed snapshot, decoded:
{
"alg": "Ed25519", "kid": "2026-09", "typ": "vnd.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, "subject": { "…": "…" }, "roles": ["member"], "grants": ["…"], "tenants": ["o_1"], "include": ["post"] }
}Signed approval, decoded payload:
{
"iss": "https://app.example.com",
"aud": "https://app.example.com",
"sub": "u_1",
"act": { "sub": "eve:app" },
"iat": 1788992431,
"exp": 1788993900,
"jti": "apr_01J8…",
"token": "sha-256:…",
"resolved_by": "u_9",
"approvals": [
{ "by": "u_7", "at": "2026-09-06T10:18:02Z" },
{ "by": "u_9", "at": "2026-09-06T10:20:31Z" }
],
"authorization_details": [
{ "type": "filing", "actions": ["pay"], "identifier": "42" }
]
}Rules that hold for every signed output:
expis the artefact's own expiry (a snapshot'sexpiresAt, an approval request'sexpiresAt, a link's expiry) when it has one; otherwiseiatplus one hour. A verifier appliesexpbefore reading a private claim.iatis the artefact's own time: a snapshot'sissuedAt, an approval'sresolvedAt, a policy document'sissuedAt.snapshotFromClaimsandpolicyDocumentFromClaimsread them back as RFC 3339.subis the principal a consumer can route or cache on without parsing a private claim.- A signed approval never replaces the bound hash
token: the resuming adapter verifies the JWS, then comparestokenwith 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 evidence export 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,sub,cnf, 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. - The
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, evidence exports 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": "sha-256:…"
}
}
}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": "sha-256:…"
}
}
},
{
"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 --kind catalog. 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.4.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? } as RFC 3339 instants, 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 --kind 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. When permdock.config.ts names an operations module, operations lists its defineOperations entries in definition order as { name, permission, results?, meta }, with permission keys. When the policy declares oauthScopes, oauthScopes lists them sorted by scope as { scope, permissions }, with permissions the keys the coarse scope covers sorted by code point; authorizationProvider reads it for better-supabase's delegation.scopes. The field is optional, so the format stays version: 1, and a reader of an older catalog sees no coarse scopes.
fingerprint identifies the catalog's contract and is what a hosted policy document pins as catalog. It is catalogFingerprint(catalog) from permdock: a sha-256: digest of the RFC 8785 JSON 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": "sha-256:...",
"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.
Catalog push response
POST /catalog on PermDock Cloud answers 201 { version, fingerprint, findings } for a new catalog version, where findings are the CatalogFinding items of a com.permdock.catalog.drift event, or 200 { version, fingerprint, unchanged: true } when the fingerprint is already published. cloud().catalog.push returns it as CloudCatalogPush (PermDock Cloud).
Pushed policy
The policy of a permdock cloud push body is a PushedPolicy v1, validated by schemas/pushed-policy-v1.json and built by pushedPolicy(policy):
{
"v": 1,
"fingerprint": "sha-256:…",
"scopes": [{ "name": "organization", "key": "organization_id" }],
"grants": [
{
"permission": "invoice.read",
"effect": "allow",
"role": "member",
"to": { "kind": "role", "role": "member", "scope": "organization" },
"where": {
"op": "eq",
"field": "ownerId",
"value": { "ref": "principal.id" }
}
},
{
"permission": "invoice.share",
"effect": "allow",
"role": "member",
"to": { "kind": "role", "role": "member", "scope": "global" },
"portable": false,
"closure": true
}
]
}fingerprintis the code policy'sfingerprint;scopesappears only when the policy declares scopes.- Each grant carries the snapshot grant fields (
permission,effect,role,to,where,check,approval,scope,fields) pluslimit,validity,name,purpose,requires,viaOnly,breakGlass,obligationsandmetawhen the grant has them. portable: falsemarks a grant a snapshot cannot evaluate.closure: truemarks one whose condition is code: the function never travels, andpolicyFromCatalog(catalog, pushed)turns it into an allow that never matches and a deny that always applies.
Project policy
GET /v1/projects/{id}/policy on PermDock Cloud answers what the project's last push sent: { v: 1, project, version, fingerprint, permdockVersion, catalog, manifest?, policy? }. catalog is a catalog-v1 document, manifest a Supabase hook manifest and policy a PushedPolicy, each with its JSON members in the order they were pushed, so permdock cloud pull writes the same bytes as collect and supabase inspect --out. fingerprint equals catalogFingerprint(catalog). A project with nothing pushed answers 404 with the policy-not-published problem (Problem Details).
Connector
A PermDock Cloud connector is { id?, kind, name, config }. The JSON Schema is schemas/connector-v1.json, one oneOf branch per kind, each config closed with additionalProperties: false and every URL https://:
{
"kind": "webhook",
"name": "Ops alerts",
"config": {
"url": "https://ops.example.com/permdock",
"types": ["com.permdock.approval.requested"]
}
}The kinds are webhook, otlp, siem, evidence, scim, caep-transmitter, federated-upstream, chat, email and vercel-oidc (Cloud integrations). A secret the Cloud mints, such as a webhook signing key, is never part of config; the Cloud generates it and shows it once.
otlp.config.headers and siem.config.headers are the only fields that carry a credential the collector issued, such as a Splunk HEC Authorization: Splunk <token> or a Datadog DD-API-KEY. A credential never goes in the URL, because URLs reach access logs. A store seals every header value at rest, and a read or a listing answers the header names only. headers on siem was added within v1: a validator built from the earlier connector-v1.json rejects it, and every body that was valid before stays valid.
parseConnector(input) in permdock/delivery checks a body against this schema with no dependency. It answers { ok: true, connector }, where connector is a frozen Connector, or { ok: false, issues }, where each issue has an RFC 6901 JSON pointer path and a message. The branch is chosen by kind, so an issue names the field (/config/url) instead of the oneOf. CONNECTOR_KINDS lists the kinds in schema order, and the test suite checks that parseConnector and the JSON Schema agree on every fixture.
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. Membership keys are snake_case: expires_at (NumericDate), granted_by and managed_by. 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
The data 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/schemas/public/functions/custom_access_token_hook.sql",
"schemas": "pg-delta"
},
"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.schemasis the schema workflow the hook file is written for:pg-delta(supabase db schema declarative sync),migra(supabase db diff) ormigrations(hand-written). A manifest without it meanspg-delta. better-supabase reads it to name the command that restores the hook's grants.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:junctionMembershipSourcehas 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 (tableMembershipSource) or{ "columns": { "<scope>": "<column>" } }(junctionMembershipSource).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),permitted_<scope>_ids_for(p_user uuid, p_grant text)and, for each scope whose memberships are in a table,permitted_<scope>_users(p_permission text, p_id)(permission holders); withrls.readOnlyActors,permdock_actor_may_write(), which write SQL a package generates calls (read-only actors); 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.namesis every SQL name the generated RLS gives application SQL, unqualified, innames.schema: per scopepermittedIds,permittedIdsFor,memberIds,memberIdsFor,permittedUsersand, on the team scope (teamorteams, also inteamScope),memberIdsOf; per resource its mappedtable, the graph helperspermittedIdsandrestricted, therls.rowHelperspairrowsandrow, and withrls.fieldsthevisible,visibleFieldsandmaskobjects.gucPrefixandclaims(tenant,role) are what the helpers read.permdock genand readers that write SQL next to the helpers take names from here instead of spelling them. Manifests written before 0.8 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.
CLI result
Every permdock run prints one envelope on stdout under --json, --format json or agent mode. Its JSON Schema is schemas/cli-result-v1.json in the permdock package. status is success, error or action_required; data is the command's report; error is Problem Details with a stable snake_case code from the errors page; next lists the commands that move the run forward.
{
"$schema": "https://permdock.com/schemas/cli-result-v1.json",
"status": "action_required",
"error": {
"reasons": ["rewrite"],
"token": "6f3a9c0e41b27d58",
"type": "https://permdock.com/docs/cli/errors#consent_required",
"title": "Consent required",
"detail": "permdock codemod edits files the project owns. Ask the person to agree, then run it again with --confirm 6f3a9c0e41b27d58",
"code": "consent_required",
"command": "codemod",
"exitCode": 3
},
"next": [
{
"when": "after the person agrees",
"command": "permdock codemod 0.5 --confirm 6f3a9c0e41b27d58"
}
]
}permdock --help --json prints the command list in the cli-commands-v1 format (schemas/cli-commands-v1.json): every command's name, path, description, docs page, examples, side effects, arguments and options, from the manifest the parser reads. Both formats only add fields within v1.
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: sha-256:…",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post", "id": "42" },
"reason": "human",
"token": "sha-256:…"
}{
"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. PermDock defines nine types (denied, approval-required, validation, step-up-required, not-entitled, rate-limited, limit-unavailable, invalid-signature, approver-not-eligible); every other status answers about:blank with the RFC 9110 reason phrase as title. PROBLEM_EXAMPLES from permdock/testing holds one body per type.
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" }]Time rule
Every format writes times one way per context:
| Context | Form | Examples |
|---|---|---|
| JWT claims and claims SQL reads | NumericDate (whole seconds since the epoch, RFC 7519) | iat, exp, membership expires_at |
| JSON records and HTTP bodies | RFC 3339 instant | snapshot issuedAt, grant validity, approval expiresAt, credential createdAt, quota.resetsAt |
| Durations | Integer seconds, at least 1 | approval.ttl, escalation.after, limit.per, maxDuration, cache.ttl, revalidate |
Runtime now options | An Instant, { epochMilliseconds } | decide(…, { now }), compileWhere(…, { now }), requestApproval(…, { now }) |
A Temporal.Instant is an Instant. A duration that is not a positive integer throws when the policy, catalog or option is defined. A now that is not an Instant makes decide, can and the snapshot client deny with reason validation; compileWhere throws a TypeError and the approval helpers throw a RangeError. Network and polling timings stay in milliseconds: the client's approvalInterval and the PDP and JWT verifier timeout.
Digests
Every digest PermDock writes is sha-256: plus the unpadded base64url SHA-256 of a text, and every hashed JSON value is first serialised as RFC 8785 (JCS). permdock exports both steps: canonicalJson(value) and sha256Digest(text). The approval token, the approval ETag (approvalEntityTag) and catalogFingerprint are built from them, so a service that recomputes one calls the same two functions instead of its own serialiser.
Versioning
- The snapshot
v, the approval requestv, the policy documentv, the capabilityv, the credentialvand the catalogversionfollow the format, not the package version, and are all1. Times follow the time rule. 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(vnd.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, andxon an approval request, on every sink event and on a SCIM User or Group) was added this way: every field is optional, plain JSON, and changes no outcome. The event schemas setadditionalProperties: false, so each listsx; a consumer that validates against a copy of a schema from beforexmust refresh it. 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.