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 (ADR 0012).
Shape
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
expiresAt?: number // 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
kind?: 'user' | 'service' | 'workload' // 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?: string // `aal` / `acr` from the token, 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 Binding = { method: 'dpop' | 'mtls'; thumbprint: string }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 sourcesThe decision endpoint, MCP, AI SDK, HTTP and A2A adapters build the third argument for you; see "How adapters fill actor and delegation" below.
Principal
The principal is whatever the policy's subject function returns:
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. Nothing else about the user is visible to the policy. rolesselects which globalrole()grants apply.membershipsselects scoped roles: each entry names a tenant, a team inside a tenant, or one resource, and the roles held there;tenantis 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 theRoleSource, or dropped. Full rules on tenants, teams and scoped roles.- 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 asubjectFrom<Source>function; what counts as verified is defined in Authentication and PermDock. See also the 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
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.
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:
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: subject.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 subject.context.teamIds to that subselect through a per-key mapping declared on the RLS adapter). This is PermDock's answer to the "no real ReBAC" complaint against permix (permix #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
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' | OAuth client_id | authInfo from the MCP TypeScript SDK (OAuth 2.1 resource server) |
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' | the verified Signature-Agent key id | RFC 9421 HTTP Message Signatures |
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 |
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.
Delegation
Delegation is the authority the principal has given the actor. Two carriers are understood today:
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).
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:
{
"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. See OAuth agent delegation.
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 today and leaves verification to the token layer; Phase 4 may verify monotonic attenuation in core. See the open questions.
Example: an MCP server
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.
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
principal.kind distinguishes three 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 or API-key owner | Your service account id | API key lookup, 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 |
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). 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 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 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); PermDock reads the result. This is a recipe over permdock/jwt and the server kernel, not an adapter, and it is scheduled with the other workload rows in Phase 4 (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
Sender-constrained tokens carry a cnf claim that binds the token to a key the presenter must prove it holds. PermDock keeps that binding on the subject:
binding: { method: 'dpop', thumbprint: 'NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs' } // cnf.jkt, RFC 9449
binding: { method: 'mtls', thumbprint: 'A4DtL2JmUMhAsvJj5tKyn64SqzmuXbMrJa0n761y5v0' } // cnf.x5t#S256, RFC 8705- It sits on
principalwhen the token identifies a user or workload acting for itself, and onactorwhen the token carries anactchain (the key belongs to the presenting agent). - Core does not check it. Proof-of-possession needs the HTTP request (the
DPoPheader) or the TLS client certificate, which core never sees. The check belongs to the adapter that has the request:verifyDpopProof(request, claims)inpermdock/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 (method and thumbprint) 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'inpermdock/jwta token without a binding is rejected (FAPI 2.0).
See Authentication and PermDock for where bindings come from.
The intersection rule
decision = evaluate(principal.roles, permission, data) ∩ delegated(delegation, permission, data)- Evaluate the policy for the principal as if no actor existed.
deniedstaysdenied. - If there is a
delegation, check the permission against it: scope present, or anauthorization_detailsobject whosetype,actionsandidentifiercover this permission and this row. - If the delegation does not cover it, the outcome is
deniedwith a denial{ role: null, reason: 'not-delegated' }, andalternativeslists the permissions on the same resource that both the principal holds and the delegation covers. approval-requiredsurvives 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
Decision.subjectongrantedis the narrowed subject (principalnon-null).on('decision')events carryprincipal.id,principal.roles,tenant, the matchingmembershipand itsvia,actoranddelegationso audit can distinguish "Alice deleted a post" from "the reporting agent deleted a post for Alice under scopepost: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
principaltosubject, putmembershipsundersubject.properties, and putactor,delegationand the activetenantundercontext(AuthZEN).
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, kind, assurance | principal fields | Referenced by conditions 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). PrincipalOf<typeof policy> is the resulting type.
Open questions
- Whether delegation-chain verification (monotonic attenuation across hops) belongs in core or stays in the token layer.
- The exact
authorization_detailsmapping when a permission is a collection action with noidentifier. - Whether
actorshould be allowed to carry its own roles for service accounts that call on their own behalf, or whether such calls should simply use the service asprincipal(the current design). - Whether a principal has a home tenant distinct from its memberships (cross-tenant guests), see tenancy.
- How anonymous principals receive grants, if at all (see policies).
Conditions
One portable condition AST evaluates in memory, filters arrays, compiles to Drizzle, Prisma and Kysely where clauses, and generates Postgres RLS.
Tenants, teams and scoped roles
Memberships put roles in a scope (a tenant, a team, one resource); scoped role declarations apply grants only inside that scope; tenant-defined custom roles compose declared roles and never widen them; RoleSource and MembershipSource are the only new inputs.