Delegation
The principal, actor and delegation model, the attenuation invariants that keep an agent from exceeding its user, how adapters fill the agent half of the subject, and how RAR authorization_details are emitted and verified.
A PermDock subject has two halves. principal is the human or service whose grants are evaluated; actor is the agent acting for them; delegation is the authority the principal handed over. A decision is the principal's grants intersected with the delegation. This page is the security view of that model; the API view is in subject, and the standards behind it in OAuth for agent delegation.
The three parts
const permdock = await createPermDock(policy, user, {
actor: { id: "https://agent.example/cimd.json", kind: "mcp-client" },
delegation: {
scopes: ["post:read", "post:update"],
authorizationDetails: [{ type: "post", actions: ["update"] }],
},
});| Part | Who supplies it | What it is | Trust |
|---|---|---|---|
principal | definePolicy's subject function from the verified session or token | The user's id, org, roles and other values behind subject.* | Trusted after authentication |
actor | The adapter, from authInfo.clientId, runtime context, signature or Agent Card | Who is making the call | Trusted after the runtime verified it; never from prompt content |
delegation | The adapter, from token scopes, authorization_details, a GNAP access array or a delegation chain | What the principal allowed the actor to do | Trusted after token verification; intersected, never added to |
A call with no actor is a human call and behaves exactly as createPermDock(policy, user). A call with an actor and no delegation (or a delegation with no scopes, authorizationDetails or access) has no delegated authority unless a policy delegation covers the actor: every check is denied with reason no-delegation, and the snapshot carries an empty scopes list so a client ceiling agrees. Token-based adapters supply the token's scopes; in-process agent adapters take a delegation option from the application and apply none by default.
Attenuation invariants
- Agent ≤ user. For every permission,
asAgent.can(p, x)impliesasUser.can(p, x). Delegation can only remove or narrow grants. This is enforced by construction:decideevaluates the principal's grants first, then filters by delegation. - Chain hops only narrow. In a multi-hop chain (user to orchestrator to sub-agent), each hop's authority is the intersection of the previous hop's authority with what it passes on. The token layer enforces this when it issues the token; core carries
delegation.chainopaquely for audit and evaluates only the finalscopes,authorizationDetailsandaccess. - Conditions intersect. A grant's
whereis evaluated first and the delegation only filters the result, so a delegation cannot relax a grant condition. - Approval survives delegation. A grant with
approval: 'human'still requires approval when exercised through an agent; delegation cannot pre-approve. The approvaltokenincludesactor, so the approval is bound to the agent that asked. - Deny is not delegable away. A
denygrant applies regardless of delegation; an agent cannot be delegated around a deny. - Fail closed on unknowns. An
authorization_detailstype PermDock does not recognise, a scope that matches no permission, or a malformed chain contributes nothing; it never widens.
Policy delegations
A token says what one principal handed to one client for one session. Some delegations are standing policy instead: every member may let the company's Eve agent read and update posts for them; finance admins may let one named billing agent read invoices. Stating that in each token would repeat the policy in the authorization server, and the in-process agent adapters have no token at all. definePolicy takes delegations for this:
const policy = definePolicy(permissions, {
roles: [member, admin],
delegations: [
{
from: roles.member, // who hands over: the same selectors as approval.by
to: actor("eve"), // which actor kind; { kind, id } names one agent, { kind, client } one named OAuth client
permissions: [permissions.post.read, permissions.post.update],
validUntil: "2027-06-01T00:00:00Z", // optional, as on a grant
},
{
from: roles.admin,
to: { kind: "eve", id: "agent-billing" },
permissions: [permissions.invoice],
},
],
subject,
});Each entry is normalised to { from, to: { kind, id?, client? }, permissions: string[], readOnly: string[], validity? } on policy.delegations, where readOnly is the subset of permissions whose readOnlyHint is true, in the fingerprint, and in the catalog's delegations section, so permdock diff reports a removed or narrowed one as breaking. from takes a role, authenticated(), a plan or assurance(); relation() and actor() are refused, because a delegation is matched without a row and the actor is to. permissions names leaves or subtrees of the policy's own tree.
How it applies, in decide and on the snapshot client alike:
- When the subject has an
actor, the active delegations whosetomatches the actor'skind(andid, when set) and whosefromthe principal holds in the active tenant are united into a ceiling of permission keys.delegatedPermissions(policy.delegations, subject, heldRoles, now)is that computation, exported frompermdock;snapshot.delegatedcarries the sorted result to the client. An actor withreadOnly: true(a read-only Supabase support session) contributes only each delegation'sreadOnlykeys, so a write it was delegated still denies withnot-delegated. - A permission outside the ceiling is
deniedwith reasonnot-delegated. Inside it, a tokendelegationon the same call must still cover the permission (coveredByDelegationruns as before): a token can only narrow a policy delegation, never widen it. With no token delegation, the ceiling alone decides; theno-delegationdenial applies only when no policy delegation matched the actor. - The principal's grants are still evaluated first, so the ceiling adds nothing the user lacks, a
wherestill filters, adenystill wins, and an approval is still required. - A delegation is revoked by removing it from the policy or by its
validUntil. TheRevocationFeedstays a connection signal: it ends sessions and never grants or shapes a decision, so dynamic revocation of a live delegation goes through the token layer orcontext, never through the feed.
Why
- A standing delegation belongs in the policy. "This agent kind may act for members on these permissions" is a statement about the application, the same kind as a grant, and it was being made in three places with no shared source: the AI SDK
delegationoption, the token scopes an authorization server mints, and prose. Putting it indefinePolicygives it a fingerprint, a catalog entry, adiff, and a policy-matrix vector, and letspermdock doctorand an audit see which actors may act for whom. - It is a ceiling, not a grant. The design that would have been easiest, treating a delegation as a grant to the actor, breaks "Agent ≤ user": an agent would hold access the user does not. So the delegation never enters the allow list; it only replaces the token in the delegation check, after the principal's grants have decided. Every attenuation invariant above holds without a special case.
- Token and policy intersect. When both are present the call must satisfy both, because either one may be the narrower statement: the token knows this session's consent, the policy knows the application's standing rule. Letting one override the other would make the wider one the effective rule.
- A client name, not a client id. An authorization server assigns client ids per environment and at dynamic registration, so an id in the policy would make its fingerprint and catalog differ per deployment.
to: { kind, client }names the client; the subject resolver maps the verified client id to that name throughclients(ClientNames) onsubjectFromSupabase,permdock/mcpandpermdock/jwt'sactoroption, and setsactor.client. An id the mapping does not name, or names twice, leavesclientunset, so the delegation fails closed. The name comes only from a verified id, never from the token's own claims. - Actor kind, optionally id. Agents are typically a fleet of one kind (
'eve','mcp-client'), so the kind is the natural unit; a single trusted agent is{ kind, id }. Matching only on id would make a renamed agent silently lose its delegation, matching only on kind could not express a one-agent rule. - The feed does not revoke a delegation.
RevocationFeedmeans "end this session" and is kept that way bynaming.mdc; a feed that also edited the policy would be a second decision path (invariant 15). Removing the entry, or lettingvalidUntilpass, is the revocation, and both are visible in the catalog anddiff.
Coarse OAuth scopes
A permission's OAuth scope is its key with : for . (task:read). An authorization server often issues coarser scopes instead (mcp:read, mcp:write). definePolicy({ oauthScopes }) maps each coarse scope to the permissions it covers:
export const policy = definePolicy(permissions, {
roles: [...],
oauthScopes: {
"mcp:read": [permissions.task.read, permissions.task.list, permissions.project],
"mcp:write": [permissions.task.update],
},
subject,
});- When an instance is created, a token delegation's
scopeskeep their order and are followed by the scopes of every permission a held coarse scope covers, sodecide,snapshot,mayUseand the client snapshot all read the same expanded list. A coarse scope only adds what it lists; the user's grants and policy delegations still decide. permdock/mcpandpermdock/a2aaccept a coarse scope in their scope pre-check and list filter. Theirinsufficient_scopechallenges, and theWWW-Authenticatechallenge of the server kernel, name the first coarse scope (in declaration order) that covers the permission, because that is the scope the authorization server can issue; a permission no coarse scope covers is challenged with its own scope.- A key must be an RFC 6749 scope token that is not a permission's own scope, and must cover at least one declared permission;
definePolicythrows otherwise. The mapping is part of the policy fingerprint.
Coverage check
decide, snapshot and the agent kernel share one coverage check, exported from permdock as coveredByDelegation(permission, delegation, resourceId?, hasActor?). permission needs only scope, resource and action, so a leaf that crossed a serialisation boundary works. It returns undefined when the delegation covers the permission, and otherwise the denial reason decide adds:
no-delegationwhenhasActoristrueand there is no delegation, a delegation with noscopes,authorizationDetailsoraccess, or only emptyscopesandaccesslists.decidepasseshasActor: falsewhen a policy delegation covers the actor, so that denial is reserved for an actor nothing delegated to.not-delegatedwhen no scope equals the permission'sscope, noauthorizationDetailsentry hastypeequal to the resource with a matchingactionslist andidentifier, and no GNAPaccessentry matches (a reference string equal to the scope, or an object whosetypeis the resource or ends in/<resource>).
A PDP outside PermDock core, such as the PermDock Cloud AuthZEN endpoint, calls it to stay identical to local decisions instead of reimplementing the rules. It narrows only; the principal's grants still decide.
How adapters fill actor and delegation
| Adapter | actor | delegation |
|---|---|---|
permdock/mcp | authInfo.clientId (an OAuth client id or a CIMD URL), kind: 'mcp-client', or actorKind | authInfo.scopes; authorization_details when the token carries them (including ID-JAG-derived tokens under Enterprise-Managed Authorization) |
permdock/ai-sdk | actor: ({ runtimeContext }) => ({ id: runtimeContext.agentId, kind: 'ai-sdk' }) | The delegation option, resolved per call by the application; none by default, so an agent without it is denied |
permdock/claude-agent | The Claude Agent SDK session, kind: 'claude-agent' | The delegation option, resolved per session; none by default. The tools map separately bounds what the agent can even ask for |
permdock/openai | The actor option, from the run context | The delegation option, from the run context; none by default |
permdock/eve | actorFromSession: the current principal when it differs from the initiator, otherwise eve:app, kind: 'eve' | The delegation option, from the session; none by default |
permdock/a2a | The calling agent's identity from its card or token, kind: 'a2a' | Caller's token scopes and authorization_details |
permdock/supabase | act by its kind (oauth-client, support with sessionId and readOnly, impersonation), else client_id as oauth-client | For oauth-client only: the scope claim without the OpenID Connect identity scopes, and the act chain. Support and impersonation get none, so only a policy delegation reaches them |
| HTTP adapters with Web Bot Auth | Verified RFC 9421 signer key id, kind: 'web-bot-auth' | From the bearer token on the same request, if any; otherwise empty |
permdock/webmcp | The page's agent context, if the browser exposes one | The client snapshot is the ceiling; the server re-checks with the real delegation |
Human-only adapters (permdock/next, permdock/react) | None | None; principal grants apply directly |
The AI SDK case deserves a note: the SDK gives PermDock the agent's identity but not a token, so what the agent is delegated is an application decision, stated in the delegation option of createPermDock. There is deliberately no default: an in-process agent with no delegation is denied every check with reason no-delegation, so an application that wants the agent to act as the user lists those scopes explicitly. See the ai-sdk adapter.
One OAuth access token can reach an application through several adapters: an MCP tool that calls the app's own API with the caller's token is decided by permdock/mcp and then by permdock/supabase (or permdock/jwt). Those adapters name the client oauth-client, and permdock/mcp names it mcp-client unless actorKind says otherwise. Set actorKind: 'oauth-client' on the MCP adapter and on subjectFromMcp so the token is one actor kind everywhere and one policy delegation or actor('oauth-client') grant covers it; keep 'mcp-client' when MCP clients should get rules of their own.
RAR authorization_details
Every permission reference carries a scope and an authorizationDetails type. This lets PermDock participate in RFC 9396 Rich Authorization Requests in both directions:
- Emission for consent screens. Given a set of permissions an agent wants, PermDock produces
authorization_detailsentries whosetypeis the resource name,actionsthe actions andidentifierthe resource id for an instance action. The objects carry no condition payload: the grant's conditions stay in the policy and are evaluated on every call. - Verification on incoming tokens.
delegation.authorizationDetailsentries are matched to a permission bytypeequal to its resource, when presentactionscontaining its action, and when presentidentifierequal to the resource id (an entry with anidentifiernever covers a collection check). Unknown types contribute nothing (fail closed).
[
{ "type": "post", "actions": ["read"] },
{ "type": "post", "actions": ["update"], "identifier": "post_123" }
]Scopes remain the coarse layer: post:update in delegation.scopes is required for permissions.post.update to be exercisable at all; the authorization_details entry, when present, narrows it further.
GNAP access as a third input
RFC 9635 (GNAP) describes delegated rights as an access array whose objects carry type, actions, locations, datatypes, identifier and privileges, a structure the RFC itself calls analogous to RAR. delegation.access accepts those objects next to scopes and authorizationDetails. At a resource server the normative source is RFC 9767 (GNAP Resource Server Connections): permdock/jwt fills it from the access claim of a JWT-formatted access token (RFC 9767 sections 2.1 and 2.2), and subjectFromIntrospection fills it from the access array of an introspection response (section 3.3), which the AS may have filtered to what this RS is allowed to see (GNAP). The three fields are unioned into one delegated set before the intersection with the principal's grants: an unknown type contributes nothing, a type matches the resource name or a URI ending in /<resource>, identifier narrows to one resource, privileges never adds a role.
delegation: {
scopes: ['post:read'],
authorizationDetails: [{ type: 'post', actions: ['update'] }],
access: [{ type: 'https://api.example.com/permdock/resources/post', actions: ['update'], identifier: 'post_123' }],
}RAR metadata and error remediation
When a delegated check is denied with reason not-delegated, the caller's next move is a step-up: go back to the authorization server and ask for the authority that was missing. RFC 6750 only offers insufficient_scope and a flat scope hint, which cannot express "you need post.update restricted to posts you authored". The IETF draft OAuth 2.0 RAR Metadata and Error Remediation (August 2026) fills that gap with two pieces: authorization-server metadata describing the RAR type values it supports, and a structured remediation object returned on insufficient authorization that names the authorization_details the client should request.
PermDock already computes the content of that object. Decision.alternatives lists the permissions on the same resource that the principal holds and that the delegation could cover, and every permission carries an authorizationDetails type with its constraint payload. When the caller is an OAuth client, HTTP adapters and permdock/mcp therefore render a denied Decision as follows:
WWW-Authenticate: Bearer error="insufficient_scope"with thescopevalues of the alternatives, for clients that only understand RFC 6750.- The RFC 9457 Problem Details body with
alternativesexpressed asauthorization_detailsobjects in the draft's remediation shape, so a RAR-aware client can copy them straight into its next authorization request. - For MCP clients, the same alternatives inside the
scopeChallenge, since SEP-2350 step-up accumulates scopes (MCP authorization).
The mapping is one-directional and fail-closed: remediation tells the client what to ask for; it never changes the decision that produced it, and the authorization server remains free to refuse. The draft is a working-group document and its field names may change; the Problem Details alternatives member is stable regardless, and the remediation shape is emitted next to it rather than instead of it. Its draft posture is therefore build: the HTTP adapters pin the draft-ietf-oauth-rar-metadata-remediation revision they render in the Problem Details fixtures, alternatives is the stable twin, and a pin bump is a maintainer change with fixtures and a changeset. The metadata half of the draft is where an authorization server would publish the resource type values it accepts.
Transaction tokens
Inside one trust domain (a set of services behind the same gateway, owned by the same team) requests fan out across several services, and each of them needs to know who the original requester was and what they were authorized to do. Re-sending the user's access token to every hop leaks a long-lived credential; re-authenticating at every hop is slow and loses the delegation context. The IETF OAuth working group's Transaction Tokens draft (revision 11, July 2026) solves this with a short-lived JWT, issued by a Transaction Token Service through RFC 8693 token exchange, that carries the requester's identity in sub_id, the request context, and an authorization-details object in azd, and that is valid only for an aud inside the domain.
PermDock's design for it:
- Carrier, not evaluator. The service at the edge builds a
PermDockfrom the verified access token, takes thesnapshot()and theDecisionfor the entry-point permission, and places their identifiers, the snapshot id and the decision id, inazdwhen it requests the transaction token. Downstream services do not re-run the edge decision; they re-derive the subject fromsub_idandazdwithpermdock/jwtand evaluate their own permissions against the same principal, actor and delegation. - Delegation travels intact.
azdcarries thescopes,authorization_detailsandaccessthat the edge saw, so the attenuation invariants hold at every hop: a downstream service can be more restrictive than the edge, never less. - Never from outside the domain. A transaction token is accepted only when its
issis the domain's Transaction Token Service and itsaudnames this service; one that arrives from outside the trust boundary, or whoseaudis another service, resolves to the anonymous subject and every check is denied. Cross-domain propagation is the separate Identity and Authorization Chaining specification (RFC Editor queue) and maps todelegation.chain, not toazd. - Audit correlates by id. The snapshot id and decision id in
azdappear on every downstreamon('decision')event, so an incident review can walk from the edge decision through every hop that acted on it.
The Transaction Tokens for Agents and Cross-domain Transaction Tokens drafts extend the same claims to agent context and to federated domains; both are tracked on the watch list and neither changes the rule above. WIMSE's architecture uses transaction tokens for security-context propagation between workloads, which is why a workload appears in PermDock as a service principal rather than as an actor without a principal. A service acting on its own behalf is always a principal (kind: 'service' or 'workload') with its own roles, never a distinct subject shape.
Audit
Every on('decision') event includes actor and delegation alongside the principal, outcome and reasons, and permdock/otel records them as span attributes. Two questions an incident review must answer, "which agent did this" and "under whose authority", are answered by the event itself. The AuthZEN endpoint carries the same fields in subject.properties.actor and context.delegation so a remote PDP sees them too.
Sources
- Agent Delegation Chain draft for monotonic attenuation across hops.
- OAuth for AI agents on behalf of a user draft for
requested_actorandactor_token. - MCP Enterprise-Managed Authorization for ID-JAG-derived tokens.
- RFC 9635, GNAP, section 8, for the
accessarray; RFC 9767, GNAP Resource Server Connections for how a resource server obtains it (introspection, JWT-formatted tokens). - Transaction Tokens (draft-ietf-oauth-transaction-tokens) for
sub_id,azdand the trust-domainaudrule; the WIMSE architecture for workload context propagation. - RFC 6750, RFC 8693 and the RAR Metadata and Error Remediation draft (draft-ietf-oauth-rar-metadata-remediation) are referenced by name; see the watch list for their status.
- two-principal subject.
Last updated on
Approvals (human in the loop)
How approval: 'human' grants produce approval-required decisions, how the replay-safe token binds an approval to one call, and how each runtime surfaces and resumes the approval.
Comparison
How PermDock differs from permix, CASL, Kilpi, zap-studio/permit, Better Auth access control, Cedar and Amazon Verified Permissions, Open Policy Agent, the Zanzibar family (OpenFGA, Auth0 FGA, SpiceDB, WorkOS FGA), Casbin, accesscontrol, hosted PDPs, ZenStack and the AI SDK OPA adapter.