# Extension interfaces

Source: https://permdock.com/docs/concepts/extension-interfaces

The fixed set of interfaces through which providers, verifiers, signers, stores, sinks and compilers plug into PermDock (SubjectResolver, TokenVerifier, TokenSigner, MembershipSource, RoleSource, ApprovalPolicySource, DirectoryStore, ApprovalStore, DecisionSink, SnapshotSource, LimitStore, ReplayStore, RevocationFeed, WhereCompiler, on() events), their trust classes, the in-process default each ships with, how provider principal types are extended without global augmentation, and the conformance runners in permdock/testing.

PermDock has no plugin system. What it has instead is a short list of interfaces, each with one job, one trust class and one in-process default, passed explicitly to `createPermDock`. A provider (Supabase, Clerk, Better Auth), a store (Postgres, Redis, PermDock Cloud), a sink (OpenTelemetry, a SIEM) or a compile target (Drizzle, MongoDB, a sync engine) implements one of them; nothing else can reach the evaluator. This page is the list. If an integration needs something not on it, that is an RFC-lite issue and a change to this page, not a new option. Application data on definitions and memberships, app obligations, UI defaults and adapter hooks are options, not interfaces: [extend PermDock](/docs/guides/extending) maps each need to one.

## The interfaces [#the-interfaces]

| Interface | Job | Trust class | Default in `permdock` | Implemented by |
| --- | --- | --- | --- | --- |
| `SubjectResolver<TInput, TPrincipal>` | Turn verified auth material into a `Subject` | Subject input: shapes the outcome | The policy's own `subject` function | `subjectFromJwt`, `subjectFromSupabase`, `subjectFromClerk`, `subjectFromBetterAuth`, `subjectFromBetterSupabase`, `subjectFromMcp`, `subjectFromApiKey`, `subjectFromCapability`, yours |
| `TokenVerifier<TClaims>` | Establish that a JWS (or nested JWE) is genuine and return its claims, never throwing | Subject input: feeds `SubjectResolver`; a wrong verifier admits a forged principal | None in core (type only); `joseTokenVerifier` in `permdock/jwt` | Another JOSE library, a KMS-backed verifier, an introspection client ([JOSE](/docs/standards/jose)) |
| `TokenSigner` | Produce compact JWS for PermDock's own outputs (snapshots, approval tokens, decision exports) | Operational: signs what core already computed; never influences an outcome | None in core (type only); `joseTokenSigner` in `permdock/jwt` | A KMS or HSM signer, `permdock/cloud` ([wire formats](/docs/concepts/wire-formats)) |
| `MembershipSource` | Add named-scope and resource memberships to a principal | Subject input | What `subject` and `context` returned; `memoryMembershipSource(entries)` | Provider mappers, your `document_members` table |
| `RelationSource` | Answer the object graph: `ancestors` (an object's parent chain, with restricted flags) and `related` (who holds a relation on one object) | Decision input, read per request through the instance's cache; it answers facts and never decides, and a failure denies | `memoryRelations(permissions, { rows, edges })` | Your folder and edge tables, an OpenFGA or SpiceDB read client ([relationships](/docs/concepts/relationships)) |
| `RoleSource` | Resolve tenant-defined custom roles to declared roles; list assignable roles | Subject input | `memoryRoleSource(customRoles)` | Better Auth `organizationRole`, Supabase `role_permissions`, WorkOS, Clerk, Auth0 organisation roles, your table |
| `ApprovalPolicySource` | Approval requirements kept as data: `approvalPoliciesFor({ tenants })` returns entries that add approval stages to matching allows | Subject input: it only tightens, and a failure denies every allowed call | `memoryApprovalPolicies(entries)` | Your `approval_policies` table ([approvals](/docs/adapters/approvals#approval-policies-as-data)) |
| `CredentialVerifier` | Look up the `Credential` an opaque API key stands for: `verify(key)` returns it or `null`, never throwing for bad input | Subject input: feeds `subjectFromApiKey`; a wrong verifier admits a forged key | None in core (type only); `apiKeyVerifier({ find })` and `memoryCredentials()` in `permdock/server` | Your `api_keys` table over Drizzle, Prisma or Kysely, looked up by the key's id ([API keys](/docs/concepts/credentials)) |
| `SettingsSource` | Per-tenant settings: `settingsFor(tenant)` returns `{ credentials? }`, the tenant's API-key rules | Subject input: it only tightens (refuses a key), never grants | `memorySettings(record)` | Your `tenant_settings` table ([API keys](/docs/concepts/credentials)) |
| `DirectoryStore` | Hold the users, groups and memberships an identity provider provisions over SCIM; read back by `directoryMembershipSource` | Subject input, write side: what `scimHandler` writes becomes memberships | `memoryDirectoryStore()` in `permdock/scim` | Your `scim_users` / `scim_groups` tables over Drizzle, Prisma or Kysely ([SCIM adapter](/docs/adapters/scim)); never PermDock Cloud (a Cloud-native directory reaches you as token claims, not as a store, [PermDock Cloud](/docs/adapters/cloud)) |
| `ApprovalStore` | Hold pending `approval-required` decisions until a human answers | Operational: never influences an outcome | `memoryApprovalStore()` | Postgres, Redis, Durable Objects, PermDock Cloud ([approvals](/docs/adapters/approvals)) |
| `DecisionSink` | Receive decision events after the fact | Operational | `memorySink()` | OpenTelemetry, SIEMs, a table, PermDock Cloud ([audit](/docs/concepts/audit-and-observability)) |
| `SnapshotSource` | Distribute and invalidate snapshots | Operational | In-process `snapshot()`; `memorySnapshotSource(snapshot)` | PermDock Cloud, your cache ([snapshots](/docs/concepts/snapshots)) |
| `PolicySource` | Deliver a verified `PolicyDocument` of hosted grants; `current()` is read once by `createPermDock` | Policy input: may widen an outcome, bounded by `hostable` in code | `memoryPolicySource(document)` | `cloud().policies` ([PermDock Cloud](/docs/adapters/cloud)); `testPolicySource` in `permdock/testing` |
| `ReplayStore` | Remember SET / `logout_token` `jti` values, namespaced by issuer, so a replay is dropped. `remember(key, expiresAt?)` is additive; the optional pair `claim(key, expiresAt?)` / `release(key)` makes concurrent deliveries dispatch once | Operational: never a decision input | `memoryReplayStore()` in `permdock/ssf` (evicts on read) | Redis `SET NX EX` or a Postgres `expires_at` row; recipes on [SSF](/docs/adapters/ssf); `testReplayStore` in `permdock/testing` |
| `RevocationFeed` | Tell open streams and sockets that a subject changed: `subscribe(listener)` returns an unsubscribe function, `revoke({ principal, session?, tenant?, kind })` publishes. `session-revoked` ends matching connections; `changed` makes them resolve their subject again | Operational: it can end or revalidate a connection, never grant; a lost event falls back to expiry | `memoryRevocationFeed()` in `permdock` (validates and freezes events, isolates listener errors) | Postgres `LISTEN`/`NOTIFY`, Redis pub/sub or Supabase Realtime bridging `revoke` across replicas; `testRevocationFeed` in `permdock/testing` |
| `DecisionProvider` | Answer `decide` for delegated permissions via a remote PDP, and optionally list permitted ids (`permitted`) for `filter` and `where` | Decision path, opt-in, fail-closed | None; local evaluation only | `remotePdp`, `openfga` and `spicedb` in `permdock/pdp` |
| `WhereCompiler<TTarget>` | Compile the portable condition AST to a query language | Pure function of the AST | The in-memory evaluator and `filter` | `permdock/drizzle`, `prisma`, `kysely`, the RLS generator; candidates for MongoDB, Zero, ElectricSQL ([local-first sync](/docs/research/ecosystem-index)) |
| `on(event, handler)` | Observe `decision`, `denied`, `approval`, `auth` events | Observational: cannot change a `Decision` | No-op | Logging, metrics, tests |

Two rules follow from the table. **Subject inputs run once per `createPermDock`**, before any check, and their output is frozen into the subject; they never run during `can` or `decide`, so a slow membership lookup costs one await per request, not one per button. **Operational interfaces never sit in the decision path**: `decide` returns before a sink is awaited, a store is consulted only on resume (and the resume re-runs `decide`), a snapshot source distributes what the in-process instance already computed, and a signer signs it afterwards (invariant 15). `permdock/cloud` implements the three operational stores and a `TokenSigner` for the snapshots it distributes, and none of the subject inputs; the Cloud's SCIM relay writes *to* an application's `DirectoryStore` through `scimHandler` and never *is* one. Core declares `TokenVerifier` and `TokenSigner` as types only, so the one runtime dependency rule holds: the JOSE implementation lives in `permdock/jwt` behind the optional `jose` peer.

## Subject inputs [#subject-inputs]

### SubjectResolver [#subjectresolver]

```ts
import type { StandardSchemaV1 } from "@standard-schema/spec";

interface SubjectResolver<TInput, TPrincipal extends Principal = Principal> {
  (
    input: TInput,
    options?: { tenant?: string },
  ): Subject<TPrincipal> | Promise<Subject<TPrincipal>>;
}
```

`subjectFromApiKey(options)` from `permdock/server` returns one, `tenant` argument included. The provider mappers take the provider's own options as their second argument, so they satisfy it once those options are bound:

```ts
const resolve: SubjectResolver<unknown, SupabasePrincipal> = (claims) =>
  subjectFromSupabase(claims, { tenant: "org_id" });
```

The same holds for `subjectFromJwt`, `subjectFromClerk`, `subjectFromMcp` and `subjectFromBetterAuth(auth, session, options)`. A bound mapper ignores the `tenant` argument; its provider option picks the active tenant instead. `createJwtSubjectResolver(options)` returns `(token, request?)`, where `request` feeds the DPoP and mTLS binding checks, so it satisfies `SubjectResolver` only as `(token) => resolve(token)`. `tests/core/subject-resolvers.test-d.ts` checks each of these shapes.

A resolver never throws: an unverifiable token, a missing session or a failed schema check yields the anonymous subject and an `on('auth')` event with the reason ([authentication](/docs/concepts/authentication)). The optional `tenant` is the active tenant the server resolved from the request; the resolver compares it against the memberships it finds and sets `principal.tenant` only on a match ([tenancy](/docs/concepts/tenancy)). `SubjectResolver` never receives the request. A check that needs it, such as DPoP or mTLS binding, runs in the adapter kernel or through the `request` argument of `createJwtSubjectResolver`.

### TokenVerifier [#tokenverifier]

```ts
interface TokenVerifier<TClaims extends JwtClaims = JwtClaims> {
  verify(
    token: string,
    expectations: {
      typ?: string | string[]; // 'at+jwt', 'JWT', 'logout+jwt', 'secevent+jwt', 'permdock-snapshot+jwt', ...
      audience?: string | string[];
      issuer?: string; // when not fixed at construction (Discovery supplies it)
      clockTolerance?: number;
    },
  ): Promise<VerifiedToken<TClaims> | VerificationFailure>;
}

type VerifiedToken<TClaims> = {
  ok: true;
  claims: TClaims;
  header: { alg: string; kid?: string; typ?: string };
};
type VerificationFailure = {
  ok: false;
  reason: "invalid-token";
  cause: TokenFailureCause;
};
```

`verify` never throws and never returns a partially verified result: signature, `alg`, `kid`, `typ`, `iss`, `aud`, `exp`, `nbf`, `iat` are all checked before `ok: true`. `TokenFailureCause` is the closed list on the [JWT adapter](/docs/adapters/jwt) behaviour table (`invalid-signature`, `expired`, `wrong-audience`, `wrong-token-type`, `alg-not-allowed`, `encrypted-token`, ...), so an `on('auth')` event has the same shape whichever verifier produced it. `subjectFromJwt` takes a `verifier`; `permdock/ssf` takes one for SETs and `logout_token`s; `SnapshotSource` consumers use one for signed snapshots. The built-in implementation is `joseTokenVerifier({ jwks | discovery, algorithms, typ, decryptionKeys })`. A verifier that calls an RFC 7662 or RFC 9767 introspection endpoint satisfies the same interface: `claims` is the introspection response, `cause` is `expired` for `active: false`. The interface has only `verify`: the key set is the verifier's own configuration, not something `permdock doctor` reads back.

### TokenSigner [#tokensigner]

```ts
interface TokenSigner {
  sign(
    payload: Record<string, unknown>,
    options: {
      typ:
        | "permdock-snapshot+jwt"
        | "permdock-approval+jwt"
        | "permdock-decisions+jwt"
        | "permdock-policy+jwt"
        | "permdock-capability+jwt";
      audience?: string | string[];
      expiresAt?: number; // NumericDate (Unix seconds); written as `exp`
    },
  ): Promise<string>; // compact JWS
  readonly kid?: string;
  jwks?(): Promise<JSONWebKeySet>; // public keys, for a /.well-known/jwks.json route
}
```

`sign` writes `alg`, `kid` and `typ` into the protected header and nothing else; it sets `iat` and `jti` itself and copies `iss` from its configuration, so the caller only supplies the format-specific claim (`snapshot`, `approval`, `events`, `policy`, `capability`) and optional `aud` / `exp`. The built-in implementation is `joseTokenSigner({ key, alg, kid, issuer })`; a KMS-backed signer implements the same two methods. Signing is optional everywhere: `permdock.snapshot()` without a `signer` returns the plain snapshot JSON ([wire formats](/docs/concepts/wire-formats)).

### Typed provider principals [#typed-provider-principals]

Each provider entry exports a base principal type and accepts a `schema` option, any Standard Schema, for the claims or fields it cannot know in advance:

```ts
import { subjectFromSupabase, type SupabasePrincipal } from "permdock/supabase";
import { z } from "zod";

const claims = z.object({
  tenant_id: z.string().uuid(),
  user_role: z.enum(["viewer", "admin"]),
  plan: z.enum(["free", "pro"]),
});

const { data, error } = await supabase.auth.getClaims();
export const subject = subjectFromSupabase(error ? null : data.claims, {
  schema: claims, // invalid claims drop `principal.claims`, never the subject
  roles: "user_role",
  tenant: "tenant_id",
});
// subject.principal is SupabasePrincipal; custom fields live on principal.claims
```

| Entry | Base type | What it fixes | What `schema` adds |
| --- | --- | --- | --- |
| `permdock/supabase` | `SupabasePrincipal` | `id` (`sub`), `kind: 'user'`, `assurance` (`aal`), `email` when present | Custom access token hook claims; `user_metadata` is never read |
| `permdock/clerk` | `ClerkPrincipal` | `id`, `tenant` (active organization), `memberships[0]` from `org_role`, `fea` roles | Custom session claims |
| `permdock/better-auth` | `BetterAuthPrincipal` | `id`, `tenant` (`activeOrganizationId`), memberships from `member` and `teamMember` rows | `additionalFields` on the user |
| `permdock/jwt` | `JwtPrincipal` | `id` (`sub`), `issuer` (`iss`), `kind`, `assurance` (`acr`, `amr`, `authTime`), `binding` (`cnf`), RFC 9068 `roles` / `groups` / `entitlements` | Any other claim |
| `permdock/convex` | `ConvexPrincipal` | `id` (`tokenIdentifier` or `subject`), `kind` | Custom claims on the identity |
| `permdock/mcp` | `McpPrincipal` | `id` from `authInfo`, `kind`, the `actor` and `delegation` halves | `authInfo.extra` claims |

Validation failure is not a throw: the principal becomes anonymous and `on('auth')` reports `schema` as the reason, the same fail-closed rule as an invalid token. The `schema` output type is intersected with the base type, so `subject.plan` in a condition is typed and a typo is a compile error. There is no `declare module 'permdock'` augmentation and no `$Infer` accessor: augmentation types values that may not exist at runtime and cannot be validated, and an inference accessor cannot see configuration TypeScript cannot link statically ([tenancy](/docs/concepts/tenancy) alternatives).

Two helper types cover the policy side: `PrincipalOf<typeof policy>` and `SubjectOf<typeof policy>` are the principal and subject types the policy's `subject` function produces, for use in server actions, tests and handlers without re-deriving them.

### MembershipSource and RoleSource [#membershipsource-and-rolesource]

```ts
interface MembershipSource {
  membershipsFor(
    principal: { id: string; kind?: string },
    options: { tenant?: string },
  ): Membership[] | Promise<Membership[]>;
  list?(query: {
    scope: string;
    id: string;
  }): MemberEntry[] | Promise<MemberEntry[]>; // { principal: { id }, membership }
  version?(principal: {
    id: string;
  }): number | undefined | Promise<number | undefined>;
  readonly claimsFirst?: boolean;
}

interface EntitlementSource {
  entitlementsFor(
    principal: { id: string },
    options: { tenant?: string },
  ): string[] | Promise<string[]>;
}

interface RoleSource {
  rolesFor(
    tenant: string,
    context?: { held: string[] },
  ): CustomRole[] | Promise<CustomRole[]>;
  assignable?(tenant: string): string[] | Promise<string[]>;
  globalRoles?(): CustomRole[] | Promise<CustomRole[]>; // scope: 'global'
}

interface ApprovalPolicySource {
  approvalPoliciesFor(query: {
    tenants: string[];
  }): ApprovalPolicy[] | Promise<ApprovalPolicy[]>;
}
```

Both are documented with their evaluation rules on the [tenancy](/docs/concepts/tenancy) page. A membership may carry `x` and a custom role `meta.x`, the application's own data; an invalid one is dropped and the membership or role stays ([tenancy](/docs/concepts/tenancy#memberships)). They are read-only from PermDock's point of view: PermDock never creates, updates or deletes a membership or a custom role, so a source is a query, not a repository. A source that throws yields no memberships or no custom roles for that request (fewer grants, never more) and an `on('auth')` event; it never fails the request. A throwing `assignable` makes nothing assignable in that tenant. `globalRoles` returns [platform custom roles](/docs/concepts/custom-roles#platform-custom-roles), read once per signed-in subject.

`createPermDock` calls `rolesFor` once for each tenant the subject has a live membership in, before the first check, and passes `held`: the role names those memberships hold there, nested scopes included, declared and custom alike. Checks are synchronous, so the source cannot be asked later, per decision.

`rolesFor` returns every custom role of the tenant, not only the ones the subject holds. The instance knows only the custom roles the source returned, so `assignableRoles`, `assignablePermissions` and `decideRoleChange` cannot offer or assign a role it left out. `customRoleSource(reader, options?)` builds that source from a function that reads a tenant's roles from the application's store:

```ts
import { customRoleSource } from "permdock";

const customRoles = customRoleSource({
  rolesOf: (tenant) => db.customRoles.findMany({ where: { tenant } }), // every role of the tenant
  globalRoles: () => db.customRoles.findMany({ where: { scope: "global" } }),
});
```

* It keeps only the roles whose `tenant` is the requested one, so a store query that returns another tenant's rows grants nothing there.
* `assignable` and `globalRoles` on the reader are passed through.
* `{ read: "held", policy }` returns `[]` without reading when every `held` name is a declared role of `policy`, for a tenant's roles and for `globalRoles`, which `createPermDock` calls with the global role names the subject holds (`globalRoles({ held })`). Use it on requests that never manage roles, where only the roles the subject holds matter; keep the default `read: "all"` on a role-management page, a members table and the server action behind `decideRoleChange`.

`customRoles` also takes a `RoleSourceFactory`, `(subject) => RoleSource | undefined`, called once per instance with the resolved subject (principal, actor and delegation). An adapter option is shared by every request, and a `RoleSource` is not told whose roles it reads, so a source that reads per user (the custom roles a principal holds, `postgrestSources(client).customRoles`) is passed as `customRoles: (subject) => subject.principal && sources.customRoles(subject.principal)` instead of keeping the last principal in a closure. A factory that throws reads no custom roles and reports `source-threw`; `permdock.derive({ customRoles })` takes one too.

`testRoleSource(source, { tenant, declared, every })` fails a source that returns fewer roles than `every` names with nothing held.

`ApprovalPolicySource` is read once per instance for the subject's tenants, alongside the custom roles. Unlike the other sources it fails toward denial: a throw, a rejected promise or an entry that does not load against the policy makes every call an allow would grant deny with reason `approval` and detail `approval-policy-unavailable`, because dropping the entries would skip an approval someone configured. An entry for a permission the policy does not declare applies to nothing. [Approvals](/docs/adapters/approvals#approval-policies-as-data) covers the entry shape and how entries combine with the code requirement.

`memberships` also takes an array of sources: `composeMemberships` merges them, keeps entries that differ in `via`, expiry, `managedBy` or seats apart, lists across every source that can `list`, and reports the highest `version`. One throwing source fails the whole lookup closed. `list` returns every member of one scope instance for member lists and access reviews. `version` is the principal's authorization version, called with the principal's id and the `roles` and `memberships` the token claims, so a source can read more in the same call when the claims show it will be needed: with the policy's `fresh` list, token memberships behind it are stale ([Supabase token hook](/docs/adapters/supabase-hook)). `claimsFirst(sources, { version?, onStale? })` sets `claimsFirst`: the verified token's memberships are kept unless the principal says they were truncated. With `onStale: 'reread'` (and a `version`) a token whose `authzVersion` is behind the source's, or absent, has its memberships read from the sources instead, so a membership added after the token was minted counts at once; the default `'deny'` keeps the token's memberships and denies `fresh` permissions. A `version` that throws keeps the token's memberships and marks the subject stale. `fromTable` and `fromJunction` in `permdock/supabase` are SQL sources that implement all three; `postgrestSources` implements `membershipsFor` over the generated `subject_for` function and `version` over `authz_version_for` (or over `subject_for` when, with `policy`, the token claims a custom role) for a backend that only has supabase-js.

`memoryMembershipSource(entries)` is the in-process default, keyed by principal id. `membershipsFor` returns a copy of that principal's memberships and ignores `tenant`, and `list` matches on `scope` and `id`. `memoryRoleSource(customRoles)` routes a role with a `tenant` to `rolesFor` and a `scope: 'global'` role to `globalRoles`.

`EntitlementSource` is a subject input too: `entitlements` on `createPermDock` merges its plan names for the validated active tenant into `principal.plans`, so `plan()` grants apply. `memoryEntitlementSource(byTenant)` is the in-process default, and `fromStripeEntitlements({ stripe, customer })` reads Stripe Entitlements through a structural client. A throwing source adds no plans and an `on('auth')` event with `source: 'entitlements'`. What a custom role may grant is bounded by the ceiling of assignable declared roles whatever the source returns ([custom roles](/docs/concepts/custom-roles)).

### RelationSource [#relationsource]

```ts
interface RelationSource {
  ancestors(query: {
    resource: string;
    id: string;
    through: string;
    depth: number;
  }): RelationChain | Promise<RelationChain>;
  related(query: {
    resource: string;
    id: string;
    relation: string;
  }): RelationHolder[] | Promise<RelationHolder[]>;
  row?(query: {
    resource: string;
    id: string;
  }): Row | null | undefined | Promise<Row | null | undefined>;
}
type RelationChain = {
  restricted?: boolean;
  ancestors: { id: string; restricted?: boolean }[];
  truncated?: boolean;
};
type RelationHolder = { startsAt?: number; expiresAt?: number } & (
  | { principal: { id: string } }
  | { group: { resource: string; id: string; relation: string } }
);
```

`through` is `'parent'` for the parent chain, or a link name, for which the source returns the one instance the link points to (`depth` is then 1). `related` is asked only for concrete relations (field, edge or principal), never for one declared with `includes` alone; core expands implication and follows group holders by asking `related` again for the group. The optional `row` returns one row by id (`null` when there is none); only [`inherit()`](/docs/concepts/relationships#inheriting-a-permission-through-a-link) grants read it, and without it they deny with `relation-unavailable`.

`createPermDock(policy, user, { relations })` takes one source for the `through` and edge-table relation grants ([relationships](/docs/concepts/relationships)). Unlike the subject inputs it is read during `can` and `decide`, because what it answers depends on the row, so it follows the `LimitStore` rules for synchronous evaluation: a synchronous answer is used, a Promise is never awaited on the decision path, and `permdock.loadRelations(permission, rows)` is the async step that loads what those rows need into the instance's cache. Answers are cached per instance and query, never at module level. A missing source, a throw, a rejection, an answer that is not a chain or a holder list, or a Promise that was not loaded denies with `relation-unavailable`; a cycle, or a chain past `depth` with no holder within it, denies with `relation-depth`. `ancestors` returns at most `depth` entries nearest first and sets `truncated` when the chain goes on to a row that exists. It flags every restricted entry, and the start in `restricted`, and ends the walk after a restricted entry only when the resource's `restricted` closes `'parent'` ([what a restricted row stops](/docs/concepts/relationships#what-a-restricted-row-stops)); core stops the walk where the resource says and checks for cycles itself, so a source may return more. `related` may return expired holders: core compares `startsAt` and `expiresAt` with the decision's clock. `whoCan` reads `related` for every instance on the chain.

### CredentialVerifier and SettingsSource [#credentialverifier-and-settingssource]

```ts
interface CredentialVerifier {
  verify(key: string): Credential | null | Promise<Credential | null>;
  touch?(id: string, at: number): void | Promise<void>; // after a key resolved; not awaited, errors ignored
}

interface SettingsSource {
  settingsFor(
    tenant: string,
  ): TenantSettings | undefined | Promise<TenantSettings | undefined>;
}
```

A verifier is where an API key is authenticated, so it belongs next to the `subjectFrom*` resolvers, never in core: `apiKeyVerifier({ find })` splits `pdk_<id>_<secret><checksum>`, reads one row by id, and compares the key's SHA-256 hash in constant time. `subjectFromApiKey` still re-validates what a verifier returns with `parseCredential` and checks expiry, revocation and the tenant settings itself, so a verifier that returns too much cannot widen a key. A verifier or settings source that throws denies with `source-threw`. Settings are read on every key use as well as at creation, so a tightened tenant rule reaches keys already issued ([API keys](/docs/concepts/credentials)).

### DirectoryStore [#directorystore]

```ts
interface DirectoryStore {
  getUser(tenant: string, id: string): Promise<DirectoryUser | null>;
  findUsers(
    tenant: string,
    filter: ScimFilter,
    page: ScimPage,
  ): Promise<ScimPageResult<DirectoryUser>>;
  putUser(tenant: string, user: DirectoryUser): Promise<DirectoryUser>;
  patchUser(
    tenant: string,
    id: string,
    ops: ScimPatchOp[],
  ): Promise<DirectoryUser>;
  deleteUser(tenant: string, id: string): Promise<void>;
  getGroup(tenant: string, id: string): Promise<DirectoryGroup | null>;
  findGroups(
    tenant: string,
    filter: ScimFilter,
    page: ScimPage,
  ): Promise<ScimPageResult<DirectoryGroup>>;
  putGroup(tenant: string, group: DirectoryGroup): Promise<DirectoryGroup>;
  patchGroup(
    tenant: string,
    id: string,
    ops: ScimPatchOp[],
  ): Promise<DirectoryGroup>;
  deleteGroup(tenant: string, id: string): Promise<void>;
  groupsFor(tenant: string, userId: string): Promise<DirectoryGroup[]>;
}
```

`DirectoryStore` is the one exception to "a source is a query": it is a repository, because SCIM provisioning is a write. The trust rule is the same as for the other subject inputs with one refinement. Only [`scimHandler`](/docs/adapters/scim) writes to it, and only after authenticating the identity provider or the PermDock Cloud relay with a per-tenant credential; `directoryMembershipSource(store)` is the `MembershipSource` that reads it (`groupsFor`), and `decide` sees only the `Membership[]` that source returns. Every method takes the tenant first, so a store cannot answer across tenants by accident. A store that throws on read yields no memberships for that request, like any source; a store that throws on write makes the handler answer the IdP with a SCIM error and no partial state. The store lives in the application (`memoryDirectoryStore()` by default, your tables in production); PermDock Cloud relays provisioning into it and never holds the authoritative copy (invariant 15, [PermDock Cloud](/docs/adapters/cloud)). An environment in Cloud-native directory mode has no `DirectoryStore` at all: the Cloud is the directory of record and its facts arrive as claims on a token that `subjectFromJwt` verifies, so the Cloud still implements neither `MembershipSource` nor `RoleSource`.

## Operational interfaces [#operational-interfaces]

`ApprovalStore`, `DecisionSink` and `SnapshotSource` are defined on their own pages ([approvals](/docs/adapters/approvals), [audit and observability](/docs/concepts/audit-and-observability), [snapshots](/docs/concepts/snapshots)) and summarised here for the trust rule only. `LimitStore` is the one operational interface whose answer produces a `denied`: exhausted remaining is reason `limit`, and a missing store, a throw, or a thenable from `consume` or `remaining` is `limit-unavailable`. `can` never consumes. See [extension interfaces](/docs/concepts/extension-interfaces).

```ts
interface LimitStore {
  consume(input: {
    key: string;
    subjectId: string;
    count: number;
    per: string;
    now?: number;
    tenant?: string;
  }): { remaining: number } | Promise<{ remaining: number }>;
  remaining(input: {
    key: string;
    subjectId: string;
    count: number;
    per: string;
    now?: number;
    tenant?: string;
  }): { remaining: number } | undefined;
}
```

`remaining` must be synchronous. A Promise from either method is denied, never awaited. A store counts per `subjectId` and `tenant` (the active tenant, absent without one). `per` is a duration such as `'hour'`, `'15 min'` or `'1 d'`, validated when the grant is defined; `memoryLimitStore()` forgets ended windows. Pass `limits: memoryLimitStore()` to `createPermDock`; omitting it fails closed on every quota grant.

The rules follow from synchronous evaluation. `can`, `filter`, `simulate` and an `approval-required` outcome only peek `remaining`, because UI paths call `can` on every render and consuming there would spend quota on a hover; `decide` and `assert` consume, since mutations call them. A Promise is truthy, so reading an async `consume` as a remaining count would fail open; the store is never awaited and a thenable is `limit-unavailable`. An async store (Redis, a database) sits behind a synchronous cache or implements only a synchronous `remaining` for UI peeks. Exhausted (`limit`) and store-down (`limit-unavailable`) are separate reasons so operators can tell them apart. If the first matching allow is over quota, the next matching allow is tried, as allows OR together. `limits` is never filled in automatically: a forgotten production store would otherwise count silently in process memory, and falling back to unlimited on a store failure would fail open.

### PolicySource [#policysource]

`PolicySource` is the channel for grants authored in PermDock Cloud ([hostable permissions](/docs/concepts/policies)).

```ts
interface PolicySource {
  current(): PolicyDocument | null; // synchronous: the last verified document
  refresh(): Promise<void>; // called by the application on its own schedule
}
```

* `createPermDock(policy, user, { policies: source })` calls `current()` once, when the instance is created, and merges the document into that frozen instance. No check calls `refresh()` or the network, and there is no module-level policy the source swaps in the background. Every server and agent adapter forwards the `policies` option.
* The source is only read when the policy lists `hostable` permissions. A `current()` that throws is reported through `on('error')` and treated as no document.
* The application calls `refresh()` on an interval, a cron or a webhook. A refresh that fails, or a document whose signature does not verify with the application's `TokenVerifier`, keeps the previous document; `refresh()` never rejects. With no document, only code grants apply.
* Hosted grants apply only to permissions the policy marks `hostable`, never override a code deny, and never weaken an `approval` a code grant requires; a grant outside those bounds is dropped and reported through `on('error')` as `hosted-grant-dropped`.
* The trust class is a policy input: unlike the operational interfaces it can widen an outcome, which is why it is bounded by `hostable` in code. The defaults are `memoryPolicySource(document)` in the package (it runs the document through `parsePolicyDocument`) and `testPolicySource(source, { policy })` in `permdock/testing`. `cloud().policies` is the hosted implementation ([PermDock Cloud](/docs/adapters/cloud)).

```ts
interface WhereCompiler<TTarget, TOptions = unknown> {
  (condition: Condition, target: TTarget, options?: TOptions): unknown;
}
```

`toWhere` from each query adapter is a `WhereCompiler`. A compiler receives the normalised JSON tree ([conditions](/docs/concepts/conditions)), including the `memberOf` node, and must return a fail-closed value (an always-false expression, `{ OR: [] }`, `eb.lit(false)`) when the condition is the empty allow set. It never receives closures; `permdock.where` has already excluded them and set `partial`. A community compiler for a new target (MongoDB, a sync engine's permission language) implements this interface and runs `testWhereCompiler` below; it does not become a package entry without a `Why` update on [adapters](/docs/adapters). `WhereCompiler` is exported from `permdock` as a type so such a compiler can be typed against it.

A compiler for a query language need not walk the condition tree itself. `compileWhere(condition, options)` from `permdock/compile` lowers it, for one subject, to a `CompiledWhere` tree: `never`, `always`, `compare`, `isNull`, `and`, `or`, `not`, `exists` for a mapped membership table, and `sql` for a `related` subquery. Subject refs are already bound, `memberOf` is already an id list or an `exists`, and every `not` already admits the NULL field the in-memory evaluator treats as a failed comparison. `permdock/drizzle`, `prisma` and `kysely` render this tree, so a renderer is a switch over nine node kinds.

```ts
import { compileWhere, type CompiledWhere } from "permdock/compile";

const tree: CompiledWhere = compileWhere(permdock.where(permissions.post.read));
```

`testWhereCompiler(compiler, { target, isFailClosed?, matches? })` checks the empty allow set and `sqlFunction` twins. With `matches(compiled, row)`, which answers whether the output selects one row, it also runs a table of comparisons, `contains` with literal `%` and `_`, lists, `isNull`, `and`, `or` and `not` over rows with NULL fields, and expects exactly the rows the in-memory evaluator keeps.

### Why RLS and PowerSync do not render `CompiledWhere` [#why-rls-and-powersync-do-not-render-compiledwhere]

`CompiledWhere` is bound to one subject at request time. Generated RLS and PowerSync sync rules are compiled once per policy and read the subject in SQL at query time (`auth.uid()`, JWT claims, a GUC), call membership helper functions, and pass `opaque` SQL through. Rendering them from `CompiledWhere` would need a second, subject-free tree, so they keep their own compilers over the condition AST. `rlsParity` holds the RLS compiler to the evaluator's answers instead, including a `not` or a deny over a NULL column, which RLS renders as `(…) is not true`.

## Events [#events]

`permdock.on(event, handler)` registers observers on the request-scoped instance:

| Event | Payload | When |
| --- | --- | --- |
| `decision` | `DecisionEvent` (permission, outcome, subject summary, tenant, membership, via, actor, delegation) | Every `decide`, `assert`, `can` |
| `denied` | The same, filtered | Outcome `denied` |
| `approval` | `ApprovalRequest` | Outcome `approval-required`, and on resolve |
| `decision` sink `membership` | `MembershipEvent` | A role change from SCIM, Better Auth, Clerk or `membershipEvent()`; written to the `DecisionSink`, not `on()` |
| `decision` sink `credential` | `CredentialEvent` | An API key created, rotated or revoked (`credentialEvent()`, `memoryCredentials`), or a sampled use (`subjectFromApiKey`); written to the `DecisionSink`, not `on()` |
| `auth` | `{ reason, cause?, source }` | A resolver, verifier, membership source or role source failed closed. `reason` is `invalid-token` (RFC 6750 vocabulary) for anything a `TokenVerifier` rejected, with the specific `cause`; `schema`, `unknown-role`, `groups-overflow` and `source-threw` for the others |
| `error` | The thrown value | A closure threw or returned a thenable, or an adapter hook (`onDenied`, `context`, a `protect` loader) threw or returned an invalid value; the hook's default applies |

Handlers cannot change a `Decision`; they receive a frozen copy after the outcome is computed. Throwing inside a handler is caught and reported once.

## Conformance runners [#conformance-runners]

`permdock/testing` ships one runner per interface so an implementation can prove it honours the contract before it is used:

```ts
import {
  testSubjectResolver,
  testTokenVerifier,
  testTokenSigner,
  testMembershipSource,
  testEntitlementSource,
  testRelationSource,
  testRoleSource,
  testApprovalPolicySource,
  testDirectoryStore,
  testApprovalStore,
  testDecisionSink,
  testSnapshotSource,
  testPolicySource,
  testLimitStore,
  testReplayStore,
  testRevocationFeed,
  testCredentialVerifier,
  testSettingsSource,
  testWhereCompiler,
} from "permdock/testing";

testSubjectResolver((claims) => subjectFromSupabase(claims, { schema }), {
  valid: [okClaims],
  invalid: [null, { role: "anon" }],
});
testTokenVerifier(myVerifier, { jwks: fixtureJwks }); // runs the JWT adapter behaviour table against the fixture tokens
testTokenSigner(kmsSigner, {
  verifier: joseTokenVerifier({ jwks: await kmsSigner.jwks() }),
});
testMembershipSource(documentMembers, {
  principals: [alice],
  expect: {
    alice: [{ on: { resource: "document", id: "d_1" }, roles: ["editor"] }],
  },
});
testRelationSource(folderGraph, {
  objects: [{ resource: "folder", id: "f_payroll", relation: "viewer" }],
  expect: { ancestors: { "folder:f_payroll": ["f_hr"] } },
});
testRoleSource(betterAuthRoles, {
  tenants: ["o_acme"],
  declared: policy.assignable,
});
testApprovalPolicySource(approvalPolicyTable, { policy, tenant: "o_acme" });
testDirectoryStore(drizzleDirectory, { tenants: ["o_acme", "o_globex"] }); // replays the recorded Okta and Entra ID requests
testApprovalStore(postgresStore, { reopen: () => openPostgresStore() });
testLimitStore(memoryLimitStore());
testPolicySource(permdockCloud.policies, { policy });
testCredentialVerifier(apiKeyVerifier({ find }), {
  key: () => issueTestKey(),
  revoke: () => revokeTestKey(),
});
testSettingsSource(tenantSettings, { tenant: "o_acme" });
testWhereCompiler(toMongo, { fixtures: conditionFixtures });
```

| Runner | Asserts |
| --- | --- |
| `testSubjectResolver` | Never throws; invalid material yields the anonymous subject and an `auth` event; valid material yields a frozen principal whose fields match the base type and the schema; `tenant` is set only on a membership match |
| `testTokenVerifier` | Never throws; every fixture token in the behaviour table produces the listed `cause` (`alg: none`, wrong `typ`, `jku` header ignored, JWE without keys, `EdDSA` on a non-Ed25519 key, expired, wrong audience, unknown `kid`); a valid `at+jwt` returns `ok: true` with the fixture claims and header; `RSA1_5` and `none` are rejected regardless of configuration |
| `testTokenSigner` | Output is compact JWS whose protected header has exactly `alg`, `kid`, `typ`; `alg` is in the allow-list; `iat`, `jti` and `iss` are set; the supplied verifier accepts it with the same `typ`; `jwks()` when present contains the signing key's public half |
| `testMembershipSource` | Returns only well-formed memberships (one shape each: a named scope instance, the `tenant` / `team` input form, or `on`); with `policy`, every membership normalises under its [named scopes](/docs/concepts/scopes); a throw yields an empty list; results are JSON; with `list`, every instance a principal holds lists that principal; with `version`, the version is a finite number or absent; `tenant` is passed to every `membershipsFor`, for a source keyed by tenant |
| `testEntitlementSource` | Returns plan names as strings for a tenant (matching `expect` when given) and none without a tenant |
| `testRelationSource` | Chains are nearest first with non-empty string ids and no longer than `depth`; `depth: 1` returns the first ancestor with `truncated` when there are more; `depth: 0` returns none; holders have a string principal id or a group with string `resource`, `id` and `relation`, and finite `startsAt` / `expiresAt`; `expect.links` pins the instance a link points to (`resource:id>link`), and a group holder is pinned as `resource:id#relation`; a source with `row` answers each object with an object or `null`, and an unknown id with `null`; an unknown object answers an empty chain and no holders instead of throwing; `expect` pins chains (`resource:id`) and holders (`resource:id#relation`) |
| `testCredentialVerifier` | The live key (a string, or a function issuing one) verifies to a v1 credential whose id is the key's, twice alike; every other string (empty, not a key, a flipped secret, another id, another prefix, a trailing character) is `null` without a throw; with `touch`, touching the key's id and an unknown id leaves what the key verifies to unchanged; with `revoke`, the revoked key stops verifying |
| `testSettingsSource` | The tenant's settings are plain JSON with a valid `credentials` block (`maxTtl` finite and non-negative, `kinds` only `user` / `service`); an unknown tenant has none |
| `testRoleSource` | Every custom role belongs to the requested tenant; every `includes` entry is a declared role; with `{ policy }`, `validateCustomRole` drops nothing (every `grants` key is inside the ceiling); `assignable` is a subset of the declared roles; with `{ every }`, `rolesFor(tenant, { held: [] })` returns exactly the named custom roles |
| `testApprovalPolicySource` | Entries are plain JSON and belong to the queried tenant or to none; every entry loads against `policy`; an unknown tenant gets no tenant entries |
| `testDirectoryStore` | Users and groups round-trip through `put`, `get`, `find` and `patch`; the normalised `PATCH` shapes from the recorded Okta, Entra ID and Google Workspace requests apply correctly; `userName` and `externalId` are unique per tenant and a duplicate is reported, not overwritten; `groupsFor` reflects `members` after every operation; a second tenant never sees the first tenant's resources; `directoryMembershipSource` over the store yields no memberships for an `active: false` user; `scimHandler` over the store publishes `session-revoked` into a `RevocationFeed` and `onChange` when a `PATCH` deactivates a user |
| `testApprovalStore` | Create, get, resolve, list, expire round-trip; a resolved request cannot be resolved twice; a repeated `create` keeps the record; concurrent `consume` succeeds once; an approver from another tenant is refused; with `{ reopen }`, an approval resumes after a restart; the token is opaque to the store |
| `testDecisionSink` | Accepts batches including a `membership` event; `flush` is idempotent; a throw never propagates to the caller |
| `testSnapshotSource` | Returns a snapshot, a v1 object or a compact JWS; with `subscribe`, subscribing and unsubscribing never throw. It does not trigger an invalidation |
| `testPolicySource` | `current()` is synchronous and returns `null` or a document `parsePolicyDocument` accepts; `refresh()` never rejects; with `{ policy }`, every merged grant targets a `hostable` permission and every other grant is reported as dropped |
| `testReplayStore` | A remembered key is seen; an expired key is not; with `claim` and `release`, two concurrent claims succeed once and a released key can be claimed again |
| `testRevocationFeed` | Every subscriber receives each event until it unsubscribes; a throwing listener does not stop the others; an event without a principal or with an unknown `kind` is rejected with a `TypeError` and delivered to no one. Delivery may be asynchronous |
| `testLimitStore` | `consume` counts down; remaining after the cap is less than zero; `remaining` when present is not a thenable |
| `testWhereCompiler` | The empty allow set compiles to a fail-closed value (`false` / `null` / `undefined`, or `options.isFailClosed`); adapters pass constant-false SQL, `{ OR: [] }` or `eb.lit(false)` |

The runners are what the provider adapters run in this repository's CI and what a community implementation runs in its own. An HTTP or agent adapter has its own runners, `testHttpAdapter` and `testAgentAdapter`: [build an adapter](/docs/adapters/server-kernel#build-an-adapter).
