# Subject

Source: https://permdock.com/docs/concepts/subject

A subject is a principal, an optional actor and the authority delegated between them; a decision is principal grants intersected with delegation.

Who is asking? In a classic web app the answer is "the logged-in user". In 2026 the answer is often "an agent, acting for a user, with a token that lets it do part of what the user can". PermDock models both with one subject type: a `principal` (the human or service whose grants apply), an optional `actor` (the agent doing the asking) and a `delegation` (the authority the principal handed to the actor). A decision is the principal's grants intersected with the delegated authority, so an agent can never exceed its user.

## Shape [#shape]

```ts
type Subject = {
  principal: Principal | null; // from the policy's `subject` function; null = anonymous
  actor?: { id: string; kind: string; binding?: Binding; [k: string]: unknown };
  delegation?: {
    scopes?: string[]; // OAuth scopes, matched against permission.scope
    authorizationDetails?: AuthorizationDetail[]; // RFC 9396 objects, matched per permission
    access?: GnapAccess[]; // GNAP access array (RFC 9635), objects and reference strings
    chain?: unknown; // attenuated delegation chain (Later)
  };
  context: Record<string, unknown>; // loaded by the policy's `context` function
  session?: string; // `sid`: the login this request belongs to; the join key for logout and CAEP events
  expiresAt?: number; // NumericDate seconds, from the token's exp / session_expiry; copied into snapshots
};

type Principal = {
  id: string; // `sub`, a user id, a client id or a SPIFFE ID
  issuer?: string; // `iss`; identity is issuer + id, always set by subjectFrom*
  kind?: "user" | "service" | "workload" | "link"; // default 'user'
  roles?: string[]; // global roles
  memberships?: Membership[]; // roles held in a tenant, a team or on one resource
  tenant?: string; // the active tenant for this request; never defaulted
  assurance?: Assurance; // `acr`, `amr`, `auth_time`, for step-up conditions
  binding?: Binding; // sender constraint, see "Binding"
  [k: string]: unknown; // any other value conditions reference
};

type Membership = {
  tenant?: string;
  team?: string;
  on?: { resource: string; id: string };
  roles: string[];
  via?: string;
  expiresAt?: number;
};
type Assurance = {
  acr?: string;
  amr?: string[];
  authTime?: number;
  verified?: VerifiedClaims[];
}; // OIDC Core section 2; provider `aal` maps into `acr`
type VerifiedClaims = {
  verification: { trust_framework: string } & Record<string, unknown>;
  claims: Record<string, unknown>;
}; // OIDC4IDA `verified_claims`, opaque
type Binding = {
  jkt?: string;
  "x5t#S256"?: string;
  jwk?: JsonWebKey;
  kid?: string;
}; // the RFC 7800 `cnf` members, verbatim
```

Field names follow one rule: where a specification names the concept, PermDock uses that name or maps to it exactly once, in the "Spec names" table on [naming](/docs/getting-started/naming). `id`, `issuer`, `expiresAt`, `actor`, `delegation.scopes` and `authorizationDetails` are the camelCase spellings of `sub`, `iss`, `exp`, `act`, `scope` and `authorization_details`; `assurance` and `binding` carry the claims' own member names inside.

```ts
const permdock = await createPermDock(policy, user); // human or service: principal only
const permdock = await createPermDock(policy, user, { actor, delegation }); // agent on behalf of user
const permdock = await createPermDock(policy, user, {
  tenant,
  memberships,
  customRoles,
}); // active tenant and the two subject-input sources
```

The decision endpoint, MCP, AI SDK, HTTP and A2A adapters build the third argument for you; see "How adapters fill actor and delegation" below. An actor the adapter supplies without a `delegation` is still covered when the policy declares a standing one for its kind in `definePolicy({ delegations })`; that ceiling, the sorted permission keys, is `snapshot.delegated` ([policy delegations](/docs/security/delegation#policy-delegations)).

## Principal [#principal]

The principal is whatever the policy's `subject` function returns:

```ts
definePolicy(permissions, {
  roles,
  subject: (user: User | null) =>
    user && { id: user.id, orgId: user.orgId, roles: user.roles },
});
```

* Its fields are the values that `subject.<field>` references read in [conditions](/docs/concepts/conditions). Nothing else about the user is visible to the policy.
* `roles` selects which global `role()` grants apply. `memberships` selects scoped roles: each entry names a tenant, a team inside a tenant, or one resource, and the roles held there; `tenant` is the active tenant this request is about. A membership with a role name the policy never declared is resolved as a tenant-defined custom role through the `RoleSource`, or dropped. Full rules on [tenants, teams and scoped roles](/docs/concepts/tenancy).
* It is computed once per `createPermDock`, frozen, and included in the snapshot so the client evaluates the same conditions against the same values.
* It is never taken from a model, a tool argument or a request body. Adapters derive it from verified auth material (session cookie, bearer token `authInfo`, provider session) through a `subjectFrom<Source>` function; what counts as verified is defined in [Authentication and PermDock](/docs/concepts/authentication). See also the [threat model](/docs/security/threat-model).

The `principal` may be a service account. A cron job or a backend-to-backend call has a principal with roles and no actor.

## Anonymous [#anonymous]

When `subject` returns `null` the principal is anonymous. Anonymous has no roles, so with the default policy every check is `denied` with reason `anonymous`, and `assert` narrows nothing. The granted branch of a `Decision` types `subject.principal` as non-null, which is how `assert` gives you a narrowed subject for the rest of the handler (Kilpi's `Grant(subject)` trick, carried through RSC and client types where Kilpi loses it).

`can` on an anonymous `PermDock` still works and still returns `false`; it never throws. The one way to grant to anonymous callers is `to: anyone()`, the only selector that matches a `null` principal; there is no magic `anonymous` role ([policies](/docs/concepts/policies)).

## Context [#context]

`context` is for relations you need in conditions but that are not on the user record: team memberships, org settings, a feature flag. It is loaded once per `createPermDock`, which makes `createPermDock` async only when the policy declares it:

```ts
definePolicy(permissions, {
  roles,
  subject: (user) => user && { id: user.id, roles: user.roles },
  context: async (user) => ({
    teamIds: user ? await loadTeamIds(user.id) : [],
  }),
});

allow(permissions.post.read, { where: { teamId: { in: context.teamIds } } });
```

Context values must be JSON so they can travel in the snapshot and compile to SQL (`team_id IN (select team_id from team_user where user_id = (select auth.uid()))` on the RLS side; the compiler maps `context.teamIds` to that subselect through a per-key mapping declared on the RLS adapter). A thrown or rejected `context` function is fail-closed: the instance still builds, `subject.context` is empty, and `on('auth')` reports `reason: 'source-threw'`. This is PermDock's answer to the "no real ReBAC" complaint against permix ([permix #25](https://github.com/letstri/permix/issues/25)): relations are resolved up front and become ordinary data, so checks stay synchronous and portable.

When the relation is a role held somewhere (a team the user leads, a document shared with them), prefer `memberships` over a `context` array: a membership carries roles, is understood by scoped role declarations and the `memberOf` condition node, and reaches RLS through the membership table mapping instead of a per-key subselect. `context` remains right for settings, flags and values that are not memberships.

## Actor [#actor]

The actor identifies the agent. It has an `id` and a `kind` plus whatever the adapter knows:

| Adapter | `actor.kind` | `actor.id` | Source |
| --- | --- | --- | --- |
| `permdock/mcp` | `'mcp-client'`, or `actorKind` | OAuth `client_id` | `authInfo` from the MCP TypeScript SDK (OAuth 2.1 resource server) |
| `permdock/jwt` with an `act` claim | `'oauth-client'` | outermost `act.sub`, the current actor (RFC 8693) | the verified token; the nesting is `delegation.chain` |
| `permdock/supabase`, `act.kind: 'support'` | `'support'` | the admin's `act.sub`; also `sessionId` and `readOnly` | a better-supabase support session; no delegation, so only a policy delegation reaches it |
| `permdock/supabase`, `act.kind: 'impersonation'` | `'impersonation'` | the admin's `act.sub` | better-supabase `actingAs`; no delegation, so only a policy delegation reaches it |
| `permdock/ai-sdk` | `'ai-sdk'` | your `runtimeContext.agentId` | the `actor` option of `createPermDock` |
| `permdock/claude-agent` | `'claude-agent'` | session or agent name | the `actor` option |
| HTTP adapters with Web Bot Auth | `'web-bot-auth'` | the verified signer `keyid` | RFC 9421 HTTP Message Signatures via `webBotAuth` |
| `permdock/a2a` | `'a2a-agent'` | the calling Agent Card identity | A2A `securitySchemes` on the authenticated extended card |
| none | absent | absent | a plain human or service request |

This table is the closed list of `actor.kind` values; the transaction-token and SPIFFE recipes below add `'workload'`, and the agent-runtime adapters add their own name (`'eve'`, `'openai-agent'`, `'claude-agent'`, `'terminal'`) on their pages. An adapter adds its row here before using a new value.

The actor appears on every decision and audit event so you can answer "which agent did this, for whom". The actor never has grants of its own; only the principal has roles. An actor with `readOnly: true` gets only the read-only permissions of the policy delegations that name it ([policy delegations](/docs/security/delegation#policy-delegations)).

## Delegation [#delegation]

Delegation is the authority the principal has given the actor. Two carriers are understood:

### OAuth scopes [#oauth-scopes]

Every permission has a `scope` string (`permissions.post.update.scope === 'post:update'`). With `delegation.scopes` present, a permission is only grantable when its scope is in the list. MCP servers get this for free: `protectServer` reads `authInfo.scopes`, and when a scope is missing the server answers with a `scopeChallenge` so the client can step up ([MCP authorization](/docs/standards/mcp-authorization)).

### RFC 9396 authorization\_details [#rfc-9396-authorization_details]

Rich Authorization Requests carry structured objects instead of flat scopes. PermDock treats each permission as an `authorization_details` type, so it can both emit an object for a consent screen and verify one on a token:

```json
{
  "type": "post",
  "actions": ["update"],
  "identifier": "post_123"
}
```

`permissions.post.update` carries an `authorizationDetails` type that says which fields may appear (`type` from the resource, `actions` from the action, `identifier` from the resource `id` field, optional `locations`). A decision on `post_456` with the object above is `denied` with reason `not-delegated`. For a collection action the object has no `identifier`. See [OAuth agent delegation](/docs/standards/oauth-agent-delegation).

### Delegation chains [#delegation-chains]

The Agent Delegation Chain and Credential Delegation drafts describe tokens that attenuate across agent hops. PermDock accepts the chain as opaque `delegation.chain` and leaves verification of monotonic attenuation to the token layer; core never inspects the chain.

### Example: an MCP server [#example-an-mcp-server]

```ts
import { createPermDock } from "permdock/mcp";

const { protectServer } = createPermDock(policy, {
  subject: (authInfo) => userFrom(authInfo), // principal from the verified token
});
// actor     = { id: authInfo.clientId, kind: 'mcp-client' }
// delegation = { scopes: authInfo.scopes, authorizationDetails: authInfo.extra?.authorization_details }
```

You write the principal mapping once; the adapter fills `actor` and `delegation` from `authInfo` on every request. When a scope is missing, the tool result is a refusal plus a `scopeChallenge` naming `permission.scope`, and the MCP client can step up (scopes accumulate across step-ups per SEP-2350). See the [MCP adapter](/docs/adapters/mcp).

### Service principals [#service-principals]

A backend job or a partner service is a principal with roles of its own, created with `createPermDock(policy, serviceUser)` and no actor. Do not model it as an actor without a principal: actors never hold grants, so such a subject is denied everything. If a service calls on behalf of a user, the user is the principal and the service is the actor with a delegation.

## Workload principals [#workload-principals]

`principal.kind` distinguishes four kinds of caller that act on their own authority:

| `kind` | Who | `principal.id` | Typical material |
| --- | --- | --- | --- |
| `'user'` (default) | A human | The identity provider's `sub` | Session, ID token, access token |
| `'service'` | An application-level service account | Your service account id | A service key verified by `subjectFromApiKey` (one tenant membership, `via: 'credential'`, [API keys](/docs/concepts/credentials)), a client-credentials token with a `sub` you assigned |
| `'workload'` | A running workload with a platform identity | A SPIFFE ID (`spiffe://trust-domain/ns/prod/sa/reports`) or the WIMSE workload identifier | Client-credentials token, SPIFFE SVID over mTLS, transaction token |
| `'link'` | Whoever holds a share link | The link id (`capability.id`) | A `permdock-capability+jwt` verified by `subjectFromCapability`; the principal holds one resource membership and no roles ([link capabilities](/docs/concepts/capabilities)) |

The WIMSE architecture treats workloads as principals in their own right: a workload has an identity, obtains credentials, and is the subject that security context is propagated for ([WIMSE architecture](https://datatracker.ietf.org/doc/html/draft-ietf-wimse-arch)). PermDock follows that: a workload calling on its own behalf is a principal with roles, exactly like a service account, and `kind: 'workload'` exists so policies can say `where: { subject: { kind: 'workload' } }` or audit can separate human from machine traffic.

**Transaction tokens.** Inside a trust domain, the [OAuth Transaction Tokens draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-transaction-tokens/) propagates the original user, the workload chain and an authorization context (`azd`) through every hop of a call chain. PermDock maps them as: the token's subject is the `principal` (the user the transaction is for, or the originating workload); each workload in the chain is recorded as `actor` with `kind: 'workload'`; `azd` is merged into `principal.context` so conditions can reference values the first hop asserted. The `aud` of a transaction token is the trust domain, and `permdock/jwt` checks it like any audience.

**SPIFFE SVIDs as actor material.** When a workload calls with a user's delegated token (a backend-for-frontend forwarding a user request, a job running under an OBO token) it is an actor, and its [SPIFFE](https://spiffe.io/docs/latest/spiffe-about/overview/) identity is the verified material for that half: the X.509-SVID presented over mTLS, or a JWT-SVID in a header, yields `actor: { id: '<spiffe id>', kind: 'workload' }` while the principal comes from the user token. Verification of the SVID is upstream (the mesh, the TLS terminator or the SPIRE Workload API), the same rule as every other token ([authentication](/docs/concepts/authentication)); PermDock reads the result. This is a recipe over `permdock/jwt` and the server kernel, not an adapter ([watch list](/docs/standards/watch-list)).

**Why a workload is a principal and an agent is an actor.** The distinction is whose authority applies. A nightly report job runs under its own grants; nobody delegated anything to it, so it is a principal. An MCP client, an AI SDK agent or a Web Bot Auth signer exercises a *user's* authority under a delegation, so it is an actor and the user is the principal. The same binary can be both on different requests: a workload is a principal when it runs its own schedule and an actor when it calls with a user's delegated token. What decides is the token, not the process.

## Binding [#binding]

Sender-constrained tokens carry a `cnf` claim (RFC 7800) that binds the token to a key the presenter must prove it holds. PermDock keeps that binding on the subject as the `cnf` object itself, member names unchanged, so it round-trips into signed outputs and into a verifier in another language without translation:

```ts
binding: { jkt: 'NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs' }          // DPoP, RFC 9449: JWK thumbprint of the proof key
binding: { 'x5t#S256': 'A4DtL2JmUMhAsvJj5tKyn64SqzmuXbMrJa0n761y5v0' }   // mTLS, RFC 8705: certificate thumbprint
```

* The method is implied by the member: `jkt` is DPoP, `x5t#S256` is mTLS; `jwk` and `kid` are accepted for RFC 7800 completeness but no adapter verifies them. An object with none of the four members is not a binding and is dropped.
* It sits on `principal` when the token identifies a user or workload acting for itself, and on `actor` when the token carries an `act` chain (the key belongs to the presenting agent).
* **Core does not check it.** Proof-of-possession needs the HTTP request (the `DPoP` header) or the TLS client certificate, which core never sees. The check belongs to the adapter that has the request: `verifyDpopProof(request, claims)` in `permdock/jwt`, or the mTLS thumbprint comparison in the server kernel. A failed check yields the anonymous subject, so the binding never reaches core in an unverified state.
* It appears on `on('decision')` events and OTel spans (the `cnf` member and its value) so an audit can prove that a decision was made for a token bound to a specific key. It is not included in snapshots.
* Under `profile: 'fapi2'` in `permdock/jwt` a token without a binding is rejected ([FAPI 2.0](/docs/standards/fapi-2)).

See [Authentication and PermDock](/docs/concepts/authentication) for where bindings come from.

## The intersection rule [#the-intersection-rule]

```text
decision = evaluate(principal.roles, permission, data)  ∩  delegated(delegation, permission, data)
```

1. Evaluate the policy for the principal as if no actor existed. `denied` stays `denied`.
2. If there is a `delegation`, check the permission against it: scope present, or an `authorization_details` object whose `type`, `actions` and `identifier` cover this permission and this row.
3. If the delegation does not cover it, the outcome is `denied` with a denial `{ role: null, reason: 'not-delegated' }`, and `alternatives` lists the permissions on the same resource that both the principal holds and the delegation covers.
4. `approval-required` survives the intersection: a delegated actor still needs the human gate.

An actor with no `delegation` at all is treated as having none: every check is `denied` with reason `no-delegation`. This is the fail-closed default; an adapter that cannot find scopes on a token does not silently grant everything the user has.

## Where the subject shows up [#where-the-subject-shows-up]

* `Decision.subject` on `granted` is the narrowed subject (`principal` non-null).
* `on('decision')` events carry `principal.id` and `principal.issuer`, `principal.roles`, `tenant`, the matching `membership` and its `via`, `actor` and `delegation` so audit can distinguish "Alice deleted a post" from "the reporting agent deleted a post for Alice under scope `post:delete`", and "as a design-team lead" from "as an Acme admin".
* Snapshots include the principal (memberships and active tenant included) and the delegation, not the actor's secrets, so the client can evaluate the same intersection offline.
* AuthZEN messages map `principal` to `subject`, put `memberships` under `subject.properties`, and put `actor`, `delegation` and the active `tenant` under `context` ([AuthZEN](/docs/standards/authzen)).

## Choosing what goes in the principal [#choosing-what-goes-in-the-principal]

Put in the principal only what conditions reference and what you are willing to ship to the client in a snapshot. An email address that no condition uses is noise on the wire; a `tenantId` that RLS filters on belongs there. Keep it flat and JSON: strings, numbers, booleans, arrays of those, and the `memberships` array.

| Value | Where it goes | Why |
| --- | --- | --- |
| The user id and issuer, kind, assurance (`acr`, `amr`, `authTime`, `verified`) | `principal` fields | Referenced by conditions (`subject.assurance.acr`), step-up challenges and the token binding |
| Global roles (`support`, `platform-admin`, `pro`) | `principal.roles` | Apply everywhere |
| A role held in a tenant, a team or on a document | `principal.memberships` | Scoped roles, `memberOf`, tenant-scoped audit, the UI's role chips |
| The tenant this request is about | `principal.tenant` | Selected by the server from the URL or the provider's active organisation, only when a membership matches |
| Settings, flags, plan | `context` (or `roles` when they select a role) | Not roles, not memberships; still portable |
| Display names, avatars, emails | Nowhere | The provider owns them; conditions do not need them |

Provider mappers (`subjectFromSupabase`, `subjectFromClerk`, `subjectFromBetterAuth`, `subjectFromJwt`) fill the first four rows from verified material and expose a `schema` option for custom claims, typed from its output ([extension interfaces](/docs/concepts/extension-interfaces)). `PrincipalOf<typeof policy>` is the resulting type.
