# Delegation

Source: https://permdock.com/docs/security/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](/docs/concepts/subject), and the standards behind it in [OAuth for agent delegation](/docs/standards/oauth-agent-delegation).

## The three parts [#the-three-parts]

```ts
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](#policy-delegations) 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 [#attenuation-invariants]

1. **Agent ≤ user.** For every permission, `asAgent.can(p, x)` implies `asUser.can(p, x)`. Delegation can only remove or narrow grants. This is enforced by construction: `decide` evaluates the principal's grants first, then filters by delegation.
2. **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.chain` opaquely for audit and evaluates only the final `scopes`, `authorizationDetails` and `access`.
3. **Conditions intersect.** A grant's `where` is evaluated first and the delegation only filters the result, so a delegation cannot relax a grant condition.
4. **Approval survives delegation.** A grant with `approval: 'human'` still requires approval when exercised through an agent; delegation cannot pre-approve. The approval `token` includes `actor`, so the approval is bound to the agent that asked.
5. **Deny is not delegable away.** A `deny` grant applies regardless of delegation; an agent cannot be delegated around a deny.
6. **Fail closed on unknowns.** An `authorization_details` type PermDock does not recognise, a scope that matches no permission, or a malformed chain contributes nothing; it never widens.

## Policy delegations [#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:

```ts
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 whose `to` matches the actor's `kind` (and `id`, when set) and whose `from` the principal holds in the active tenant are united into a ceiling of permission keys. `delegatedPermissions(policy.delegations, subject, heldRoles, now)` is that computation, exported from `permdock`; `snapshot.delegated` carries the sorted result to the client. An actor with `readOnly: true` (a read-only Supabase support session) contributes only each delegation's `readOnly` keys, so a write it was delegated still denies with `not-delegated`.
* A permission outside the ceiling is `denied` with reason `not-delegated`. Inside it, a token `delegation` on the same call must still cover the permission (`coveredByDelegation` runs as before): a token can only narrow a policy delegation, never widen it. With no token delegation, the ceiling alone decides; the `no-delegation` denial 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 `where` still filters, a `deny` still wins, and an approval is still required.
* A delegation is revoked by removing it from the policy or by its `validUntil`. The [`RevocationFeed`](/docs/standards/shared-signals-caep) stays 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 or `context`, never through the feed.

### Why [#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 `delegation` option, the token scopes an authorization server mints, and prose. Putting it in `definePolicy` gives it a fingerprint, a catalog entry, a `diff`, and a policy-matrix vector, and lets `permdock doctor` and 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 through `clients` (`ClientNames`) on `subjectFromSupabase`, `permdock/mcp` and `permdock/jwt`'s `actor` option, and sets `actor.client`. An id the mapping does not name, or names twice, leaves `client` unset, 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.** `RevocationFeed` means "end this session" and is kept that way by `naming.mdc`; a feed that also edited the policy would be a second decision path (invariant 15). Removing the entry, or letting `validUntil` pass, is the revocation, and both are visible in the catalog and `diff`.

## Coarse OAuth scopes [#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:

```ts
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 `scopes` keep their order and are followed by the scopes of every permission a held coarse scope covers, so `decide`, `snapshot`, `mayUse` and 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/mcp` and `permdock/a2a` accept a coarse scope in their scope pre-check and list filter. Their `insufficient_scope` challenges, and the `WWW-Authenticate` challenge 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; `definePolicy` throws otherwise. The mapping is part of the policy fingerprint.

## Coverage check [#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-delegation` when `hasActor` is `true` and there is no delegation, a delegation with no `scopes`, `authorizationDetails` or `access`, or only empty `scopes` and `access` lists. `decide` passes `hasActor: false` when a policy delegation covers the actor, so that denial is reserved for an actor nothing delegated to.
* `not-delegated` when no scope equals the permission's `scope`, no `authorizationDetails` entry has `type` equal to the resource with a matching `actions` list and `identifier`, and no GNAP `access` entry matches (a reference string equal to the scope, or an object whose `type` is 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` [#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](/docs/adapters/ai-sdk).

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` [#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_details` entries whose `type` is the resource name, `actions` the actions and `identifier` the 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.authorizationDetails` entries are matched to a permission by `type` equal to its resource, when present `actions` containing its action, and when present `identifier` equal to the resource id (an entry with an `identifier` never covers a collection check). Unknown types contribute nothing (fail closed).

```json
[
  { "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 [#gnap-access-as-a-third-input]

[RFC 9635 (GNAP)](/docs/standards/watch-list#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](https://www.rfc-editor.org/rfc/rfc9767.html) (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](/docs/standards/watch-list#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.

```ts
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 [#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](/docs/standards/watch-list) (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 the `scope` values of the alternatives, for clients that only understand RFC 6750.
* The RFC 9457 Problem Details body with `alternatives` expressed as `authorization_details` objects 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](/docs/standards/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 [#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](/docs/standards/watch-list) 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 `PermDock` from the verified access token, takes the `snapshot()` and the `Decision` for the entry-point permission, and places their identifiers, the snapshot id and the decision id, in `azd` when it requests the transaction token. Downstream services do not re-run the edge decision; they re-derive the subject from `sub_id` and `azd` with `permdock/jwt` and evaluate their own permissions against the same principal, actor and delegation.
* **Delegation travels intact.** `azd` carries the `scopes`, `authorization_details` and `access` that 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 `iss` is the domain's Transaction Token Service and its `aud` names this service; one that arrives from outside the trust boundary, or whose `aud` is 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 to `delegation.chain`, not to `azd`.
* **Audit correlates by id.** The snapshot id and decision id in `azd` appear on every downstream `on('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](/docs/standards/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 [#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 [#sources]

* [Agent Delegation Chain draft](https://datatracker.ietf.org/doc/html/draft-asor-wimse-agent-delegation-chain-00) for monotonic attenuation across hops.
* [OAuth for AI agents on behalf of a user draft](https://datatracker.ietf.org/doc/html/draft-oauth-ai-agents-on-behalf-of-user-02) for `requested_actor` and `actor_token`.
* [MCP Enterprise-Managed Authorization](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/extensions/auth/enterprise-managed-authorization.mdx) for ID-JAG-derived tokens.
* [RFC 9635, GNAP](https://datatracker.ietf.org/doc/html/rfc9635), section 8, for the `access` array; [RFC 9767, GNAP Resource Server Connections](https://www.rfc-editor.org/rfc/rfc9767.html) for how a resource server obtains it (introspection, JWT-formatted tokens).
* [Transaction Tokens](https://datatracker.ietf.org/doc/draft-ietf-oauth-transaction-tokens/) (draft-ietf-oauth-transaction-tokens) for `sub_id`, `azd` and the trust-domain `aud` rule; the [WIMSE architecture](https://datatracker.ietf.org/doc/html/draft-ietf-wimse-arch) 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](/docs/standards/watch-list) for their status.
* [two-principal subject](/docs/concepts/subject).
