Wire formats
The JSON shapes PermDock reads and writes, permission leaves, conditions, snapshot v2, 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. Field names marked provisional may still change before the core implementation lands and will be versioned when they do.
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 "kind": "collection".
Condition
The normalised form of { where: { authorId: subject.id, teamId: { in: subject.context.teamIds } } }. See conditions.
{
"op": "and",
"conditions": [
{ "op": "eq", "field": "authorId", "value": { "ref": "subject.id" } },
{ "op": "in", "field": "teamId", "value": { "ref": "subject.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": "subject.<field>" } or { "ref": "subject.context.<key>" } |
| Date | { "date": "2026-09-06T10:15:00Z" } |
| Scope (from scoped roles) | { "op": "memberOf", "scope": "tenant" | "team" | "resource", "field": string, "roles": string[], "resource"?: string, "parents"?: string[] } (tenancy) |
| 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 are provisional.
Snapshot v2
Output of permdock.snapshot({ include: [permissions.post] }). See snapshots. Version 2 adds subject.principal.memberships, subject.principal.tenant, per-grant scope and membership, and the top-level tenants and simulated fields (ADR 0024); readers still accept v1.
{
"v": 2,
"issuedAt": "2026-09-06T10:15:00Z",
"subject": {
"principal": {
"id": "u_1", "roles": [], "tenant": "o_1",
"memberships": [
{ "tenant": "o_1", "roles": ["member"] },
{ "tenant": "o_1", "team": "t_design", "roles": ["lead"], "via": "group:9f2c" }
]
},
"delegation": { "scopes": ["post:read", "post:update"] },
"context": {}
},
"roles": ["member", "lead"],
"grants": [
{ "permission": "post.read", "effect": "allow", "role": "member", "scope": "tenant" },
{ "permission": "post.update", "effect": "allow", "role": "member", "scope": "tenant",
"where": { "op": "eq", "field": "authorId", "value": { "ref": "subject.id" } } },
{ "permission": "post.publish", "effect": "allow", "role": "lead", "scope": "team",
"membership": { "tenant": "o_1", "team": "t_design" } },
{ "permission": "post.delete", "effect": "allow", "role": "member", "scope": "tenant", "approval": "human",
"where": { "op": "eq", "field": "authorId", "value": { "ref": "subject.id" } } },
{ "permission": "post.archive", "effect": "allow", "role": "member", "scope": "tenant", "portable": false }
],
"tenants": ["o_1"],
"include": ["post"]
}A grant entry has permission, effect (allow or deny), role, optional where, check, approval, scope ("tenant", "team" or { "resource": string }) with the membership that supplied the role, and portable: false when the server-side grant is a closure or opaque. tenants lists the tenants whose grants are included; simulated: true marks a preview snapshot the decision endpoint must refuse. Readers must reject a v they do not know.
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.
{ "tenant": "o_1", "team": "t_design", "roles": ["lead"], "via": "group:9f2c", "expiresAt": 1789000000 }{ "on": { "resource": "document", "id": "d_9" }, "roles": ["editor"] }{ "tenant": "o_1", "name": "billing-manager", "includes": ["billing-viewer", "invoice-payer"], "meta": { "title": "Billing Manager" } }Exactly one of tenant-only, tenant plus team, or on applies to a membership; a CustomRole.includes entry names a declared role marked assignable.
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" }, "token": "pd1.…",
"subject": { "principal": { "id": "u_1", "roles": ["member"] } } }{ "outcome": "denied",
"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.
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"
}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>) when the provider recorded one; a tenant-scoped audit log is a filter on tenant (audit and observability).
Two documented projections leave the event unchanged and exist so DecisionSink implementations agree with each other (audit and observability):
- OCSF.
toOcsf(event)maps a decision event onto the OCSF Authorization activity class:outcometostatus,denials[].reasontostatus_detail,permissionandscopeto the resource and privilege fields,subject.principaltoactor.user,subject.actorto the acting application,adapterandsourcetometadata.product. The exact class and field names are pinned to one OCSF version when the function ships and recorded here. - CloudEvents 1.0 envelope. On the wire, each event is
{ specversion: '1.0', type: 'dev.permdock.decision' | 'dev.permdock.approval', source: '<service>', subject: '<permission key>', id, time, datacontenttype: 'application/json', data: <event> }. The PermDock Cloud sink and any queue or HTTP sink use it.
Changing either projection is a wire-format change and follows the versioning rule below.
Approval request v1
Stored by an ApprovalStore and exchanged with PermDock Cloud. See approvals and the approvals adapter.
{
"v": 1,
"token": "pd1.…",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post", "id": "42" },
"subject": {
"principal": { "id": "u_1", "roles": ["member"] },
"actor": { "id": "eve:app", "kind": "eve" },
"delegation": { "scopes": ["post:delete"] }
},
"detail": "post.delete requires human approval (human).",
"adapter": "eve",
"createdAt": "2026-09-06T10:15:00Z",
"expiresAt": "2026-09-06T11:15:00Z",
"status": "approved",
"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. The record never carries the resource object, the policy, or tokens from authInfo. On the wire, the HTTP resume header is PermDock-Approval: <token>.
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, and puts actor, delegation and the full Decision under context. 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.…" } }
}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, the resource JSON Schema and grants-by-role. See catalog.
{
"v": 1,
"generatedAt": "2026-09-06T10:15:00Z",
"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"] }
}
},
"permissions": [
{ "key": "post.update", "scope": "post:update", "resource": "post", "action": "update", "kind": "instance",
"meta": { "title": "Edit post" },
"usedIn": ["src/features/posts/edit-button.tsx:14", "src/app/posts/[id]/page.tsx:22"],
"grantedBy": ["member", "admin"] },
{ "key": "post.create", "scope": "post:create", "resource": "post", "action": "create", "kind": "collection",
"usedIn": [], "grantedBy": ["member", "admin"] }
],
"roles": {
"member": { "on": "tenant", "assignable": true, "meta": { "title": "Member" }, "grants": ["post.read", "post.update", "post.create"] },
"admin": { "on": "tenant", "assignable": true, "meta": { "title": "Admin" }, "grants": ["post.delete", "member.invite"] },
"support": { "assignable": false, "grants": ["post.read"] }
},
"scopes": { "tenant": { "key": "orgId" }, "team": { "key": "teamId" } }
}roles and scopes are new in this shape (ADR 0024): the role editor recipe on UI renders the assignable entries, and permdock usage reports scoped roles no membership source can fill. 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 byte-for-byte with a fresh run.
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/cli@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 (ADR 0025). The shape is versioned by v like every other extension; drafts is added before the first release, so v stays 1.
Problem Details
application/problem+json bodies from the HTTP adapters. See errors and Problem Details.
{
"type": "https://permdock.dev/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.dev/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.dev/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 a placeholder until the docs domain is final.
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
vfields on the snapshot, approval request and catalog follow the format, not the package version. A breaking change to a shape bumpsv; readers reject unknown majors.- Condition, Decision, event and Problem Details shapes are additive within a major: new optional fields may appear, existing fields keep their meaning.
- AuthZEN messages follow the 1.0 spec; PermDock-specific data lives only under
context.permdock.
Open questions
- Condition field names (
op,field,value,ref,date, thememberOfnode) and snapshot grant field names (effect,portable,scope,membership). - Whether decision events should carry the full matching
membershipor only its scope identifiers. - Whether the catalog should embed resource JSON Schemas or reference a separate
resources.schema.json. - How much of the
Decisionto expose in AuthZENcontextto non-PermDock PEPs, and whetheralternativesshould be answered only throughsearch/action. - The Problem Details base URI.
Errors
PermDock throws three error classes, each carrying the Decision or issues that caused it, and adapters map them to Problem Details and model-readable refusals.
Adapters
One core, one Fetch-first server kernel, and thin typed adapters for UI frameworks, HTTP servers, RPC layers, agent runtimes, the decision plane, databases and auth providers.