PermDock

Changelog

Every PermDock release, newest first. Per-package detail is in packages/permdock/CHANGELOG.md.

0.3.1 (2026-10-11)

  • permdock rls generate and permdock supabase hook generate read global roles only from the rows without a tenant when rls.roles references a roles table through through and rls.customRoleWrites.roles names that table with a tenant column. permdock_replace_global_roles resolved keys over every row, so replacing a user's roles with ['dispatcher'] inserted one row per tenant role named dispatcher and kept rows that referenced a tenant role. It now resolves, keeps and inserts only global rows, and a key only tenant roles have raises unknown-role. permdock_has and the token hook no longer read a global-roles row that references a tenant role as a global role.

0.3.0 (2026-10-10)

  • decideRoleChange checks a change to a global role. Pass scope: 'global' and no id for a role declared without on; RoleChange is now a union in which id is required for a named scope and absent for 'global', so a helper typed Partial<RoleChange> needs Extract<RoleChange, { id: string }>. The authority is the one assignableRoles({ scope: 'global' }) lists, which is new: the assignable global roles the subject may hand out through its own global roles. exclusiveWith, managedBy: 'idp' and the self-change rule apply as for a scoped role, a role held at a named scope is denied with scope, and an id on a global change is denied with validation. The snapshot-backed client answers assignableRoles({ scope: 'global' }) with an empty list.
  • permdock rls generate --seeds-out <directory> numbers a new seeds migration one second after the newest versioned migration in the directory instead of using the current time, so regenerating the same directory gives the same file name and --check names the same missing file on every run. The clock is read only when the directory holds no versioned migration. A seeds migration still sorts after the migrations that create role_permissions, and an unchanged policy still writes nothing.
  • permdock rls generate writes permdock_replace_global_roles(p_user, p_roles text[]) when rls.roles names the global-roles table. It deletes the user's rows whose role is not in the list and inserts the missing ones in one call, for a role editor that saves a whole set. It runs with the caller's rights, so the table's policies and the rls.assignments trigger judge each row, and execute goes to authenticated. A role column that references a roles table (through) refuses an unknown key with unknown-role. Regenerate the helpers.
  • permdock/better-supabase accepts better-supabase 0.7 as its optional peer, so an app can install better-supabase 0.7.0 next to PermDock with strict peer dependencies.
  • permdock/better-supabase: bucketPolicy and topicPolicy accept { policy, schema?, scope, segment? } besides { manifest, catalog, scope, segment? }. With the definePolicy result the app already imports, no manifest or catalog is read at runtime: the scopes, the permissions with row conditions and the scopes a permission is granted at come from the policy, and schema names the helper schema (default permdock). The same keys are refused as before.
  • supabase.hook.api: { schema?, prefix? } makes permdock supabase hook generate write public.permdock_subject_for, permdock_members_of and permdock_authz_version_for (schema public and prefix permdock_ by default), so a backend can call the hook's readers through the Data API without hand-written wrappers. Each is security definer, revoked from public, anon and authenticated, and granted to service_role: the only grant the hook makes to it, and only with api. postgrestSources(client, { api }) takes the same setting and derives schema, fn, membersFn and versionFn from it; the four options still override.

0.2.0 (2026-10-10)

  • A createPermDock instance asked for a tenant other than the one a user key is held to now answers for no tenant: it keeps none of the owner's memberships, reads no entitlements and denies every check, so protect(), can(), where(), snapshots and tenants() agree. Before, the key's tenant took precedence over the requested one, and a route of tenant T granted a permission the owner held in the key's tenant. This covers every adapter, the MCP and agent entries and the remote PDP, which all resolve the tenant through createPermDock.

  • A user credential may name a tenant, which holds a personal key to one tenant of its owner. parseCredential accepts it (it was rejected before), decideCredential takes tenant on a user request and refuses one the creator is not a member of with exceeds-creator, and the tenant's credentials settings govern the key. createPermDock makes the key's tenant the active tenant, keeps only the owner's memberships inside it and drops the owner's global roles, whether the memberships come from owner, the token or a memberships source, so can(), decide(), where(), snapshots and tenants() answer for that tenant only. rls.apiKeys already holds a key whose claim names a tenant to it in SQL; a verifier copies the credential's tenant into that claim. ids still holds resource ids only.

  • A credential may list any number of permissions. parseCredential and decideCredential no longer cap permissions at 64 entries, so a verified key that delegates a read-and-write set over a catalog of 100 or more permissions resolves through subjectFromApiKey instead of failing as invalid-claims and becoming anonymous. The cap guarded self-contained tokens; a credential always comes from the application's own store through a verifier. ids per entry keeps its limit of 64.

  • A membership role column that stores a role id is read as a key through a roles table: fromTable columns.role, fromJunction roles, an rls.memberships table's role and authorizeSql's tenant.role take { through, on, column }, the shape rls.roles already takes. The token hook, in-process membershipsFor and list, the database mode helpers, the custom-role match, the ownership triggers, permdock_can_assign and memberOf all join the roles table, supabase_auth_admin gets read access to it, permdock_bump_authz_version_role_keys bumps every global and membership holder of a renamed key, the manifest's membership role carries through, and doctor PD028 counts the roles table's id and key columns. permdock_can_assign now also answers from membership sources in database mode.

  • A membership row can hold roles in several columns: role in rls.memberships and fromTable columns.role accept an array of columns and { through, on, column } references, and fromJunction accepts roles: { sources }. The row holds every non-null key in the token hook, membershipsFor, the database-mode helpers and the holder-count triggers.

  • A membership source can read its user through another table: fromJunction user and fromTable columns.user take { through, on, column }, the shape a role column already takes, so portal contacts in customer_contacts (contact_profile_id) whose login is contact_profiles.user_id need no copied user_id. The token hook, in-process membershipsFor and list, the database mode helpers and permdock_can_assign join that table and filter on its user column, with suspension applied to the joined user and to the source's instances. A row whose referenced row is missing or has no user holds no membership. supabase_auth_admin gets read access to the table, rls generate --indexes indexes its user column, permdock_bump_authz_version_member_users bumps the users a membership row references, and permdock_bump_authz_version_linked_users bumps both the old and the new user when the referenced row is re-linked, skipping a login that was deleted. The manifest's membership user carries through, and doctor PD028 counts the referenced table's id and user columns. User and role through combine on one source.

  • A policy delegation can name an OAuth client by a stable name, to: { kind: 'oauth-client', client: 'cli' }, instead of its id. subjectFromSupabase, permdock/mcp (createPermDock and subjectFromMcp) and subjectFromJwt's actor option take clients (ClientNames: a record from name to id, or a function from a verified id to a name) and set actor.client. An id the mapping does not name, or names twice, leaves client unset and the delegation does not apply. The catalog's delegation to gains client.

  • A policy's Map and Set members (rolesByName, resources, index.grantsByKey) now throw a TypeError on set, add, delete and clear, as the rest of the frozen policy does on assignment. memoryRoleSource returns a copy of its role list on each call.

  • A resource whose rows are the scope's instances, such as an organizations table whose own id is the organization id, can take scoped roles: its one memberOf relation to the scope ({ self: { field: "id", memberOf: "organization" } }) names the field when it is not the scope's key. can, where(), whoCan, snapshots and rls generate read that field; a snapshot's scopes[] entry carries it in the new optional fields map. definePolicy throws when a resource has several memberOf relations to a scope and none on its key.

  • A route that checks no permission can now require OAuth scopes. protect(null, loadData?, { oauthScopes }) in permdock/server and the Hono, Express, Fastify, Elysia and Node adapters, withPermDock({ oauthScopes }) without protect in the Supabase middleware, and an operationPermissions entry with only oauthScopes (read through operations on permdock/server) gate a route such as a chat endpoint: an anonymous caller gets 401, a delegated token holding none of the scopes gets the same 403 insufficient_scope challenge as a permission route, and a first-party session passes. The guard is a ScopeGuard, with no decision. An app no longer needs its own rule that maps HTTP methods to scopes. Routes with a permission behave as before.

  • A single membership can be suspended while the user's other memberships and sign-in stay intact. disabledAt names a nullable timestamp column on an rls.memberships table, on fromTable (columns.disabledAt) and on fromJunction; a row with a value keeps its role, and only its permissions drop. rls.suspension.memberships.keep lists what a suspended membership still holds, as a scope's keep does. The database mode helpers, the inline memberOf checks, authorize(), the token hook, subject_for, members_of and the in-process sources honour the column, the jwt mode helpers and authorize() honour the keep of a claim membership, and the holder-count triggers and permdock_can_assign count no suspended membership. The manifest carries memberships[].disabledAt and rls.suspension.memberships.keep, and PD061 lists the kept keys.

  • A snapshot check reads the clock only when a grant needs it. A usePermission hook under a PermDockProvider that is still awaiting its snapshotPromise no longer calls Date.now(), so it renders in a Next.js Cache Components static shell without a Suspense boundary.

  • A snapshot instance's can(), filter() and pick() skip the decision token and the deep freeze, and the UI stores reuse a local decision for the rest of one render pass. useTenant, useMemberships, useRoles, useAssignableRoles, useAssignablePermissions and useSubject return the same object until the store changes.

  • A snapshot now carries ids, the row id field of each resource whose id option is not id. A client can on a membership held on one row then answers as the server does, where it used to deny. The client can() returns false instead of throwing when reading the row throws. The plan seats a client counts now use the policy's scopes.

  • A snapshot-backed instance (fromSnapshot) now answers an instance action checked without a row the way the server does: from the first-scope memberships of the active tenant, or from the instance team(id) selected. It used to deny every such check on a partitioned resource with tenant-mismatch or scope, so a page guard that passed on the server failed on the client.

  • A suspended scope can keep permissions. rls.suspension.scopes.<scope>.keep (and the same key on SupabaseSuspension for fromTable, fromJunction and authorizeSql) lists permission references or keys that members of a suspended instance, and of every instance nested in it, still hold through their roles: cancelling a scheduled deletion, restoring the instance, exporting before a purge. The generated permitted_<scope>_ids helpers and their _for forms compare the grant's permission with the kept keys in both modes, inline policy checks drop the suspension check for a kept permission, authorize() answers a kept permission for a suspended tenant, and the membership sources, the token hook, subject_for and members_of keep the rows of a suspended instance with a keep list. Membership.keep carries it in process: such a membership counts only for the kept permissions, never for role listings or assignment checks, and a malformed keep drops the membership. subjectFromSupabase, the JWT claim mapping, the claims schema and the hook manifest read the new field. permdock doctor PD061 lists each scope's kept keys and warns on a key the definitions do not declare. Without keep, the generated SQL is unchanged.

  • Add the { subject: { session: { live: true } } } condition, which holds only while the Auth server still holds the session: subjectFromSupabase(claims, { liveSession: true }) sets it in process, and rls generate compiles it to permdock.permdock_session_live(), which reads auth.sessions (Supabase dialect only).

  • An ApprovalPolicy entry with where on a collection permission no longer loads silently and never matches: it is refused like any other invalid entry, so the source denies with approval-policy-unavailable until the entry uses check. The new validateApprovalPolicy(policy, entry) returns { ok: false, problem } (unknown-permission, invalid, where-on-collection, stale-on-without-version) so an application can refuse a bad entry when it is saved.

  • An rls.memberships table mapping takes a constant membership kind: via: { value: 'staff' } gives every row that kind, the way fromJunction's via does, so a staff table needs no kind column (or a generated column) for roles with for: ['staff'] to grant anything. The helpers, the exists checks of memberOf, the ownership triggers and permdock_can_assign read the constant, and the hook manifest describes it as via: { value }. via: '<column>' keeps naming a column.

  • An instance check without a row (can(permission, undefined) in a page guard) now answers from memberships of the first scope only. A membership of a nested scope, such as a portal contact's customer membership inside an organization, applies only when the row carries that scope's key or when team(id) selected its instance; otherwise the check is denied with scope. This is a breaking change for an app that relied on a nested role passing an organization-level guard: pass the row, or select the instance with team(id). Snapshots already denied these checks and are unchanged.

  • Approvals gain holder(permission), an approver who holds the permission in the request's tenant through any role (custom roles included), checked through verdict.permissions that approvalsHandler reads with the new permdockFor option and approverPermissions. anyOf(...) lets any one of several approvers sign (a list, or allOf(...), stays all-of). A stage can carry its own escalation, and an ApprovalPolicy entry may now set escalation, which widens only that entry's stages.

  • Breaking: PermDockProvider from permdock/react-native requires subjectId, the signed-in user's id or null when signed out. A stored snapshot is used only when its principal matches subjectId; null clears the storage. With a verifier, the provider stores the signed JWS and verifies it again at launch instead of storing the decoded snapshot. A stored copy starts with status: 'stale' until the first refresh. Storage that throws or rejects no longer breaks the provider. New headers no longer rebuild the store; the next refresh sends them. The interval pauses in the background, a return to the foreground refreshes, and the new subscribeOnline prop skips refreshes while offline.

  • Breaking: PermDockProvider from permdock/react no longer suspends its readers on a snapshotPromise. Until the promise resolves, usePermission, usePermissions, usePermDock and the other hooks answer from the current snapshot with status: 'pending' (an empty snapshot denies), <Protected> renders its pending slot, and the store re-renders the readers when the promise settles. Permission UI therefore stays in the Next.js Cache Components static shell, as the guide describes. A promise React has already settled hydrates before paint, and a rejection fails closed with status: 'server-only' instead of reaching an error boundary. To keep the previous behavior, pass suspend to PermDockProvider from permdock/react or permdock/next.

  • Breaking: subjectFromBetterAuth from permdock/better-auth no longer copies the user's additional fields into principal.claims without options.schema, because a user can edit their own additional fields. Pass a Standard Schema that lists the server-set fields to keep: subjectFromBetterAuth(auth, session, { schema: z.object({ plan: z.string() }) }).

  • Breaking: a thrown assert now answers with the status protect sends for the same decision. toProblemDetails() and problemFromError() return 401 for no subject or a missing step-up, 404 for a row of a disclosure: 'hide' resource, 429 with the RateLimit fields for an exhausted limit and 503 for an unavailable limit store, where they returned 403. problemFromError(error, { credentials }) picks the 401 challenge. The AuthZEN and approvals 401s carry WWW-Authenticate, Nest's missing-row 404 and the Supabase middleware's 405 carry Problem Details, and Convex maps PermDockValidationError.

  • Breaking: every URL PermDock emits moves from permdock.dev to permdock.com: Problem Details type values (https://permdock.com/problems/<slug>), the $id and $schema of catalog-v1.json, supabase-claims-v1.json and supabase-manifest-v1.json, and the docs links in errors and CLI output. A client that matches on a type URI or a schema $id updates the host.

  • Breaking: two fixtures in permdock/testing drop the better-supabase name, with the same contents.

    BeforeAfter
    supabaseClaimFixtures.betterSupabasesupabaseClaimFixtures.tenantPlans
    better_supabase.feature_claims in supabaseHookManifestFixturepublic.feature_claims
  • Deleting an auth.users row no longer fails when its memberships cascade. The generated permdock_bump_authz_version_for and permdock_bump_authz_version_role_keys now skip a user that is no longer in auth.users, so the version trigger on a membership table cannot insert a permdock_authz_version row that violates its foreign key. Regenerate the hook SQL and apply the changed function body in a migration.

  • Doctor PD039 and supabase hook generate now find helpers written with rls generate --split. They read the helpers part of an rls.out path with a {part} placeholder, and the SQL files in the hook file's folder, besides rls.out and the migration folders. Before, a project that kept the split path in a script got a false warning that the helper schema had no helpers.

  • Edge groups accept a subject column per group: groups: { resources: { team: { relation: 'member', subject: 'team_id' } } }. Share tables that keep each subject kind in its own typed column (user_id uuid, team_id bigint) no longer need one subject column plus a kind column. Without column, a row names the principal when the edge's subject is not null and a group when that group's column is not null. memoryRelations, the where() compilers and permdock rls generate all read the new form, and the EdgeGroup type is exported. A plain relation name per group still works and still needs column.

  • Every customRoles option, on createPermDock and on each adapter (permdock/server, permdock/mcp, permdock/next, the agent adapters and the rest), also takes a RoleSourceFactory, (subject) => RoleSource | undefined, called once per instance with the resolved subject. A source that reads one principal's roles no longer has to remember the last principal an adapter served; a factory that throws reads no custom roles and reports source-threw. RoleSourceFactory is exported from permdock.

  • Every adapter's options type now extends the exported InstanceOptions, so approvalPolicies, relations, entitlements and policies reach the instance from every adapter; before, only the core factory read approvalPolicies, so approval rules kept as data never applied to adapter decisions and toolApproval could not return user-approval for them. The Hono, Express, Fastify, Elysia, Nest, Node, tRPC and oRPC adapters accept actor, and tRPC and oRPC accept otel.

    Breaking: the snapshots option is removed from every server and agent adapter factory, which never read it. Delete it from the options object; a SnapshotSource such as cloud().snapshots goes to the source option of permdock/react-native.

  • Generated RLS renders not and deny policies as (…) is not true, so a row whose deny condition is NULL stays visible, as can() allows it. Run permdock rls generate again to pick this up.

  • Grants take group, a stable name for their condition group in generated SQL: allow(permissions.quote.read, { where: { status: 'sent' }, group: 'sent' }) gives the grant key quote.read#sent instead of a positional quote.read#2, so hand-written SQL that repeats the condition keeps working when other grants are added, removed or reordered. Unnamed groups keep their positional keys. rls generate refuses one name on two conditions of a permission and two names on one condition, and definePolicy refuses a name that is not lower case letters, digits, _ and - starting with a letter, or is break-glass. The option is ignored in process. Without it nothing changes.

  • New permdock/better-supabase entry for better-supabase 0.6, which is an optional peer.

    • authorizationProvider({ manifest, catalog, scope?, approver? }) builds its authorization config from permdock.manifest.json and permissions.catalog.json. With rls.customRoles at a root tenant scope, canAssign and canAssignFor call permdock_can_assign_any and its _for form, so better-supabase's can_assign accepts custom roles. permissionsFor calls the new permitted_<scope>_permission_keys_for helper that rls.mode: 'database' writes. approver sets canApprove with approvals.distinctApprover. Scopes carry a normalised idType, and a missing catalog is reported in problems.
    • bucketPolicy and topicPolicy build its storage and realtime access policies from Permission references.
    • The SQL templates call permitted_<scope>_ids_by_permission, permdock_has_permission and their _for forms, so a deny of the permission is subtracted and a permission split into #n grant keys still matches.
    • apiKeyVerifier({ keys, manifest?, serviceRoles?, allPermissions? }) turns its API keys block into a CredentialVerifier; serviceRoles defaults to the manifest's rls.apiKeys.serviceRoles. Keys need createApiKeys({ prefix: "pdk" }). A rotated key's credential expires when its grace period ends. apiKeyClaimOptions reads its api_key claim settings from rls.apiKeys.
    • subjectFromBetterSupabase(session, options?) reads its sessions with the features claim as plans; plans: { claim, keys } decodes entitlements.claim.keys short codes, and apiKeys maps an apiKey session to the key's credential subject.
    • toolPolicy({ permdock, data? }) returns the authorize and visible hooks of createMcp for tools whose meta is a permission.
    • credentialGuard(provider, { permdock, use, revoke? }) wraps a CredentialProvider so PermDock decides each token use.

    The Supabase manifest's helper names may now end in _permission_keys and _permission_keys_for. Regenerate permdock.manifest.json with permdock supabase inspect --out.

  • New command permdock config warns on config keys PermDock does not read, and --print prints the effective config with every default. Every command now exits 2 when a module path or a config section has the wrong type. Under --json, a non-zero exit always prints JSON: a text failure such as collect --check drift becomes Problem Details with type https://permdock.com/problems/cli-failed. doctor loads the policy and scans the sources once per run. It reports a policy module that fails to load as PD059 and a source file that does not parse as PD060.

  • New entry permdock/compile exports compileWhere and the CompiledWhere tree that the Drizzle, Prisma and Kysely compilers render. testWhereCompiler takes an optional matches(compiled, row) and then checks that a compiler selects the rows the in-memory evaluator keeps.

  • On Supabase, permdock rls generate writes permdock_user_id() into the helper schema, and every generated helper, policy, field view and trigger reads the subject through it in every mode. It reads the sub of request.jwt.claims and returns null for a token whose sub is an empty string, such as a tenant service key, where auth.uid() fails the uuid cast. execute on the function follows the helpers' grants, and an anon policy that reads the subject now grants anon the helper schema as rls.anonExecute does. The Supabase manifest lists it in rls.helpers, and rls import reads it as principal.id. Regenerate and apply the migration.

    Breaking: withSubject in permdock/drizzle, permdock/kysely and permdock/prisma and permdock rls verify no longer set request.jwt.claim.sub. A test or job that sets only request.jwt.claim.sub sets request.jwt.claims with a sub instead. Doctor check PD062 reports migrations that still read or set a request.jwt.claim.<name> setting.

  • REST routes can now require the OAuth scopes an operation declares, as permdock/mcp tools can. protect(permission, loadData, { oauthScopes }) in every HTTP adapter, or operations on permdock/server (the result of operationPermissions), narrows which coarse scopes reach a route: when the subject carries a token delegation, a token holding none of them is refused before the loader runs with 403 and WWW-Authenticate: Bearer error="insufficient_scope" naming the first. A subject without a delegation, such as a first-party session, is not gated, and the decision still needs the delegation to cover the permission. operationPermissions entries take oauthScopes, read by oauthScopesForRequest(method, path) for REST and oauthScopesForOperation(id) for permdock/mcp's oauthScopesFor, so one declaration narrows both. Routes without the option behave as before.

  • Scope ids compare as text. A row whose key column is a number (a bigint id read through supabase-js) now matches a membership whose id is that number's text, in can, snapshots, heldRoles({ id }), custom-role matching, decideRoleChange, activate and capabilities; before, the check denied with scope. A membership whose id, within or on.id is a number or bigint is read as its text instead of being dropped.

  • Server decisions do less work per request. The decision endpoints look permissions up by key, build their handler once and reuse the request's subject; tRPC and oRPC subscriptions open with the row protect loaded; Nest rules sharing a loadData run it once; Fastify routes opt out with config: { permdock: false }.

    withSubject (Drizzle, Kysely, Prisma) sets the role and claims in one select set_config(…) statement. subjectFromBetterAuth reads every member row in one adapter query, and createClerkSubjectResolver({ cache: { ttl } }) caches each user's memberships: 'all' list for up to 30 seconds.

  • Snapshots carry the roles and plans in vocabulary and the permissions in assignable as plain objects, with kind as an ordinary field. They held the policy's own leaves, whose kind is a non-enumerable property, so React refused the snapshot as a Server Component prop in development ("Only plain objects can be passed to Client Components") and a JSON round trip dropped kind, so a client isRole or isPlan check failed on a parsed snapshot.

  • The React, Vue, Svelte and Solid providers take snapshotUrl, where refresh() fetches a fresh snapshot; it defaults to endpoint. They used to drop it.

  • The Supabase hook file now defines authz_version_for(p_user uuid) returns bigint, the authorization version subject_for reports without reading roles or memberships, under the same grants. postgrestSources takes versionFn (default authz_version_for) and memberships.version calls it, so claimsFirst(sources.memberships, { onStale: 'reread' }) costs one cheap call per request and reads subject_for only when the token is behind. Regenerate the hook file and, when permdock is not an exposed schema, add a wrapper for the new function; until then version falls back to subject_for as before.

  • The Supabase manifest gains requires, the supabase/sdk capability-matrix feature ids the setup depends on. permdock/testing exports supabaseClaimVectors, the claim fixtures as a conformance vector file for auth.session.get_claims.

  • The permdock-wire skill now wires @supabase/server into Hono, H3, Elysia and NestJS through a pipeline bridge (toHono([withClaims(), withPermDock()]), then c.var.permdock) instead of the deprecated @supabase/server/adapters/*, which are removed on 1 December 2026.

  • The agent adapters keep every field of the Actor they resolve, as the HTTP adapters do. Before, permdock/ai-sdk, permdock/claude-agent, permdock/eve, permdock/openai, permdock/mcp and permdock/a2a kept only id and kind. An actor option that returned readOnly: true therefore lost its read-only narrowing, and the client name permdock/mcp reads from clients never matched a to: { kind, client } delegation.

  • The hook file defines members_of(p_scope text, p_id text) returns jsonb next to subject_for: every live membership of one scope instance as [{ principal: { id }, membership }], read from the hook's membership sources with their expiry and suspension filters, executable by no client role. postgrestSources(client, { membersFn? }) calls it for memberships.list, once per instance, so countHolders and whoCan work over PostgREST. Regenerate the hook and grant the new function to your backend role the way you granted subject_for.

  • The hook file now defines subject_for(p_user uuid) returns jsonb: the global roles, memberships, authorization version and, in database mode with rls.customRoles, the held custom roles the hook would put in a token for p_user, or { id, active: false } for a suspended user. No client role may execute it. postgrestSources(client, { schema?, fn? }) in permdock/supabase reads it through supabase-js once per user and returns memberships (with version, for claimsFirst), customRoles(principal) and subject(userId), so a backend without a SqlQuery no longer re-implements the membership sources, the suspension filter and a custom-role reader with the admin client.

  • The optional @nestjs/common, @nestjs/core and @nestjs/platform-express peers now accept Nest 11 (>=11). permdock/nest uses no API that is new in Nest 12, and CI runs its unit and integration suites against Nest 11 as well. An app that carries Nest 11 through another dependency no longer gets ERR_PNPM_PEER_DEP_ISSUES.

  • UI stores key endpoint answers for a row without an id by its content and read the row id from the snapshot's ids field, so two such rows no longer share one answer. useApproval polling resumes when a component subscribes again after a StrictMode or remount unsubscribe. <Protected tenant> renders pending while the provider's snapshot is on its way. PermDockProvider keeps its store in state, so React never rebuilds it for unchanged props.

  • Under pg-delta, rls generate --split no longer writes the rls.realtime and rls.storage policies into the declarative policies part. The Realtime and Storage services create realtime.messages and storage.objects, so pg-delta's shadow database, and a local stack with either service off, has neither table and supabase db schema declarative sync failed with relation "realtime.messages" does not exist. The policies now go in the --seeds-out migration, each in a do block that creates it only where to_regclass finds its table. With those policies, the policies part under pg-delta needs the seeds part with --seeds-out. Other layouts are unchanged.

  • PermDockProvider from permdock/react and permdock/react-native keeps its store when a re-render passes an inline headers object, fetch function or verifier. Before, every parent render rebuilt the store, dropped the refreshed snapshot and cached answers, and on React Native refetched the snapshot. headers compare by value; fetch and verifier call the latest prop.

  • allow(permission, { requires }) makes a grant count only where the subject also holds other permissions through a role: allow(drive.read, { to: relation(drive, 'viewer'), requires: file.read }) keeps a share to the organizations where the user holds file.read, and requires: [file.read, file.download] asks for every listed permission. Each requirement is met by a role grant without a row condition, declared or custom, globally or on the row's scope instance, minus a deny of that permission at the same instance. It folds into the grant's row condition for can, where() and snapshots, and permdock rls generate ANDs one permdock_has_permission or permitted_<scope>_ids_by_permission check per key. Grant.requires on a normalised grant is a readonly string[]; the catalog lists one key as a string and several as an array. definePolicy rejects requires on a deny, on a collection action and for an undeclared permission.

    A delegated caller (an OAuth client or an API key) uses an allow with requires only when its scopes cover every required permission and, unless the granted permission is read-only (meta.readOnly, or the read and list actions) and the allow is not a role grant, the granted permission as well. can(), decide(), where(), snapshots and the rls.apiKeys checks in the generated SQL apply the same rule. A key stored with file:read keeps reading the drives a requires: file.read share reaches after an app moves drive.read to relation grants, and a read-only key cannot write through an editor share.

  • assignableRoles() no longer offers a custom role that allows nothing after the ceiling (empty, deny-only, or with every entry outside the ceiling or undeclared), and decideRoleChange denies assigning one with not-assignable-by. Revoking such a role stays granted for an actor who may assign at its scope.

  • assignableRoles() now lists the tenant's custom roles from the RoleSource that the subject may hand out, and decideRoleChange assigns and revokes them instead of denying with unknown-role. A custom role qualifies when the subject may assign a declared role at its scope (or holds meta.manageRoles) and may hand out every permission and level it allows; it has no holder count and inherits for and exclusiveWith from its included roles. Declared roles behave as before.

  • can, decide, assert and explain accept a permission typed as plain Permission, such as one from listPermissions or findPermission, when the call passes data (undefined for a check without a row). Apps no longer cast the instance's methods to call them with a permission chosen at run time; literal references keep their narrower overloads, so an instance action without a row still fails to compile.

  • claimsFirst(sources, { version, onStale: 'reread' }) re-reads the memberships from the sources when the token's authzVersion is behind the source's version, or absent, instead of keeping the token's memberships and denying fresh permissions. A token minted before a new membership or contact link then sees it on the next request without the application rebuilding the instance. A fresh token costs one version read and no membership read; a version that throws keeps the token's memberships and marks the subject stale. The default, onStale: 'deny', keeps today's behaviour.

  • cloud().approvals.resolve throws the ApprovalError code and message the Cloud sends, such as approver-not-eligible. It used to report every refusal other than 409 and 410 as approval-not-found.

  • countHolders(memberships, { scope, id, role }) counts the principals holding a role in one scope instance from the MembershipSource's list, live memberships only and each principal once, for the holders that decideRoleChange needs with min, max or transferOnly. It answers undefined when the source cannot list or the read fails, which the role change check denies.

  • createPermDock({ secretKeys }) in permdock/supabase/middleware turns a Supabase secret key that withSupabase matched by name into a tenant service principal capped at the declared permissions, and withSubject now writes the rls.apiKeys claim (sub: '' for a service key) for any principal that acts through a credential. fromSupabasePostgres(ctx.postgres) in permdock/supabase adapts withPostgresClient's client to the SqlQuery that fromTable, fromJunction and supabaseApprovalStore take.

  • createPermDock from permdock/server returns getSnapshot(request, { tenant, include, tenants }) for React Router, TanStack Start, SvelteKit and Nuxt loaders, reusing the request's cached subject. snapshotHeaders(snapshot, { tags }) returns a private Cache-Control capped at expiresAt, Vary, an ETag and Cache-Tag. cacheLifeFor and snapshotTag are now exported from permdock/server as well as permdock/next.

  • createPermDock now calls RoleSource.globalRoles with { held }, the global role names the subject holds, and customRoleSource(reader, { read: 'held', policy }) skips the platform read while every one of them is a declared role, as it already did for a tenant's roles. A request from a subject with only declared global roles no longer reads platform custom roles. Sources that ignore the argument are unaffected.

  • createPermDock now passes RoleSource.rolesFor(tenant, { held }) the role names the subject's live memberships hold in that tenant, nested scopes included. A source whose custom roles live in a database can return [] without reading when every held name is a declared role. The parameter is optional in the type, so existing sources and direct callers keep working.

  • customRoleSource({ rolesOf, assignable?, globalRoles? }, { read? }) builds a RoleSource from a read of every custom role of a tenant, so assignableRoles, assignablePermissions and decideRoleChange see roles the subject does not hold. It keeps only the requested tenant's roles; read: 'held' with a policy skips the read when the subject holds only declared roles. testRoleSource takes every, the custom role names a tenant has, and fails a source that returns fewer with nothing held.

  • database mode adds permdock_can_assign_for(p_user, p_role, p_scope_id), the assigns check of permdock_can_assign for a user the caller names, and, with rls.assignments and custom roles, permdock_can_assign_custom_role_for(p_user, p_tenant, p_scope, p_scope_id, p_role) over the new internal permdock_custom_role_guard_for and permdock_custom_role_beyond_for. Trusted SQL that acts later for a stored user, such as accepting an invitation, re-checks that the inviter may still assign the role. No client role may execute them. The custom-role form is written only where every scope has member_<scope>_ids_for (Supabase).

  • decide, can, assert and filter take a scope option naming a declared scope: only memberships of that scope answer the check, global roles still apply, and any other membership is skipped with scope. Use { scope: 'organization' } for a staff guard on a collection action (quote.list), which by default also passes through a nested membership such as a customer contact's. The default is unchanged. Snapshot instances apply the option too.

  • definePolicy({ oauthScopes }) maps coarse OAuth scopes an authorization server issues (mcp:read) to the permissions they cover. A token holding one is delegated those permissions in every decision, snapshot and listing, permdock/mcp and permdock/a2a accept it in their scope pre-checks, and insufficient_scope challenges (MCP, A2A and the server kernel) name the first coarse scope that covers the permission instead of a <resource>:<action> scope the server cannot issue.

  • describe(decision, { messages }) accepts messages.reasons as a function (reason, decision) => string | undefined as well as a record, so a translation layer can read the denied decision. undefined keeps the reason code.

  • doctor.srcPath sets the directories or globs permdock doctor reads for its source checks (PD001, PD002, PD007 to PD015, PD044 and the others over code), so client components outside collect.srcPath are checked without changing the catalog. It defaults to collect.srcPath; PD003 and PD004 still follow the catalog.

  • inherit(permission, { through }) is a grantee that holds on a row when the subject holds permission on the row a link list or the parent points to: allow(node.read, { to: inherit(drive.read, { through: ['drive'] }) }) makes a node readable wherever its drive is, by any grant of drive.read. In process the instance reads the target row through the new optional RelationSource.row (implemented by memoryRelations) and decides it with the full policy; a source without row denies with relation-unavailable. permdock rls generate compiles it to a check against the target's permitted_<resource>_rows, which it now writes for every targeted resource. definePolicy rejects it on a deny, on a collection action, for an unreachable or undeclared target and for a cycle. testRelationSource checks row when a source has it.

  • localSnapshot({ manifest, read, subscribe }) in permdock/react-native builds the snapshot from the device's own membership and custom-role rows, and the provider takes it as source. localSnapshotManifest(policy) from permdock writes the JSON manifest at build time, so the policy never ships to the app.

  • operationPermissions matches a HEAD request to the GET entry of its path when no HEAD entry matches, so protect(null) and a permission gate no longer fail on the HEAD requests a framework such as Next.js serves with the GET handler. A declared HEAD entry still wins.

  • permdock collect --watch collects again when a source folder, the config file or the configured permissions or policy module changes. The Next.js plugin and permdock/unplugin now pick up an edited permissions.ts instead of reusing its first import, ignore the catalog and barrel they wrote, and debounce reruns to one at a time.

  • permdock doctor PD001 now flags a client file importing any server-only entry, including permdock/express, permdock/drizzle and permdock/otel. PD007 now counts every HTTP, MCP and agent adapter entry, including permdock/trpc, permdock/a2a and permdock/webmcp.

  • permdock doctor PD001 now reports a client entry that imports the configured policy module, resolving each import from the importing file: relative paths, tsconfig paths, and package names through node_modules and the package's exports, so a workspace package that exports the policy (@acme/access/permdock/policy) is followed to the policy file. Type-only imports are not reported.

  • permdock doctor PD002, collect and usage now resolve a reference's root identifier to its binding. A parameter, local variable or module variable named permissions (an array of strings, for example) is no longer read as a permission tree, so permissions.length is not reported as an unknown permission; an import of permissions or of any definePermissions export, and a definePermissions declaration, still are, in any file order.

  • permdock doctor PD004 now compares the catalog on disk with the catalog permdock collect would write from collect.srcPath. Before, it built the comparison from doctor.srcPath, so a doctor.srcPath wider than collect.srcPath reported catalog drift that permdock collect could never clear. PD002 and PD003 still count the references under doctor.srcPath.

  • permdock doctor PD005 looks for the skills and their lock in the working directory and every directory above it up to the workspace root, and accepts the skills CLI's skills-lock.json when it lists the permdock skill, so a package in a monorepo whose skills are installed at the root no longer reports them missing.

  • permdock doctor PD014 now reads only the options of PermDock calls: object literals passed to a function imported from a permdock entry, the object literals nested in them, and a const object such a call names. An object with a jwks key that never reaches PermDock, such as another library's environment object, is no longer reported. Within PermDock options, discovery next to issuer is now reported as the docs describe, as discovery next to jwks already was.

  • permdock doctor adds PD065: it warns when a file that imports permdock/react-native reads a permission whose grant needs the server, because the device denies it offline.

  • permdock doctor reports PD063, an error, for a migration that alters or drops a reserved Supabase role or grants a reserved membership, which supautils rejects. permdock rls generate refuses to write such a statement.

  • permdock doctor, collect and usage no longer throw on a for loop with an empty initialiser (for (;;), for (; test; )) or on an array hole in a snapshot({ include }) list. The PD002 scanner reads every for, for...in, for...of and for await form.

  • permdock powersync generate writes PowerSync Sync Streams (sync-config.yaml, edition 3) from the policy and rls.memberships, and permdock powersync verify --db fails when a stream syncs a fixture row the policy denies. A resource with a deny grant gets no stream. The permdock_* streams sync the signed-in user's own membership and global-role rows, the roles tables they reference and, with rls.customRoles, the custom roles of the user's tenants: the rows localSnapshot needs. powersync verify passes a fixture's subject.claims to auth.parameter() and fails when an own-rows stream syncs another user's rows.

    With powersync.manifest set, generate writes localSnapshotManifest(policy) to that path. Doctor check PD058 flags a stale streams file or manifest and reports a config that does not compile. powersyncSource(db, { manifest, queries, read }) in permdock/react-native builds the local snapshot from PowerSync db.watch queries.

  • permdock rls generate --custom-roles in database mode emits permdock_replace_custom_role_grants, permdock_rename_custom_role_grants and permdock_delete_custom_role_grants. They save, rename and delete a custom role's rows with the rules of validateCustomRole and assignablePermissions, checked against the signed-in caller, so an application no longer hand-writes a security definer function for it. A null tenant with scope global writes a platform custom role, checked against the global ceiling and global grants, and a caller holding a meta.manageRoles permission through a global role passes the membership check for any tenant.

    The permdock_trusted_replace_custom_role_grants, permdock_trusted_rename_custom_role_grants and permdock_trusted_delete_custom_role_grants variants keep the definition checks without the caller checks, for migrations, jobs and backends; only the owner may execute them until a migration grants a role.

  • permdock rls generate adds permdock_permission_keys(p_scope) next to permdock_permission_keys(): the declared keys some declared role is allowed on that scope (global or a scope name), so a role editor at the organization lists only what an organization role can hold. The zero-argument form is unchanged.

  • permdock rls generate folds a constant membership kind (rls.memberships via: { value }, or a table with no via) at generation time instead of emitting a per-row case over every role with for: a role the kind allows gets no filter, and one it excludes is filtered out by name. Access is unchanged.

  • permdock rls generate names graph helpers after the resource's snake_case name, so a camelCase resource such as chatThread gets permitted_chat_thread_ids, permdock_link_chat_thread_<link> and permdock_closure_chat_thread instead of failing with "unsafe scope name". Link names are converted the same way. Doctor PD032 now also reports two resources that share one snake_case name. Lowercase resource names generate the same SQL as before.

  • permdock rls generate now emits permdock_role_permissions(p_role, p_scope, p_tenant, p_scope_id) and permdock_permission_keys(), so role editors and admin screens no longer hand-write the join over role_permissions and the custom-role tables. The first returns the permission keys a role holds on a scope with allow or deny: a declared role from role_permissions, and with rls.customRoles in database mode a custom role of the caller's tenant, resolved through the ceiling as resolveCustomRole does. Only a member of the tenant (or a meta.manageRoles holder for a platform role) may read a custom role. The second lists every declared permission key. Both are executable by authenticated and not by anon. The change adds two functions to the helpers file and breaks nothing.

  • permdock rls generate puts the min, max and transferOnly triggers on the tables of fromTable and fromJunction membership sources when a scope has no rls.memberships table, so a policy with ownership rules can keep its memberships in sources. One permdock_holders_<scope> and one permdock_transfer_only_<scope> function count holders over every source of the scope, and MembershipSql.holders (typed MembershipHolders) describes the rows they count. A source without holders still leaves the counts to decideRoleChange.

  • permdock rls generate suggests indexes only for the columns its policies and helpers look rows up by: scope keys, condition fields compared with the subject, a claim or a membership, and membership user columns. It no longer suggests indexes for comparisons with constants such as status: 'sent', so the indexes part and rls verify --introspect warnings lose those entries. With the new --db $DATABASE_URL flag, generate also leaves out an index an existing index already leads with, and one whose table or column the database lacks, with a warning.

  • permdock rls generate writes permdock_can_assign_any(p_role, p_tenant, p_scope, p_scope_id) and, in database mode, permdock_can_assign_any_for(p_user, ...) whenever a role declares assigns: one check for a declared or a custom role. permdock_can_assign_custom_role is now written with custom roles in database mode whether or not rls.assignments is set. The permdock.manifest.json written by permdock supabase inspect lists the _for helpers and the assignment checks in rls.helpers, and adds rls.customRoles, rls.roles, rls.suspension and rls.assignments, so a package writing SQL next to the helpers can call them without reading PermDock's config. Regenerate the manifest with permdock supabase inspect --out.

  • permdock rls generate writes permdock_trusted_role_permissions(p_role, p_scope, p_tenant, p_scope_id) next to the member-facing permdock_role_permissions: the same rows and argument checks without the caller check, for server code such as an admin backend, a support console or a job. No client role may execute it, and the new rls.trustedReaders lists the Postgres roles it is granted to, such as ['service_role', 'support_reader'].

  • permdock rls generate writes permitted_<scope>_permission_keys(p_id) for each scope: every permission key the caller holds on one instance, as permdock_has_permission or permitted_<scope>_ids_by_permission would answer each key, in one set-based query. A screen or RPC that checks fifteen permissions on an organization makes one call instead of fifteen. The overload permitted_<scope>_permission_keys(p_id, p_keys) answers for the listed keys only. In database mode the _for(p_user, p_id) and _for(p_user, p_id, p_keys) forms answer for a named user and no client role may execute them.

  • permdock rls verify --advisors runs supabase db advisors --type security on --db or the local stack, names the doctor check for each lint and exits 1 on a WARN or ERROR row. With --revoke-columns, the <table>_visible_fields companion now lives in the helper schema (rls.schema) instead of public, so Supabase's security_definer_view lint no longer flags it; regenerate to move it.

  • permdock rls verify --tree no longer seeds placeholders that break a column's constraints. A required column it fills gets the first label of its enum type, or a value its single-column check constraint admits (the first literal of an = 'x' or in (...) check, the bound of a > n or >= n check), before falling back to a placeholder of its type. The new rls.treeValues sets column values for every row seeded into a table, keyed by table name, for constraints the generator cannot read.

  • permdock skills install follows a symlinked agent or skill folder (.claude/skills/permdock linked to .agents/skills/permdock) and writes the files once at the target, where it failed with "Cannot overwrite non-directory" before. permdock skills install --check compares the installed skills with the package version, writes nothing, and exits 1 listing every missing or changed file.

  • permdock.derive({ customRoles?, approvalPolicies?, relations? }) returns an instance for the same subject, active tenant, team, actor, delegation, sink and limits with the sources it names read again, so an app no longer rebuilds the instance from createPermDock to add a tenant's approval policies or every custom role of a tenant. It returns a promise only when a named source is asynchronous; a fromSnapshot instance returns itself, and permdock/pdp keeps its providers.

  • permdock.filter() no longer builds a decision token, freezes a decision or computes alternatives for each row it drops or keeps. Filtering 1,000 rows takes about a quarter of the time it did (pnpm bench).

  • permdock.snapshot() is typed by overload: without a signer it returns Snapshot, with one Promise<string>, and only an options value whose signer the compiler cannot see returns either. Callers drop the instanceof Promise check or the parseSnapshot round trip they needed before. The options type is exported as SnapshotOptions. An instance from fromSnapshot now signs when given a signer, as the overloads say, instead of returning the plain snapshot.

  • permdock/a2a and permdock/mcp now decide through the shared agent kernel. An A2A task failure's status follows the Problem Details matrix: 401 with a wwwAuthenticate challenge for an anonymous caller, 429 and 503 for limits, 403 otherwise. permdock/terminal protect exits with EX_NOPERM when load throws or returns nothing for an instance permission. permdock/convex accepts the instance options (memberships, sink, limits and the rest). permdock/testing adds testAgentAdapter.

  • permdock/ai-sdk takes unmapped: 'deny' | 'allow' and returns composeToolApproval(app). With 'allow', tools the tools map does not bind to a permission stay in capabilityMiddleware's list and pass toolApproval; the default 'deny' keeps hiding and denying them. composeToolApproval(app) runs PermDock's verdict first and asks the application's own approval function only for a call PermDock grants, so apps no longer wrap the adapter to compose a registry's confirmation. It returns the application's answer as is: undefined leaves the decision to the tool's own needsApproval or the SDK default, and an answer that is not a tool approval result denies.

  • permdock/approvals exports storedApprovalToken(store, decision, { denyPending?, now? }), the helper the adapters use to resume a call by its recomputed token, so an application that decides approvals in its own gate passes it to resumeDecision instead of reimplementing it.

  • permdock/approvals exports the list-paging helpers the built-in stores use, so a custom ApprovalStore no longer copies them: pageApprovals(matching, query) pages requests filtered in memory, approvalPageSize, encodeApprovalCursor and decodeApprovalCursor (with the ApprovalCursorPosition type) serve a store that pages in its own query, and listAllApprovals(store, filter) follows next to the last page.

  • permdock/fastify: the permdockHandler decision endpoint reuses the subject the permdock plugin resolved for the request, so subject runs once per request, as it does in Express, Node and Nest.

  • permdock/mcp adds cacheScope: 'private' to a filtered tools/list, prompts/list, resources/list or resources/templates/list result only when the request was sent under protocol revision 2026-07-28 or later. A 2025-era result no longer carries the field, which that revision does not define.

  • permdock/mcp takes actorKind on createPermDock and subjectFromMcp, the kind of the actor built from authInfo.clientId (default 'mcp-client'). Set it to 'oauth-client' so an OAuth token is the same actor kind in permdock/mcp as in permdock/supabase, permdock/jwt and permdock/a2a, and one policy delegation covers it on both surfaces.

  • permdock/mcp tools take oauthScopes, the OAuth scopes that reach the tool in place of those derived from its permission (the permission's own scope and the coarse scopes of oauthScopes in the policy). Any one of them reaches the tool and the first is the one an insufficient_scope challenge names. They only narrow: the token's delegation must still cover the permission, so the policy lists the permission under each coarse scope that may reach it and each tool names the one it needs. Two tools that share one permission can now need different coarse scopes, such as reading an export with mcp:read and creating one with mcp:write. Under enforce: 'procedure', oauthScopesFor(name) supplies them for the listing. An empty list throws at registration. Tools without the option behave as before.

  • permdock/nest adds PermDockModule.forRoot({ guard: 'global' }), which registers PermDockGuard as APP_GUARD, and decorateMethod(cls, key, ...decorators) for code without decorator syntax. Gateway messages run the same OAuth scope check, loader, decision and approval resume as HTTP routes, and PermDockExceptionFilter handles PermDockRevokedError. Protect(null) in Nest, and protect(null) in permdock/trpc and permdock/orpc, need a principal and the declared OAuth scopes but no permission.

    permdock/express answers a PermDock error from permdock(), protect or the decision endpoint with Problem Details instead of passing it to next(err). permdock() in permdock/orpc reuses context.permdock only when it built that instance for the same tenant. permdock/convex takes tenant and actor options, and its ConvexError carries the marker Convex needs to send data to the client. joseTokenVerifier keys cached JWKS by jwks_uri, so keys from an old URI never answer after discovery moves it.

  • permdock/next and permdock/next/client now load in plain Node ESM, such as Vitest with permdock external or a Node script, instead of failing with ERR_MODULE_NOT_FOUND for next/cache. The entries import Next.js modules by file and route next/navigation through the package import #next/navigation, so Next.js still resolves its server build under react-server.

  • permdock/next: getPermission lets Next.js interrupts (redirect(), notFound(), request-time bailouts) from subject or tenant pass through instead of turning them into a denial, and takes { tenant } as a third argument. The server PermDockProvider no longer replaces a failed snapshot with an empty one: hooks answer server-only, and with suspend the error reaches the nearest error boundary. Instances with a factory onDenied are frozen, and their tenant(), team() and derive() keep the handler.

  • permdock/next: the factory returns getSnapshot({ tenant?, include?, tenants?, tags? }). Call it inside your own 'use cache: private' function: it builds the session's snapshot and calls cacheLife(cacheLifeFor(snapshot)) and cacheTag(snapshotTag(sub), ...tags) in that scope. PermissionBoundary denied and approval also accept a function of the boundary state.

  • permdock/openapi exports operationPermissions(operations, { base? }), one declaration of each API operation's permission as "METHOD /path/{param}" to a permission (or { permission, operationId }), with forRequest(method, path) for an HTTP gate and forOperation(id) for permdock/mcp's permissionFor, and operationPermissionsFromOpenApi(document, permissions), which builds it from the x-permdock-permissions a description carries. REST routes and the MCP tools over them no longer need separate route tables that can drift apart.

  • permdock/react-native adds secureStoreStorage(SecureStore), which splits values into 2048-byte chunks, and the synchronous mmkvStorage(mmkv). It also adds appStateForeground(AppState) and netInfoOnline(NetInfo) for the provider's subscribeForeground and subscribeOnline. useSnapshotReady() is for the splash screen, usePermissionGuard(permission | permission[], data?) is for Stack.Protected and Tabs.Protected, and the type guard parseLocalSnapshotManifest(value) is also exported from permdock. A source is read again on each return to the foreground.

  • permdock/server exports createServerKernel with the ServerKernel and ServerKernelOptions types, the kernel every bundled HTTP adapter is built on. memoryMembershipSource and memorySnapshotSource join memoryRoleSource as in-process defaults, and testMembershipSource takes a tenant to pass to every membershipsFor.

  • permdock/supabase adds parseSupabaseManifest, which reads a permdock.manifest.json document (parsed, or the JSON text), validates it against schemas/supabase-manifest-v1.json and returns a frozen SupabaseHookManifest. It throws PermDockValidationError with every issue, so a manifest of another major is refused. SupabaseManifestActiveRow is now exported.

  • permdock/vue, permdock/svelte and permdock/solid export PermissionBoundary, which renders denied or approval for a thrown PermDockDeniedError or PermDockApprovalRequiredError (Svelte 5.3 or later). Their providers accept endpoint: false, read a headers getter on every request, and call refresh({ tenant }) when a tenant ref, getter or prop changes. useAssignablePermissions and assignablePermissions take a getter, Vue exports ProtectedProps, and permdock/svelte exports requiredPlans under the svelte condition.

    Breaking: Vue composables and Solid hooks throw when called outside setup(), an effectScope or a reactive owner, because their store subscription could never be removed there.

  • permdock/vue, permdock/svelte and permdock/solid re-run a check when a reactive row changes in place again; only permdock/react reuses decisions within a render pass. In every UI adapter, a throwing subscriber no longer stops the others from seeing a logout. Approval polls also back off on errors and stop after a 4xx or five failures, and a snapshot for another user drops the previous user's approval state.

  • permdockExtension from permdock/prisma now scopes findFirstOrThrow and groupBy. verifyDpopProof from permdock/jwt compares htu without the request's query and fragment, per RFC 9449 section 4.3. A new replay option on createJwtSubjectResolver with sender: "dpop" rejects a reused proof jti, and memoryReplayStore is exported from permdock/jwt.

  • permdock_can_assign now answers for global roles. A global role whose assigns lists another global role may assign it, and the check takes a null p_scope_id for that assignment. A scoped role is never assignable with a null instance, by a global assigner or anyone else. The rls.assignments triggers treat a row whose instance column is null as a global assignment: a declared role goes through permdock_can_assign(role, null) and a custom role through permdock_can_assign_custom_role(null, 'global', null, role), the platform custom-role checks. One invitations table can now hold tenant and platform invitations. Regenerate the RLS output; a row with a null instance that names a scoped role is now refused.

  • permittedIds(permdock, permission, scope, { within?, conditioned? }) lists the instances of scope in which the subject holds permission with no row condition, minus the instances a deny reaches, within its delegation: the in-process mirror of permitted_<scope>_ids_by_permission. Apps no longer loop over memberships calling can with a made-up row to list, for example, the customers a portal contact may open. Both sides treat a validity window as a row condition and a fields list as none. With conditioned: true, and the SQL overload permitted_<scope>_ids_by_permission(p_permission, p_conditioned boolean) and its _for form, the list includes the instances a conditioned allow reaches and subtracts only unconditional denies, leaving the row condition to the caller. grant_keys takes p_effect 'conditioned-allow' and 'conditioned-deny' for the keys that carry a row condition.

  • postgrestSources and supabaseApprovalStore now take their client as SupabaseRpcCaller, which a supabase-js client typed with a generated Database (SupabaseClient<Database>) satisfies. Before, its rpc typed the arguments as never for a function name the Database did not declare, so the call needed a cast to an untyped client. SupabaseRpcClient stays the type to implement a fake against; it satisfies SupabaseRpcCaller too.

  • postgrestSources takes policy. With it, customRoles reads nothing while every role the subject holds is declared, and memberships.version calls subject_for instead of authz_version_for when the token claims a custom role, so a custom-role token costs one call instead of two and a current token with declared roles keeps the version-only call. MembershipSource.version now receives the roles and memberships the token claims next to id.

  • problemDetails in permdock/openapi is a Standard Schema, with a JSON Schema, for the Problem Details body of a denial. When an oRPC contract declares oc.errors({ FORBIDDEN: { data: problemDetails } }), protect from permdock/orpc throws through that constructor, so clients receive a defined, typed error.

  • protectServer(server, { enforce: 'procedure', permissionFor }) in permdock/mcp lists and annotates tools by permission but leaves the call to the procedure the tool runs, so each call decides once. permissionOf(procedure) in permdock/orpc returns the permission of a procedure's protect.

  • resource({ restricted }) takes { field, stops } besides a column name. stops lists the inherited paths a restricted row closes: 'parent' for the parent walk and link names for grants that cross those links. restricted: { field: 'restricted', stops: ['parent'] } hides a node from shares on the folders above it while everyone who reads its drive through inherit(drive.read, { through: ['drive'] }) still reads it and its subtree; stops: ['drive'] closes only the drive link, and the parent walk then passes restricted rows. A closed link now also stays closed below a restricted row: a grant across it does not reach a row of a self-parented resource when a row above it within 32 parent hops is restricted, and denies with relation-depth when the chain goes on past that. can, where() (Drizzle, Kysely and resolveRelated, with a closure mapping or a recursive walk) and permdock rls generate agree; RLS reads the new permdock_restricted_<resource>() helper, and the closure keeps 32 levels for such a resource. definePermissions rejects an empty stops, an undeclared link and 'parent' without a parent. The catalog carries restrictedStops, and RelatedCondition gains restrictedAncestors and passRestricted.

    Breaking: the string form still closes the parent walk and every link, and now applies the check below a restricted row too, so children of a restricted row are no longer reachable through a link such as the drive. Use stops: ['parent'] to keep a link open. memoryRelations keeps walking past a restricted row when its restricted does not close 'parent', flags the start row in restricted either way, and sets truncated only when the next row exists. Regenerate the RLS SQL.

  • resource(…, { levels }) declares named conditions such as own, team and all, and a custom-role grant picks one with { permission, level }. The level is ANDed into the ceiling grant in resolveCustomRole, snapshots, customRoleClaim (key@level) and rls generate in both modes; an undeclared level is dropped as unknown-level and denies. assignableLevels(permission) lists the levels a subject may hand out, the catalog lists levels per permission, permdock diff reports level-removed as breaking, and a snapshot binds every principal attribute it does not carry, such as teamIds, so fromSnapshot agrees with decide.

  • resumeDecision takes a ttl in milliseconds for the request it opens, and a request opened by requestApproval or any adapter without one now stays open for the store's ttl (memoryApprovalStore({ ttl }), or the new optional ApprovalStore.ttl) instead of always one hour. A grant's approval.ttl still caps the window, and a ttl that is not a positive whole number of milliseconds throws a RangeError.

  • rls generate --custom-roles in database mode now works with membership sources (fromTable, fromJunction, rls.membershipSources or supabase.hook.memberships) instead of failing with --custom-roles in database mode needs rls.memberships.scopes.<scope>. Each permitted_<scope>_ids unions a custom-role branch over the source rows, matching custom_role_permissions and custom_role_includes on the row's first-scope instance as tenant_id and its id as scope_id, through the same permdock_ceiling view as a membership table.

  • rls generate --grants-out and supabase hook generate --grants-out move only what supabase db diff drops into the grants file: schema privileges, function privileges (including the revokes on the hook's version and protection trigger functions, which no file carried before), the revoke on the permdock_ceiling view and its security_invoker option. rls generate --grants-out works with the helpers part, and with the hook part as well the file holds the helpers' statements first and the hook's after them. Table grants, such as the select grants to supabase_auth_admin, and the permdock_auth_admin_read_* policies stay in the hook and helpers parts, because db diff dropped them on the next diff when they lived only in a migration. Regenerate the parts and the grants file; a project that applied an earlier grants file keeps its privileges, and the next db diff finds nothing to change.

  • rls generate --helpers-only combines with --fields views (rls.helpersOnly with rls.fields: 'views'): it writes the field views next to the helpers, so a role at one scope whose grants list fields (a portal contact) reads masked columns through <table>_visible while the row policies stay hand-written. --revoke-columns is still refused with --helpers-only, because the table grants are the application's. Before, --helpers-only refused --fields, and an application with hand-written policies needed security definer functions to return field-safe rows per scope.

  • rls generate --seeds-out takes a migrations directory (a path ending in /, or an existing directory). It compares the rows with the newest migration there that starts with -- permdock:seeds v1 and writes a new <version>_permdock_seeds.sql only when they changed, so declarative-schema projects no longer rewrite an applied seeds migration in place. --check fails while that newest seeds migration is missing or out of date.

  • rls generate --shims wrappers answer permissions whose grants are split by condition. A wrapper used to pass the permission key to the helpers, which take grant keys, so a permission seeded as quote.read#1 and quote.read#2 answered nothing. Each wrapper now carries, per helper scope, the grant keys of every permission: it answers from the keys of the unconditional allows, minus the instances where the caller holds a deny key. A conditional allow answers nothing through a wrapper, since a wrapper cannot apply a row condition; before, a permission whose grants all shared one condition answered as if they had none. The wrappers are now security definer (still search_path = '', reading no table), so a caller needs execute on the wrapper only, not usage on the helper schema.

  • rls generate adds helpers that take a permission key instead of a grant key: permdock_has_permission(p_permission), one permitted_<scope>_ids_by_permission(p_permission) per scope, and grant_keys(p_permission, p_scope, p_effect), plus _for(p_user, p_permission) forms in database mode. They answer with the permission's unconditional allows minus any deny and map a former key to the current one, so hand-written SQL no longer spells positional #n grant keys, which change when a role's grants change. A permission whose allows on a scope all carry a condition answers nothing there. rls generate --shims no longer answers from a break-glass grant key, which only the break-glass read may use.

  • rls generate in database mode adds permdock_has_for(p_user, p_grant) and one permitted_<scope>_ids_for(p_user, p_grant) per scope: the answers of permdock_has and permitted_<scope>_ids for a user the caller names, from the same tables with the same expiry and suspension filters and no active-tenant narrowing. They are for trusted SQL that acts for a stored user, such as a job, a trigger or an approval decided later, which otherwise had to rewrite request.jwt.claims to ask the helpers. execute is revoked from public, anon and authenticated; grant it to a backend role yourself when it calls them directly.

  • rls generate no longer invents a table for a resource that rls.tables does not name. With rls.tables set and --helpers-only, such a resource gets no index in the indexes part or the index suggestion warnings, and rls verify --introspect no longer warns about a missing index on it; generate names the skipped resources once. Without --helpers-only, its policies still target public.<resource>, and generate now warns that they do. A config without rls.tables keeps mapping every resource to the table of the same name.

  • rls verify --format pgtap now asserts each fixture against the database instead of writing select ok(true, ...). The script creates pg_temp.permdock_decision(statement text), seeds the fixture custom roles, and for each fixture binds the subject with the same settings as --db, runs the statement --db runs and checks is(..., 'granted' | 'denied', ...) against the outcome can() gave; an opaque grant becomes skip(). A fixture that disagrees with its expected or names an unknown permission now exits 1 without a script. rls verify --db keeps custom-role rows that already exist (on conflict do nothing) instead of failing on them, and its success line reads in-process and against the database when it checked the database.

  • rls verify --tree also seeds distinct values in a column that a unique expression index reads, such as name in (drive_id, coalesce(parent_id, ''), lower(name)). The unique columns came only from the index's plain key columns, so every sibling row got the same placeholder and the seed failed on such an index.

  • rls verify --tree seeds a different value in each row for a required column that a unique index covers. Sibling rows got the same placeholder before, so a table with a unique index such as (drive_id, parent_id, name), or a unique uuid column, refused the seed and the command could not run. Text gets a numbered placeholder, a uuid a new value, a number counts up from its check's bound and a date moves a day per row; a column whose enum or equality check allows one value keeps it.

  • rls.anonExecute: true grants anon usage on the helper schema and execute on every generated helper, for hand-written policies that apply to public or anon and call them. Without it only field views that anon reads get that grant, and a statement anon runs that reaches a helper fails, or as an InitPlan has crashed the backend on some Postgres builds. The docs now say that a policy calling a helper should say to authenticated.

  • rls.apiKeys holds a user key whose claim names a tenant to that tenant. The generated permitted_<scope>_ids and member_<scope>_ids keep only the key's tenant and the instances inside it, whatever rls.tenants says, so with rls.tenants: 'all' a user in two tenants whose key names one reads only that one. A scope memberOf check compares its tenant column with the key's tenant, a scope outside the first scope's chain admits nothing for such a key, and permdock_has returns false for it, since a global role reaches every tenant. A key without a tenant, a tenant service key and the _for helpers are unchanged.

  • rls.apiKeys lets the database narrow an API key request the way subjectFromApiKey does in process. A backend that verified a key passes it in a claim (api_key by default, with scopes, tenant and roles), and permdock rls generate writes permdock_api_key_allows(p_grant): permdock_has and every permitted_<scope>_ids return nothing for a permission the key's scopes do not list, and allows that call no helper get the same check in their policy. Denies always apply, and a request without the claim is unchanged. A key with a tenant and an empty or missing sub is a service principal of that tenant: permitted_<first scope>_ids and member_<first scope>_ids answer for it with the claim's roles, or rls.apiKeys.serviceRoles when the claim lists none. claim, scopes, tenant and roles rename the claim and its fields. The Supabase manifest lists the settings as rls.apiKeys and the helper in rls.helpers. Off by default; nothing changes without it.

  • rls.approvals: true makes rls generate add an approval store to the helpers: an approval_requests table and one security definer function per ApprovalStore method (permdock_approval_open, _get, _resolve, _consume, _list, _expire, _cancel), each one atomic statement and closed to client roles. supabaseApprovalStore(client, { schema?, ttl?, onOpen? }) in permdock/supabase implements ApprovalStore over them through supabase-js and passes testApprovalStore; onOpen runs after a request is stored, for notifications and superseding older requests. APPROVAL_POLICY_UNAVAILABLE is exported for the detail of a denial caused by a failing ApprovalPolicySource.

  • rls.approvals accepts { table, token?, body?, mirror? } to put the generated approval store on a table the app already has. rls generate adds the token and body columns when missing, indexes them and writes the same permdock_approval_* functions, so supabaseApprovalStore works unchanged. The functions read every field from the body, skip the app's rows that have none, and copy the fields mirror names into the app's columns on each write, converted to their types. The table's grants and policies stay the app's.

  • rls.approvals on an adopted table takes open: 'attach' and schema. With open: 'attach', permdock_approval_open attaches the request to the row the app inserted with the decision's token, so tables with required columns only the app can fill work with supabaseApprovalStore; with no such row it raises P0002 and the open fails. schema writes the store functions into another schema, such as public when the helper schema is not exposed, so supabaseApprovalStore(client, { schema }) reaches them without wrapper functions.

  • rls.assignments adds assignment triggers in database mode: each scope's rls.memberships table, and each table in rls.assignments.tables such as invitations, gets a before insert or update or delete trigger that refuses a client role's write of a role the caller may not assign at that instance (SQLSTATE 42501, hint not-assignable-by). Declared roles follow the assigns graph through permdock_can_assign; custom roles go through the new permdock_can_assign_custom_role, which runs the custom-role write checks on the stored definition. Writes that do not run as a client role (the owner, a security definer function, a backend role) are trusted, which covers bootstrap paths without a bypass setting.

  • rls.assignments now guards the global-roles table. When rls.roles (else supabase.hook.roles) names the application's table, rls generate puts a permdock_assignment trigger on it too: a client role may insert, change or delete only the global roles it may assign, checked like a tenant assignment at no instance through permdock_can_assign(role, null) and, for a platform custom role, permdock_can_assign_custom_role(null, 'global', null, role). A client that holds no global role whose assigns lists the role can no longer give itself or anyone else a global role through the Data API. The generated user_roles table is unchanged, since no client role may write it. The hook manifest lists the table in rls.assignments.tables.

    rls.assignments.ownRole: 'refuse' refuses a client write to a row whose user is the caller, on every guarded table with a user column, whatever the role; a map such as { 'public.user_roles': 'refuse' } limits it to the tables it names. A listed table names its user column with the new user field. The refusal raises SQLSTATE 42501 with hint self-demotion for the old row and not-assignable-by for the new one. permdock doctor PD064 warns on a table rls.assignments guards that no migration or rls.out file creates the permdock_assignment trigger on.

    An application that sets both rls.assignments and rls.roles and lets clients write its global-roles table directly gets those writes checked after regenerating; move such writes into a security definer function or a backend role, which stay trusted.

  • rls.customRoleWrites.requires makes the generated custom-role write functions check that the caller may manage roles before any hand-out check: it names permission keys, or 'manageRoles' for every permission with meta.manageRoles. A caller holding none of them in the tenant, or through a global role, gets 42501 with hint manage-roles from permdock_replace_custom_role_grants, permdock_rename_custom_role_grants and permdock_delete_custom_role_grants.

  • rls.customRoleWrites.roles names the application's own table of custom roles (table, key, tenant, and optionally scope, id and a skip column for declared or system rows). rls generate then adds permdock_cascade_custom_role() and an after update or delete trigger on that table: a rename or a move to another tenant, scope or instance carries the role's grants and includes, and a delete removes them. A signed-in caller passes the same checks as the write functions where the role was and where it lands; migrations, jobs and nested triggers move the rows directly.

  • rls.jsonSchema: true | 'auto' adds a pg_jsonschema check constraint that each approval store body matches the new schemas/approval-request-v1.json. supabase.hook.validate: true makes the token hook check its claims against supabase-claims-v1.json and drop them all on a mismatch instead of failing sign-in.

  • rls.ownershipTriggers: false leaves out the holder-count and transfer-only triggers that min, max and transferOnly put on the membership tables, and rls.ownershipTriggers: { <scope>: false } leaves them out for the named scopes, for an application that enforces those counts its own way. decideRoleChange keeps checking the rules and permdock_can_assign is still written.

  • rls.readOnlyActors adds a restrictive policy per table and write command that refuses writes from support and impersonation sessions (the token's act claim) unless act.read_only is false, replacing the hand-written policy the support access guide showed.

  • rls.realtime and rls.storage (and supabaseRls({ realtime, storage })) make permdock rls generate write policies on realtime.messages for private channels and on storage.objects for buckets. A topic segment or a folder of the object name selects the scope instance, and the policies call permitted_<scope>_ids_by_permission('<key>'), so they count only the allows without a row condition, minus any deny, and a permission split into #n grant keys still matches. For a permission that also has a relationship grant or a where, rls generate warns that its topic or folder admits only the instances its role allows reach. Doctor check PD037, rls verify --db and rls verify --introspect no longer flag a policy that calls the permission-key forms, which never grant more than the application does, and the PD037 fix names them.

  • rls.rowHelpers: true (or a list of resources) makes permdock rls generate write permitted_<resource>_rows(p_permission): the ids of the resource's rows the caller may act on with one permission, from the same allows and denies the generated policies use, so closure walks, link hops, the restricted stop, includes, groups and requires apply without being restated in hand-written policies and functions. Actions with no SQL command get an arm too. permitted_<resource>_rows_for(p_user, p_permission, p_claims) answers for a stored user and is executable by no client role; the neon dialect gets no _for form. It works with rls.helpersOnly.

    permitted_<resource>_row(p_row, p_permission) decides one row value from its columns instead of looking it up by id. A table's own select policy written as using (permdock.permitted_doc_row(doc, 'doc.read')) admits a row the same statement inserts, so insert ... returning passes row-level security.

  • rls.tables accepts schema-qualified names such as integrations.webhook_endpoints end to end. permdock rls verify (with --db and in the pgTAP output), its field view reads and rlsParity from permdock/testing now quote schema.table as a schema and a table instead of refusing it as an unsafe identifier, so a table a package installs in its own schema can be verified like one in public. With --target drizzle or --target prisma, the default export or model name of a schema-qualified table is the table name without its schema; rls.drizzle.exports and rls.prisma.models still override it. Nothing changes for unqualified names.

  • rls.tenants: 'all' stops the generated RLS helpers from narrowing to the tenant claim: permitted_<scope>_ids, the root memberOf (now in (select member_<scope>_ids())) and the nested membership exists admit every tenant the subject is a member of, for apps whose tenant comes from the URL. The default, 'active', keeps the current behaviour: narrow to the tenant claim when the token carries one.

  • rls.trustedReaders accepts service_role. rls generate threw "generated RLS must never emit service_role" when the list named it, although service_role is the trusted server role in a Supabase app. The execute grant on permdock_trusted_role_permissions may now name it, and the generator still refuses service_role on every other line.

  • securityFor(permission) and permissionsExtension(permissions) in permdock/openapi return an operation's security and x-permdock-permissions from permission references alone, so a contract package can write them without importing the policy. protect from permdock/orpc now attaches to contract procedures that declare an output.

  • subjectFromSupabase and actorOf in permdock/supabase read a support act level without read_only as read-only (Actor.readOnly: true), as better-supabase does. Before, such a token could reach every delegated write.

  • subjectFromSupabase and delegationOf leave the OpenID Connect identity scopes (openid, profile, email, address, phone, offline_access) out of delegation.scopes. A Supabase OAuth server token that carries only those scopes is now an oauth-client actor with no delegation: it is still denied every check with no-delegation by default, and a policy delegations entry that names the client now lets it act within that ceiling. Before, the identity scopes counted as a token delegation that covered nothing, so the client was denied with not-delegated even when the policy delegated to it.

  • supabase.hook.before names schema-qualified functions (event jsonb) returns jsonb that the generated custom_access_token_hook calls first, in order. A result with an error key is returned as the hook's answer, so Supabase Auth refuses the token, and a null or non-object result is refused with status 500; otherwise the hook continues with the event the function returned and writes its own claims as before. The migration grants supabase_auth_admin usage on each function's schema and execute on the function, and the Supabase manifest lists them as hook.before. Use it for checks such as single sign-on enforcement that must run inside the one hook Supabase allows. Nothing changes without it.

  • supabaseApprovalStore's onOpen now runs once per stored request. A repeated ask whose token finds the request still open keeps the stored request, as before, but no longer runs onOpen again, so an app that notifies approvers or opens its own record there does not need to make it idempotent. An open whose stored request cannot be read back runs no onOpen either.

  • usePermission, usePermissions and useApproval from permdock/react and permdock/react-native now update after a snapshot change when the React Compiler compiles them. Before, a compiled hook kept its first answer, such as pending.

  • vouchApproval(store, token, { status, by, rule, note? }) in permdock/approvals records a verdict that the application's own approval rules decided, such as a manager chain, delegates or a quorum it counts itself. The store resolves the request at once and records vouched: rule on the request and on the approval, so the audit trail says which rule decided. The eligibility checks of approvers, the tenant membership and the quorum are skipped; the actor, the principal (unless distinct: false), a repeated approver, an unauthenticated subject and a closed or expired request are still refused. ApprovalVerdict.vouched is server-side input that approvalsHandler never reads from a body. applyApprovalVerdict handles it, so the memory store, the generated Supabase store and custom stores built on it support it, and testApprovalStore checks it. The approval request wire format (v: 1) gains the optional vouched field.

0.1.0 (2026-10-04)

  • A 401 to a request without an Authorization header now carries a bare Bearer challenge instead of error="invalid_token" (RFC 6750 section 3.1), and a rejected token gets a fixed error_description. The /unauthenticated body no longer carries denials or alternatives, so nothing explains the failure to an unauthenticated caller. problemFromDecision takes a credentials option for the challenge.

  • A deny that cannot be evaluated now denies the decision, as a relation deny already did: a deny closure that throws or returns a thenable (closure-error) and a deny with an opaque condition (opaque-condition). An opaque node nested under not, or or and now fails its grant with opaque-condition on the server and in fromSnapshot, so not(opaque) no longer matches. coveredByDelegation treats an RFC 9396 or GNAP entry whose identifier is not a string or whose actions is not an array as covering nothing, instead of ignoring the field.

  • A dotted condition field, relation or identifier with a __proto__, constructor or prototype segment (a.__proto__) is now rejected at definition time like the bare key, as the threat model states. permdock/testing renames the ClientStoreDock type to ClientStorePermDock.

  • A grant whose roles the subject holds but whose plan grantee it lacks now denies with the new reason not-entitled instead of no-grant, with the grantee in to; a plan grant whose roles are not held adds no denial. When every denial is not-entitled, HTTP adapters answer 403 /not-entitled with plans (the plan keys that would grant), describe(decision) returns kind: 'upgrade' with plans, and the new requiredPlans(decision) returns the list. Snapshots carry these grants as notEntitled entries, which grant nothing, so usePermission in every UI adapter gets the same reason and plans as decide on the server.

  • API keys end in a 6-character base62 CRC-32 checksum: pdk_<id>_<secret><checksum>. generateApiKey appends it and parseApiKey rejects a key whose checksum does not match, so secret scanners can match pdk_[A-Za-z0-9_-]{1,128}_[A-Za-z0-9]{49} and discard lookalikes offline; keys generated before this change no longer parse. CredentialVerifier gains an optional touch(id, at) that subjectFromApiKey calls, without awaiting, after a key resolves, for a lastUsedAt column; apiKeyVerifier({ find, touch }) passes it through, memoryCredentials() records it (lastUsedAt(id)), and testCredentialVerifier checks that a touch never changes what a key verifies to.

  • Add toCsvRow(event) and CSV_COLUMNS: the pinned CSV export row for decision and approval events, shared by the Cloud export and self-hosted sinks.

  • Approval grants take quorum, ttl and escalation. quorum (default 1) is the number of distinct approvers a request needs: ApprovalStore.resolve appends each approval to the request's new approvals: { by, at }[], keeps the request pending until the quorum is met, and refuses a repeat approver with the new approver-repeated code (409 from approvalsHandler); one rejection ends the request. ttl (a duration such as '30m') caps the store's window when requestApproval sets expiresAt. escalation: { after, to } lets to approve once after has passed since createdAt. The request's approvers carries quorum and escalation; applyApprovalVerdict, approvalQuorum and escalationOpenAt are exported from permdock/approvals for custom stores, and the Drizzle recipe uses them. definePolicy rejects a quorum below 1, a malformed duration or a relation in escalation.to; hosted grants with a smaller quorum or a longer ttl than a code allow are dropped as weaker-approval. The catalog approval carries the three fields, so permdock diff reports a change to any of them.

  • Approval tokens bind the call's data when there is no row id: for a collection action, or a row without its id field, Decision.token includes a SHA-256 of the canonical JSON of the validated data, so an approved call no longer covers a retry with different arguments. approval.by refuses relation() at definePolicy, and an approval store refuses every approver for a stored request whose approvers include a relation. permdock/terminal prompts y/N only for a grant with approval: { distinct: false } and no actor; any other approval is recorded in the store and the command exits 75 until someone else approves it, or exits 77 when no store is configured. storedApprovalToken moved from the agent kernel to the approvals helpers.

  • Approvals can go stale when the row changes. resource(schema, { version: 'updatedAt' }) names the row field that changes with the row, and approval: { staleOn: 'resource-change' } hashes its value into the decision token (the prefix stays pd1.; tokens of grants without staleOn are unchanged). Resuming with a token that approved an earlier version of the same row denies with the new reason stale-approval and leaves the old record unconsumed; the next call without it is a new approval-required. definePolicy throws for staleOn on a collection action or on a resource without version. Approval requests carry approvers.staleOn, the catalog carries resource version and staleOn in approvals, and a hosted approval without staleOn is weaker-approval against a code allow that sets it.

  • Approvals refuse the requester by default. approval: 'human' and approval: { by } now refuse the request's principal as approver as well as its actor (approver-is-principal), in assertApprover, ApprovalStore.resolve, resolveApproval and approvalsHandler. A grant that wants the user to confirm their own agent's call opts out with approval: { distinct: false }, and permdock doctor PD024 (--only self-approval) warns on each opt-out. requireDistinctApprover stays as a handler-wide floor that refuses the principal even on an opted-out grant. A hosted grant that sets distinct: false on a permission a code allow guards with an approval is dropped as weaker-approval. testApprovalStore checks that a custom store refuses the principal on every approval shape and accepts it only with distinct: false.

    Behaviour change: an approval request without approvers.distinct now refuses its principal. Set distinct: false on the grant to keep the old behaviour.

  • Approvals take mode: 'all' | 'sequential' with stages: [{ by, quorum? }], user(id) approvers, and relation() approvers checked at verdict time through approvalsHandler's new relations option (approverRelations in permdock/approvals). The approval request carries mode and stages, each signature its stage, and the verdict relations. createPermDock takes approvalPolicies, an ApprovalPolicySource (memoryApprovalPolicies) whose entries add approval stages to matching allows and deny with approval-policy-unavailable when they fail to load; testApprovalPolicySource checks a source.

  • Arazzo simulate and permdock arazzo check follow the 1.0 and 1.1 specs more closely:

    • Documents are validated against the required fields.
    • Patch versions are accepted.
    • $sourceDescriptions forms of operationId and operationPath select their source.
    • $inputs.* and $components.parameters references resolve.
    • Nested workflows take their calling step's parameters as inputs.
    • Results are kept per step position, so equal stepIds in nested workflows no longer collide.

    simulate now emits the one simulate audit event its docs describe, with the batch's worst decision and counts.

  • Assign authority now comes from live, held memberships. Expired memberships no longer count toward tenants(), the active tenant, assignableRoles() or a credential's ceiling. decideRoleChange reads a nested change's tenant from the subject's membership and denies a disagreeing within with no-membership; pass { trusted: true } when the application loaded within from its own store. activate copies within from the eligible membership, and the approval token covers it.

  • Bound the work one request can cause. can and whoCan walk each nested group once per depth budget, so a lattice of shared teams costs time in its group count rather than its path count. createEvaluationsHandler (and permdockHandler) answers a batch over maxEvaluations (256 by default, the AuthZEN limit) with a 413 Problem Details before resolving the subject. A denial's alternatives peek a quota grant's remaining without spending it, and can skips alternatives entirely.

  • CLI fixes found by the coverage suite:

    • The generated barrel imports the permissions module relative to its own folder; before, the default src/permissions.generated.ts imported ./src/permissions.js, which does not resolve.
    • collect, usage and doctor skip a broken symlink in a source folder instead of crashing.
    • permdock usage exits 2 with "policy export is not a Policy" when the policy module exports something else, instead of throwing (which crashed doctor at PD003).
    • A computed identifier key such as defineRoles({ [name]: … }) no longer counts as a role named name, which made usage report a false "granted by no role".
    • PD009 names the duplicate permdock package folders, not their parent node_modules.
  • Catalog permissions carry rowConditions when permdock.config.ts names a policy: true when a code grant for the key has a where, check, closure, field list, purpose, break-glass override, or a relation, plan, actor or assurance grantee, which the SQL helpers (permdock_has, permitted_<scope>_ids) do not check. permdock doctor PD037 errors on a migration's storage.objects or realtime.messages policy that calls a helper for such a key, and permdock rls verify --db reports the same from pg_policies, so a Storage or Realtime policy written outside PermDock (better-supabase permdock mode) cannot grant more than the application does.

  • Catalog v1 carries a top-level fingerprint and per-permission approvals. catalogFingerprint(catalog) is exported from permdock: base64url SHA-256 over canonical JSON without generatedAt, generator, fingerprint and usages, so the CLI version and call sites no longer change it. permdock collect --check ignores generator.

  • Composed membership sources and permdock supabase hook generate. memberships on every createPermDock accepts an array of sources, merged and de-duplicated by composeMemberships, and MembershipSource gains optional list({ scope, id }) (a scope's members), version(principal) and claimsFirst. claimsFirst(sources, { version }) keeps the verified token's memberships and reads the sources only when the token was truncated. Permissions listed in definePolicy({ fresh }) deny with the new reason stale-credentials when token memberships are behind the source version; a stale subject's snapshot drops those allows.

    permdock/supabase adds the SQL sources fromTable and fromJunction (fixed roles, via, within, expiry, managedBy, seats, suspension), authzVersion, supabaseMembershipsBudget, and subjectFromSupabase reads memberships_truncated and authz_ver into principal.membershipsTruncated and principal.authzVersion. permdock supabase hook generate compiles the same sources into custom_access_token_hook: user_role and roles, a union over every source with the active scope first (--active-from), an attrs claim from allow-listed server-owned columns and app_metadata.<key> entries for attribute conditions (user_metadata is refused, and the migration refuses to install while clients can write a listed column), a byte budget over attrs and memberships (--budget, default 1024) with memberships_truncated, authz_ver with its version table and triggers, a trigger that refuses client writes to IdP-owned rows, the supabase_auth_admin grants and revokes, and the printed config.toml block with jwt_expiry = 900.

    Memberships carry managedBy: 'idp' (set by directoryMembershipSource; decideRoleChange refuses with the new reason externally-managed, isExternallyManaged tells a UI) and entitlements (seats that plan() grantees match inside the active tenant). EntitlementSource (entitlements option, memoryEntitlementSource, testEntitlementSource) adds plan names for the active tenant, and fromStripeEntitlements reads Stripe Entitlements through a structural client. permdock doctor PD028 (--only attrs) flags attrs entries a user could set.

  • Custom roles in generated RLS. permdock rls generate --custom-roles (or rls.customRoles: true) makes permitted_tenant_ids and permitted_team_ids resolve tenant-defined custom roles as well: from the new custom_role_permissions and custom_role_includes tables in database mode, or from a compact memberships[].grants claim in jwt mode (off unless the flag is set). Both go through permdock_custom_keys and a generated permdock_ceiling view of the assignable declared roles, so a row or claim entry outside the ceiling never widens access, and the database agrees with resolveCustomRole. authorizeSql({ customRoles: { declared } }) answers tenant requests from custom roles, and --rbac supabase --custom-roles passes it through.

    customRoleClaim(roles) builds the claim map for a token hook. rls verify fixture files accept customRoles (resolved in-process, sent in the claim, and seeded into the tables in database mode), and rlsParity accepts customRoles. An included role's denies now apply to a custom role only in the custom role's own scope, matching what RLS can evaluate. Output without --custom-roles is unchanged.

  • Custom roles with scope: 'global' are platform roles held through principal.roles, capped by the allows of assignable global roles and returned by the new RoleSource.globalRoles(). With rls generate --custom-roles they are rows with a null tenant_id, or the top-level role_grants claim in jwt mode, and permdock_has answers from them. The --rbac supabase scaffold types user_roles.role as text when --custom-roles is on.

  • Decision data is untrusted unless the caller marks it. With validate: 'boundary', decide, can, filter, protect, conn.check and the Nest guard validate a row against the resource schema unless the call passes trusted: true; pass it for rows your server loaded itself. The agent kernel labels tool data boundary: 'tool-args'. The AuthZEN handler forwards the loader's trusted flag and answers decision: false (no-grant, detail: 'resource-unavailable') when the resource loader throws, instead of evaluating the request's { id } stub.

  • Denials that leave the process are now WireDenial ({ role, reason, to?, detail? }), and DecisionEvent.denials and ProblemDetails.denials are typed that way. This covers decision events, Problem Details bodies, MCP and WebMCP refusals and the decision endpoint. detail is kept only as a JSON value: what a closure threw (closure-error), a validation error and any Error or non-JSON detail are dropped, so they no longer reach a sink or a response body. WireDenial and WireDecision are exported from permdock.

  • Doctor PD028 now also warns when the migrations let anon or authenticated insert or update a column that decides a membership: the user, scope, id, within, role, via and expiry columns of every fromTable / fromJunction source. A contact who can edit their own row could otherwise set user_id or customer_id and give themselves a membership. The grant model matches Postgres, where a column-level revoke leaves a table-level grant in place. MembershipSql gains columns, the list the check reads.

  • Document that scope and tenant ids compare as exact text in decide and the SQL helpers, never lower-cased, and that producers emit uuid::text; the policy matrix and an RLS parity suite over a uuid column cover it.

  • Document the PermDock Cloud contract v1: client, admin and export key kinds, the authoring routes (/hosted-grants, /directory/*, /connectors, /export), the environment's JWKS and OIDC Discovery, RFC 8693 token exchange at <env URL>/oauth/token minting RFC 9068 access tokens, the raw sink body the Cloud envelopes as CloudEvents, and membership events with source: 'cloud'.

  • Drizzle toWhere returns SQL, and columns accepts only columns of the given table. permdock/prisma adds prismaModelFields, which reads required and list fields from schema.prisma or a DMMF datamodel for the new model option, and toPredicate, which compiles a condition to a Prisma 8 field-proxy predicate.

  • Edge fixes found by the coverage suite:

    • SCIM list paging starts at the first item for a negative or non-finite cursor; before, it sliced from the end and returned a negative startIndex.
    • An SSF back-channel logout token with an empty sub and a sid maps to an opaque session subject, not an iss_sub subject with an empty sub.
    • PermDockCloudEvent includes dev.permdock.credential, which verifyWebhook already accepted.
  • Elevated access: just-in-time role activation, break-glass and support access with tenant consent. Memberships gain grantedBy (the principal id that wrote the row) and reason (free text); both round-trip through the Supabase custom access token hook claim, the snapshot and every event. Membership also gains eligible (roles a holder may activate but does not hold) and member: { group } (a subgroup inside the instance).

    Role activation (E1). role('admin', ADMIN, { on: 'organization', activation: { maxDuration: '4h', justification: 'required', approval: { by: roles.owner }, assurance: { maxAge: 300 } } }) makes a role eligible-only: it is never held directly, a membership lists it under eligible, and permdock.activate({ role, scope, id, duration, reason }) returns a Decision. It is approval-required when activation.approval is set; once granted it carries the membership to write under elevation (via: 'elevated', with expiresAt, grantedBy and reason). PermDock never writes the membership; the app does. Expiry uses the existing expiresAt check plus C3's revocation counter, and the Supabase hook already includes elevated rows. permdock doctor PD033 warns on an activation without maxDuration and on an activation role held standing in the memberships fixture.

    Break-glass (E2). breakGlass(permissions.patient.read, { overrides: ['restricted-record'], requires: { purpose: ['BTG','ETREAT'], reason: true, assurance: { maxAge: 60 } }, maxDuration: '1h', obligations: ['notify','review'] }) is the only deny override: it overrides deny grants whose name it lists and nothing else, so deny-overrides-allow holds everywhere else. A satisfied break-glass grant is granted with matched.breakGlass: true and obligations: [{ kind: 'notify' }, { kind: 'review' }, { kind: 'justify', reason }]; a break-glass grant is engaged by context.purpose, and a missing purpose, reason or assurance denies with the new reasons purpose, reason-required or insufficient-user-authentication. DecisionEvent records purpose and reason, and toOcsf raises break-glass to high severity. RLS never compiles a break-glass grant (it stays non-portable); instead permdock rls generate emits a permdock_break_glass_audit table and one security definer permdock_break_glass_<resource>(permission) per targeted resource that checks a signed break-glass session, writes an audit row and returns the rows (bypassing RLS as the definer), so a plain RLS read keeps denying the restricted rows. permdock doctor PD034 flags a break-glass grant under an rls config.

    Support access (E3). supportAccess({ role: 'support', actorRequired: true, consent: { by: roles.owner, durations: ['1d','7d','30d'] }, forbid: [permissions.billing, permissions.security] }) is a role a vendor holds only through a consented, time-bound via: 'support' membership. A tenant owner consents through an ApprovalRequest whose membership is { scope, id, via: 'support', roles: ['support'], member: { group: 'vendor-support' }, expiresAt, grantedBy }; group members ride C3's fromJunction group option. With actorRequired: true, every decision under a support membership denies with the new reason actor-required unless the subject carries an act. forbid compiles to deny grants scoped to via: 'support'. The lifecycle emits the new access.started, access.ended and access.revoked events (accessEvent), each a CloudEvents type (dev.permdock.access.started / .ended / .revoked) with an OCSF Account Change mapping (accessToOcsf); revoking consent bumps C3's revocation counter. permdock doctor PD035 warns on a support role without actorRequired.

    Purpose of use. context.purpose is a decision input: allow(p, { purpose: ['treatment'] }) applies only when the caller asserts a matching purpose. It is not portable; RLS honours it only through the break-glass session function.

    The catalog gains role activation and supportAccess entries and break-glass entries per permission.

  • Every DecideOptions field has JSDoc, so editors describe trusted, boundary, now, source, adapter, onDenied and field on hover. The same text is the options table on the decisions page.

  • Export coveredByDelegation(permission, delegation, resourceId?, hasActor?) from permdock: the delegation coverage check decide uses for OAuth scopes, RFC 9396 authorizationDetails and GNAP access. It returns undefined when covered, otherwise no-delegation or not-delegated, and permission needs only scope, resource and action, so an external PDP such as the PermDock Cloud AuthZEN endpoint can reuse it.

  • Fail-closed fixes for a grantee kind this build does not know, as a forged snapshot or a newer policy document could carry:

    • An allow naming one matched every caller, anonymous included; it now matches nobody, on the server, in fromSnapshot and for approvers.
    • A deny naming one now applies to everyone.
    • A hosted grant naming one is dropped as unknown-grantee, and an approver by naming one is invalid.
    • whoCan reports complete: false when it meets one.
    • toOcsf maps an outcome this build does not know to status_id 0 Unknown instead of leaving status_id and status undefined.
  • Fail-closed fixes found by the coverage suite:

    • subjectFromJwt with actor: { kind } and no from now reads the RFC 8693 act claim. Before, a delegated token under that config resolved as the user acting directly, with no actor and so no delegation check.
    • A custom verifier.verify that throws in subjectFromJwt, subjectFromCapability or subjectFromCiOidc resolves the anonymous subject with cause malformed instead of rejecting.
    • permdock/pdp: a provider.decide that throws or rejects is denied with pdp-unavailable, and a permitted callback that throws makes filter and where keep no rows.
  • Field views. permdock rls generate --fields views (rls.fields: 'views') emits, after the policies, one security_invoker view <table>_visible per table whose read grants set fields: a column some read allow leaves out or some read deny lists is case when <permitted> then col end, where <permitted> is can(permission, row, { field }) built from the row policy's own per-statement helper calls (permitted_<scope>_ids, permdock_has) and row conditions; every other column and the row key pass through. In this mode grant keys also split by field set, and field-only read grants (a deny with fields, an allow with an empty list) leave the row policy and live in the masks, as they never decide the row in can. When anon reads a view, the helpers are executable by anon too; they return nothing without a subject. The resource schema must expose Standard JSON Schema so the view can list its columns.

    --revoke-columns (rls.revokeColumns: true) closes the table: anon and authenticated get select on only the unrestricted columns and the key, and the restricted columns are read through <table>_visible_fields, a security_barrier companion that runs as its owner, masks every column with the full field decision and keeps only rows with a readable column, left-joined on permdock_key. It breaks select * and returning * on the table. Drizzle and Prisma targets put the views and column statements in the migration comment.

    permdock rls verify with rls.fields: 'views' and rlsParity({ fieldViews: true }) compare each read fixture's row in <table>_visible with the columns pick keeps (reported as fields: { app, database }, type RlsFieldsOutcome), and read back only the key so a closed table does not reject them; rls verify statements now always returning "id". permdock rls import reads the generated views back as a fieldViews export of per-column grants, from a dump or with --db. permdock doctor PD030 (--only fields) warns on field-limited columns a table still returns, and PD022 exempts the marked companion.

  • First release of PermDock: typed, portable permissions for apps, APIs and agents, with the permdock core and adapters, the permdock CLI and the permdock/testing runners.

  • Generated RLS keeps parity with decide. contains escapes \, % and _ before LIKE. rls.suspension now also applies to the inline membership exists, the root-scope tenant-claim check and the graph helpers. rls migrate counts only allow seeds when it checks that a key is granted on a scope.

  • Global roles can be read by key through a roles table: supabase.hook.roles.role and the new rls.roles accept { through: 'roles', on: { role_id: 'id' }, column: 'key' }. It ships because CentraKit, like most apps that manage roles in a UI, keeps user_roles (user_id, role_id references roles(id)), and copying keys into user_roles would turn every rename into a data migration. permdock_has, permdock_can_assign and the token hook join through the table, the hook grants supabase_auth_admin a read on it, and permdock_bump_authz_version_role_keys bumps every holder's authz_ver when a key changes. rls.roles defaults to supabase.hook.roles and the hook's roles to rls.roles; with it set, rls generate no longer creates user_roles, and --rbac supabase refuses it.

  • Grants take validFrom and validUntil (RFC 3339 or Unix seconds), normalised to Grant.validity { from?, until? }. Outside the window an allow denies with the new inactive-grant reason and detail: { from, until }; a deny does not apply and explain records it as skipped with why: 'validity'. where() and filter drop inactive grants, snapshot grants carry validity for the client evaluator, permdock rls generate ANDs now() >= to_timestamp(from) and now() < to_timestamp(until) into the access check, and simulate(checks, { now }) previews a batch as of one instant. The window is part of the policy fingerprint, and the catalog marks such a permission rowConditions: true.

  • Graph grants reach the ORMs. toWhere in permdock/drizzle and permdock/kysely compiles a related node to one subquery when given relations: { tables?, closure? }, and resolveRelated in permdock/prisma replaces each node with the ids it reads through a raw query. All three adapters export checkRow, which tells a missing row from a denied one in one query, and withSubject, which runs a transaction with the role and claims the generated RLS policies read.

  • HTTP adapters answer an exhausted limit with 429, Retry-After and the RateLimit / RateLimit-Policy fields of draft-ietf-httpapi-ratelimit-headers-11, and limit-unavailable with 503; tRPC and oRPC map them to TOO_MANY_REQUESTS and SERVICE_UNAVAILABLE. A limit denial now carries detail: { count, window, resetsAt } (LimitDetail). resource() takes disclosure: 'hide', which makes a denied row answer the same 404 /not-found problem a missing row gets; a missing row is now that problem instead of an empty 404. A step-up challenge names acr_values and max_age in WWW-Authenticate and acrValues / maxAge in the body, including the requirements of break-glass grants and role activations, whose denials now carry the assurance grantee in to. permdock doctor PD036 warns on a protect(permission) with no row loader on a route whose path names an id.

  • Link capabilities. A share link is a signed permdock-capability+jwt whose capability claim (v1) holds roles on one resource instance (on), optionally narrowed to permissions, with a required expiresAt, an optional redeemer ('anyone', 'signed-in', { user }, { scope, id }) and optional one-time use. signCapability(input, signer, { audience }) issues one from permission and resource references, and capabilitySubject and parseCapability build the subject and validate the claim. subjectFromCapability(token, { issuer, audience, revoked?, replay?, viewer? }) in permdock/jwt verifies it and returns a kind: 'link' principal whose only membership is { on, roles, via: 'link' }, with delegation.scopes from permissions; a revoked link id, a replayed one-time jti, a redeemer the viewer does not satisfy or a throwing store resolves to the anonymous subject, reported on on('auth') with the new causes capability-revoked, capability-replayed and redeemer-mismatch. A tenant can tighten links on its resources with a LinkPolicy (maxLifetime, allowed redeemers, required once): subjectFromCapability({ linkPolicy }) refuses a violating link with cause link-policy, signCapability({ linkPolicy }) refuses to sign one, and linkPolicyViolation names the broken rule. subjectFromJwt never accepts the capability typ.

    For RLS, exchangeCapability(subject, { key, alg, kid?, ttl? }) in permdock/supabase exchanges a verified link for a short-lived Supabase access token with role: 'anon' and the capability in a capability claim, and permdock rls generate --capabilities (rls.capabilities) emits permdock_capability_ids(p_resource, p_role, p_permission) and one anon policy per resource-scoped grant; a resource role without a memberships table is then reached by links only instead of failing generation. permdock/testing ships the permdock-capability+jwt fixture.

  • Named scopes (breaking). definePolicy({ scopes }) now declares scopes by name and in order, each { key, within? }: { organization: { key: 'organization_id' }, customer: { key: 'customer_id', within: 'organization' } }. Every scope after the first names an earlier parent, so the scopes form one tree; tenant and team are aliases of the first and second scope, and a policy with { tenant, team } must now declare team: { key, within: 'tenant' }. role(name, grants, { on }) is typed against the declared names, and the activation and restricted role options are reserved.

    Memberships are { scope, id, within?, roles, via?, expiresAt? } (or { on }); the { tenant, team } form is still accepted as input and normalised when the subject is resolved, and snapshots, claims and events always carry the canonical form. There is no implicit cascade between scopes: a role applies only through a membership of its own scope, so a team membership no longer satisfies a tenant role, and subjectFromJwt groups and directoryMembershipSource now yield tenant memberships with via: 'group:<id>' instead of team memberships. definePolicy throws when a scoped instance grant touches a resource without a memberOf relation on the scope's key.

    where(), filter, snapshots (scopes is now an ordered { name, key, within, resources } list), fromSnapshot, mayAccess, memberOf conditions, custom roles (CustomRole.scope and id) and the catalog (scopes[], roles[].on as a scope name) understand named scopes. permdock rls generate emits one permitted_<scope>_ids(p_grant) per declared scope, reads rls.memberships.scopes.<scope> tables with columns in database mode and canonical memberships claim entries in jwt mode, types scope columns through rls.scopeTypes, and keys the custom-role tables by scope and scope_id instead of team_id. authorizeSql({ scope }) names the first scope. permdock doctor PD025 (--only scopes) warns on fixture memberships the scopes would drop. rlsParity({ snapshot: true }) also checks the serialized snapshot, and testMembershipSource({ policy }) checks memberships against the policy's scopes.

  • New runtime entry permdock/catalog: parseCatalog(json) validates a permissions.catalog.json (text or parsed) against schemas/catalog-v1.json and returns a deep-frozen, prototype-safe copy, throwing PermDockValidationError with every issue; rowConditionKeys(catalog) lists the permissions marked rowConditions: true; the Catalog* types move here and stay exported from permdock/cli, with CatalogDocument['version'] now 1. permdock/cli exports catalogPath(config, cwd, out?), the path collect writes to. catalog-v1.json now requires what collect always writes (generatedAt, generator, a permission's meta and usages, a resource's id and schema), types every field the catalog carries, and adds the missing staleOn on approvals; permdock catalog --format schema emits the same document.

  • One name per concept, and the policy vocabulary reaches every handler.

    Adapters that hand out an instance now type it with the policy's roles, plans and permissions (PermDock<V>), as core and permdock/next already did: Hono's c.get('permdock'), Express and Fastify withPermDock handlers, Elysia's derived permdock, the Node, Claude Agent SDK, Eve, OpenAI Agents and terminal permdock() results, the terminal protect context, the Supabase middleware contribution, oRPC middleware context, Convex ctx.permdock and PdpPermDock. permdock/fastify gains withPermDock(handler), which types request.permdock without augmenting FastifyRequest.

    OldNew
    CreatePermDockOptionsPermDockOptions
    CreatePermDockPluginOptionsPermDockPluginOptions
    ServerPermDock.handler, AuthzenPermDock.handlerpermdockHandler
    ExpressPermDock.handler(fn)withPermDock(fn)
    SsfAdapter, SsfOptionsSsfPermDock, SsfPermDockOptions
    SupabaseMiddlewareOptionsSupabaseMiddlewarePermDockOptions
    A2AAgentCard, A2APermDock and the other A2A* typesA2aAgentCard, A2aPermDock, …
    dock, pd, server, factory for the instance in docs and skillspermdock; a cloud() result is permdockCloud
  • One package: the CLI moves into permdock. The permdock binary is the package's bin, @permdock/cli becomes permdock/cli (defineConfig, run), @permdock/cli/unplugin becomes permdock/unplugin, and permdock/next/plugin runs collect itself instead of resolving @permdock/cli. permdock, owned by the scaledockhq npm organisation, is the only package name.

    Runtime entry points (permdock, permdock/next, permdock/jwt, ...) still depend on @standard-schema/spec only: every command is loaded on demand, and tests/bundle fails if a runtime entry reaches a CLI or test-runner package. Dependency cost, measured as npm unpacked size:

    • oxc-parser is the package's one other dependency, because collect, usage, doctor, catalog, cloud push and the build hooks parse source: 1.43 MB, plus @oxc-project/types (0.04 MB), plus one platform binding (1.59 to 2.12 MB; darwin-arm64 1.76 MB, linux-x64-gnu 2.12 MB). That is about 3.1 to 3.6 MB per install.
    • ajv (1.03 MB unpacked) and yaml (0.69 MB) are bundled into the lazily loaded OpenAPI command chunks instead of being installed.
    • pgsql-parser (2.83 MB with libpg-query WASM and pgsql-deparser), pg (0.10 MB) and unplugin (0.08 MB plus its dependencies) are optional peers. permdock rls import, the --db modes and permdock/unplugin print the install line when the peer is missing.

    Generated catalogs record generator: permdock@<version>, and generated barrels say @generated by permdock.

  • OpenAPI output now matches the documented shapes:

    • scheme.deprecated emits deprecated on 3.2 and 3.3 and x-oai-deprecated on 3.1.
    • On 3.1 the device flow sits inside flows as x-oai-deviceAuthorization, with the flow's own URL as x-oai-deviceAuthorizationUrl.
    • A permission granted unconditionally to anyone() gets security: [].
    • Top-level grants now reach x-permdock-conditions and x-permdock-approval.
    • The 3.3 profile scheme carries supportedParametersSchema as a URL and named servers. securityProfileRequirements(scopeSets?) writes one requirement per distinct scope set, each with securityScheme, token_endpoint_auth_methods, grant_types and scopes.
    • permdock openapi emit --target 3.3 writes openapi: 3.3.0 and validates the result against a patch of the 3.2 schema keyed by the draft pin. permdock openapi import accepts 3.3 documents.
  • Overlay actions now target operations with $.paths.*[?@.operationId == '<id>']. The old target had an extra .* and selected nothing. permdock openapi emit --format overlay covers the source document's operations by their own operationId, fails on a covered operation without one, and under --check reports operations whose source already sets security. overlay({ operations }) takes the same list. Actions are sorted, and info.version is a catalog fingerprint. x-permdock-conditions and x-permdock-approval are objects keyed by permission key, as the extension table defines.

  • Permission-level custom roles bounded by a ceiling. CustomRole gains grants ({ permission, effect? }, no conditions) and an optional team; includes is now optional. Every custom role resolves through resolveCustomRole(policy, role): its included roles' grants plus its own allows, minus its own denies, intersected with the code allows of the declared assignable roles in its scope. Grants inherit the declared grant's condition, approval and limit, and keys outside the ceiling are dropped and reported by validateCustomRole and permdock doctor PD023.

    assignablePermissions({ tenant? }) joins assignableRoles: both intersect the ceiling (narrowed by RoleSource.assignable) with what the subject holds, and a held role or granted permission with meta.manageRoles: true lifts the intersection. Snapshots carry resolved custom-role grants and a per-tenant assignable list, read by fromSnapshot and the new useAssignablePermissions hook (Vue, Solid, React Native; assignablePermissions store in Svelte). testRoleSource accepts { policy } to check custom-role grants against the ceiling.

    Behaviour change: an includes entry is now bounded by the ceiling too, so a custom role that included a global or non-assignable role no longer reaches that role's grants, and assignableRoles() now also lists assignable roles whose every allow the subject holds.

  • Provisioned roles and plans are bound to their tenant. directoryMembershipSource looks users up with a structured eq filter, so a principal id is never parsed as filter syntax. Core drops roles the policy declares assignable: false from managedBy: 'idp' memberships, and scimHandler reports unknown roles on PATCH too. subjectFromClerk puts o: plans (as entitlements) and o: features on the session organization's membership instead of the principal.

  • RLS CLI fixes found by the coverage suite:

    • rls import maps = 0 and = false conditions instead of importing them as opaque.
    • rls import treats only auth.jwt() and auth.session() as claim sources; ->> on a column or on (select auth.uid()) is no longer a principal.claim.* reference.
    • The raw rls import fallback reads roles, for and as restrictive from the policy header only, so a with check (...) or a word inside using no longer leaks into them.
    • A malformed supabase.hook.memberships entry gets the "takes fromTable / fromJunction sources" error instead of a TypeError.
    • rls generate refuses any output that names service_role, in every target; before, the guard matched only to, from and ; forms.
  • Relationship graph additions. An edge relation takes match (fixed column values, so one table with a role column serves several relations) and groups (a row may name a group whose members hold the relation, nested at most 16 deep per resource), and any relation may list includes, the relations that imply it. A resource declares links, named to-one references, and relation(resource, name, { through: ['folder', 'team'] }) follows them. A resource role on a self-parented resource reaches the instances below it when a relations source is present. RelationHolder gains a group variant, RelationSource.ancestors takes a link name as through, whoCan reports group shares and implied relations, and RLS compiles all four through the permitted_<resource>_ids helpers and new permdock_link_<resource>_<link> helpers.

  • Relationship graph. A resource's parent may now name the resource itself (nested folders, sub-teams, reporting lines), restricted: '<column>' names a boolean column whose rows are reached only by grants on themselves, and relations take three shapes: a field (owner: 'ownerId'), an edge table ({ edge, object?, subject?, expiresAt? }, where an expired edge does not match) and, on a principal resource, { principal, period?: { startsAt?, expiresAt? } }. relation(resource, name, { through: 'parent', depth }) follows the row's parent chain upward to the relation's resource and then along its self-parent for up to depth hops (default 16, at most 32); definePolicy rejects a through grant that cannot reach its resource or walks a memberOf relation.

    Graph grants compile to the new related condition and are decided through a RelationSource (ancestors, related) passed as createPermDock({ relations }) (every adapter's createPermDock forwards it), with answers cached per instance. memoryRelations(permissions, { rows, edges }) is the in-process source and testRelationSource in permdock/testing the conformance runner. Evaluation stays synchronous: await permdock.loadRelations(permission, rows) loads an async source's answers first. A cycle, or a chain that goes on past depth with no holder within it, denies with the new reason relation-depth; a missing, throwing or unloaded source denies with relation-unavailable, and a graph deny that cannot be evaluated denies the decision. await permdock.whoCan(permission, row) lists holders and how (role, relation, share) from MembershipSource.list and RelationSource.related, never grants, and returns complete: false whenever a grantee cannot be enumerated.

    Snapshots carry no graph: graph grants, and grants on a relation with a period, are portable: false there, so clients resolve them on the server; where() keeps graph grants as related nodes and leaves out only relations with a period (partial: true). permdock rls generate emits <schema>.permdock_closure(resource, ancestor, descendant, depth) kept current by statement triggers on each walked self-parented table (stopping at restricted rows and the depth, refusing cycles), permitted_<resource>_ids(relation) helpers, and compiles each graph grant to one uncorrelated closure subquery. permdock rls verify --tree --db checks parity on a generated tree with restricted branches. Catalog resources carry restricted and the new relation shapes, and a relation grantee on the wire may carry through and depth. permdock doctor PD031 warns on a self-parent or restricted column no graph grant uses, and PD032 errors on a graph resource rls generate cannot name. The saas domain in permdock/testing gains a folder tree with shares (saasRelations, saasFolderScenarios).

  • Repository tooling only: presets for lint, TypeScript, tests, CI, Turborepo and Vercel, plus Portless local dev, editor and MCP configs, agent docs and decision records. The published permdock package is unchanged.

  • Restricted API keys and service accounts. An API key is an opaque pdk_<id>_<secret>; the application stores its SHA-256 hash (hashApiKey) next to a Credential (v1) record and never the key (generateApiKey, parseApiKey). A user credential acts as its owner with the owner's live roles and memberships, narrowed to the key's permissions through delegation (OAuth scopes, and RFC 9396 authorizationDetails with an identifier per resource id), so a demoted owner's keys lose the same rights at once. A service credential is a kind: 'service' principal whose only membership is { tenant, roles, via: 'credential' }.

    decideCredential(permdock, request, { settings, approved? }) decides whether the current subject may create a key: a service key's roles and permissions must be within the creator's assignableRoles and assignablePermissions in its tenant, and links, credentials and delegated creators cannot exceed themselves (new denial reason exceeds-creator). A tenant's credentials settings (maxTtl, kinds, approval, allowNoExpiry) come from the new SettingsSource (memorySettings); a violation is the new denial reason credential-policy, a key without expiry is refused unless the tenant allows it, and approval makes creation approval-required with a token bound to the key and its creator. credentialSubject, credentialDelegation, credentialPolicyViolation and parseCredential are exported.

    subjectFromApiKey(options) in permdock/server returns a SubjectResolver that verifies a key through a CredentialVerifier (apiKeyVerifier({ find }) compares hashes in constant time; memoryCredentials() issues, rotates and revokes), re-checks expiry, revoked(id) and the tenant settings on every use, and loads the live owner; any failure is the anonymous subject with an on('auth') cause (malformed, unknown-credential, invalid-claims, expired, credential-policy, credential-revoked, owner-unavailable). Credential changes and sampled uses are credential sink events (created, used with sample, rotated, revoked) with CloudEvents type dev.permdock.credential, built by credentialEvent, and decision events name the key in subject.credential. permdock/testing adds testCredentialVerifier and testSettingsSource, and permdock doctor PD029 (--only credentials) flags fixture keys that never expire and tenants that allow them.

  • Role ownership and audiences. role(name, grants, options) takes min and max (holders per scope instance), transferOnly (the holder count only moves by transfer), assigns (the roles a holder may assign and revoke), for (the membership kinds, Membership.via, that may hold the role) and a typed meta: RoleMeta with audience. A role held through a membership kind its for does not list, or through a membership without via, grants nothing: it is dropped when the subject is resolved and filtered in the generated RLS helpers.

    permdock.decideRoleChange({ kind: 'assign' | 'revoke' | 'transfer', role, scope, id, within?, target, holders? }) checks one change against the assigns graph, the ceiling, for, exclusiveWith, min, max and transferOnly, with the new denial reasons last-holder, max-holders, transfer-only, not-assignable-by, self-demotion, not-allowed-for-membership and conflicting-role; the snapshot-backed client denies it with unsupported. Once any role declares assigns, assignableRoles() returns exactly the roles the held roles list, and heldRoles() / assignableRoles() are ranked by the graph. heldRoles({ scope, id }) narrows to one scope instance, and permdock.audiences() and snapshot.audiences list the distinct audiences of the roles held in the active tenant.

    permdock rls generate reads a via column on membership tables, adds a deferred constraint trigger per scope table for min and max, statement triggers over transition tables for transferOnly, and permdock_can_assign(p_role, p_scope_id). Catalog roles carry min, max, transferOnly, assigns, for, exclusiveWith and audience. permdock doctor PD026 (--only ownership) warns on a scope whose roles set no min.

  • SCIM discovery follows RFC 7644 section 4: /Schemas and /ResourceTypes return ListResponses, serve one item by id, carry meta, and refuse a filter with 403. Every advertised attribute states multiValued and required. Created users and groups now get a real meta.created time instead of an empty string.

  • Smaller server bundles, bounded network calls and a faster policy lookup.

    OpenTelemetry and Web Bot Auth now reach an app's bundle only when it imports them. The adapter options take the function instead of its options, and applyOtel is gone:

    BeforeAfter
    otel: { logger }otel: (permdock) => withOtel(permdock, { logger }), with withOtel from permdock/otel
    webBotAuth: { verify: true, keys }webBotAuth: (request) => verifyWebBotAuth(request, { verify: true, keys }), with verifyWebBotAuth from the adapter entry or permdock/server

    OtelWrap (from permdock/otel) and WebBotAuthVerifier (from permdock/server) type the two options. A Hono app without either option ships about 1.6 KB gzip less; permdock/next and permdock/mcp about 1.9 and 2.4 KB less.

    Every outbound request now has a default timeout: JWKS and discovery 5 seconds (jwksCache.timeout changes it), Web Bot Auth key directories 5 seconds, Cloud requests 10 seconds, the React provider's endpoint calls 10 seconds, terminal device, token and CI OIDC requests 10 seconds, and SSF polls 30 seconds. An aborted request fails closed like an unreachable endpoint. Concurrent JWKS and discovery fetches for one issuer share a single request.

    definePolicy indexes grants by permission key once, so a check no longer scans every grant.

    The CLI reports a missing optional peer (pg, pgsql-parser) with its install line only when the module is missing; any other load error surfaces as it is.

  • Snapshots bind principal.claims.* refs in grant conditions to the subject's values. The snapshot principal carries no claims, so a client read them as missing: an eq or in on a claim denied what the server granted, and a notIn granted what the server denied. principal.id, tenant and the other fields the snapshot carries stay references.

  • Soft and hard quotas. limit accepts mode: 'hard' | 'soft' (default 'hard', today's behaviour) and alertAt, a fraction of count in (0, 1]. A soft limit grants past its count with the obligation { kind: 'over-limit' }; alertAt adds { kind: 'near-limit' } once usage reaches it. A granted decision under a limit carries quota: { remaining, resetsAt } (resetsAt in Unix seconds), and obligations when one applies. An unreachable store still denies with limit-unavailable in soft mode. describePolicy matrix cells accept obligations (the expected kinds). New exported types: GrantLimit, Quota, Obligation.

  • Subject attributes in RLS. permdock rls generate compiles nested claim refs (principal.claims.attrs.region becomes (select auth.jwt()) -> 'attrs' ->> 'region', with every segment checked against the prototype-key blocklist), casts a claim to the column's type read from the resource's Standard JSON Schema (numeric, boolean, timestamptz, date, uuid; numbers and booleans must be that JSON kind), and compiles in / notIn against an array claim to = any (array(select ...)), evaluated once per statement. A grant that reads context.* is refused with a pointer to the new doctor check PD027 (--only context-refs), or skipped under --skip-closures. rlsParity accepts subject claims and a neon dialect.

  • Supabase SQL security and performance:

    • Helpers. rls generate and supabase hook generate put the security definer helpers, role_permissions, the closure, break-glass and authz_version tables and the hook in a private permdock schema the Data API does not expose. authz_version.user_id is a uuid that references auth.users and cascades on delete.
    • Casts. The helpers, the hook, the ownership triggers, graph SQL and rls verify --tree compare a membership's user and scope columns uncast and cast the parameter instead, so the column's index applies.
    • authorize(). It honours effect = 'deny' and membership expiry, and is executable by authenticated only.
    • Break-glass. The break-glass read keeps the tenant boundary: a grant on a role reads only the rows of the scope instances where the subject holds it, through a <permission>#break-glass grant key.
    • Generated SQL. It enables row level security before the grants and schema-qualifies every table. The hook reads app_metadata from the event instead of auth.users.
    • New rls generate --split parts. seeds writes the role_permissions rows as a versioned migration (--seeds-out). indexes writes the indexes the policies and helpers filter through. rls verify --introspect warns about a column no index starts with.
    • New doctor checks. PD046 to PD053 cover user_metadata in SQL, definer functions executable by anon or public, functions without search_path, public tables without RLS, unwrapped auth.uid(), update policies without with check, and unindexed foreign keys. PD054 flags stale role_permissions seeds. PD028 accounts for Supabase's default privileges and tracks insert and update apart.
    • rls.anonymousSignIns: 'deny' keeps a Supabase anonymous sign-in out of every grant but anyone()'s.
    • exchangeCapability signs ES256 by default.
    • Contracts. Every catalog permission carries rowConditions, which is true when the catalog was built without the policy. The claims schema and supabaseClaims() require a non-empty sub at each act level and accept member: { group }, which subjectFromSupabase keeps.
  • Supabase declarative schemas on pg-delta. With [experimental.pgdelta] enabled = true in supabase/config.toml, permdock rls generate --split without --out writes pg-delta's per-schema, unnumbered layout under declarative_schema_path: permdock/helpers.sql, permdock/indexes.sql, public/policies/permdock.sql and permdock/functions/custom_access_token_hook.sql. permdock supabase hook generate without --out writes the same hook path. pg-delta keeps grants, so the hook keeps its supabase_auth_admin grants. It rejects data in a schema file, so the helpers part now needs the seeds part with --seeds-out. Doctor PD042 and PD043 are off under pg-delta. The token hook casts user_id to uuid once, so supabase db lint reports no implicit casts.

  • Suspension in generated RLS, and a token hook that writes memberships. rls.suspension (also a supabaseRls and authorizeSql option) names a users table and a table per scope, each { table, id, disabledAt?, status?, active? }. permdock_has, every permitted_<scope>_ids and authorize() then drop a suspended user's roles and every membership whose instance or ancestor instance is suspended. The check runs live in database and jwt mode, and a missing status row counts as suspended. generate fails on a suspension entry with an undeclared scope, no disabledAt or status, a status without active, or a nested membership table without the ancestor column it needs.

    In jwt mode, permdock rls generate --rbac supabase now emits a custom_access_token_hook that also writes the canonical memberships claim ({ scope, id, within?, roles, via?, expiresAt? }) from every rls.memberships.scopes table. It leaves out expired and suspended entries, gives a suspended user user_role: [] and memberships: [], and grants supabase_auth_admin read access to the tables it reads. supabaseRls({ memberships }) accepts scopes, and SupabaseMembershipTable accepts columns, like RlsMembershipTable.

  • The OCSF projections now match OCSF 1.3.0. accessToOcsf uses Account Change activity 2 (Enable) for started and 5 (Disable) for ended and revoked, instead of 1 (Create) and 4 (Password Reset), and sets the required type_uid. toOcsf always sets the required user, as { name: 'anonymous' } for an anonymous subject.

  • The SQL helpers have a written contract: permdock_has, permitted_<scope>_ids and permitted_<resource>_ids, what each answers, the grant keys to pass, the role-and-scope-only rule and the exact-text id rule, on the RLS page. schemas/supabase-claims-v1.json in the package is the JSON Schema of the claims the hook writes and the helpers and subjectFromSupabase read. The Supabase page adds a "With better-supabase" split of what each package owns.

  • The SSF receiver looks up onEvent handlers by own key only. An event named after an Object.prototype member, such as toString, now reaches the * handler instead of calling the inherited function.

  • The Supabase hook manifest gains memberships (each source's table and its user, scope, id, role, within, via and expiry columns or fixed values), rls (helper schema, jwt or database mode, tenant claim, scope id types, and each helper's arguments, return type and execute roles), decidingColumns (every schema.table.column a membership or an attrs claim is computed from, the same set PD028 checks) and markers ({ hook: 'v1', grants: 'v1' }), plus $schema. Its JSON Schema ships as schemas/supabase-manifest-v1.json. permdock supabase inspect --out permdock.manifest.json writes it, and --check exits 1 when the file's JSON differs. inspect --out used to override the hook path; the path now always comes from supabase.hook.out. fromTable and fromJunction expose their entry as sql.manifest.

  • The Supabase manifest's rls section has a memberships list of the tables member_<scope>_ids_for reads, in the same shape as the hook's memberships. It names the rls.memberships table mapped for each scope, else the rls.membershipSources that can hold it, else the hook's sources. A reader that resolves memberships outside the hook, such as better-supabase's entitlements, can now read the tables the SQL helpers use.

  • The Supabase token hook migration adds permdock_bump_authz_version_for(p_users uuid[]), which bumps authz_ver once for each listed user. A trigger on a table the hook does not read, such as better-supabase's entitlement_members, can now invalidate its users' tokens. The function is security definer, and no client role may execute it. The membership triggers now call it. The manifest names it in authzVersionBump when authzVersion is true.

  • The cloud() sink bounds its re-queue while the Cloud is unreachable (capacity, default 10 000, oldest dropped first), matching memorySink.

  • The permdock CLI parses flags with citty. permdock <command> --help (or permdock help <command>) prints that command's flags with their values and defaults. A flag with a fixed set of values rejects any other with exit 2 and the list it accepts, for example catalog: Invalid value for argument: --format (xml). Expected one of: json, schema, markdown.

    Config, permissions and policy modules that Node cannot load on its own fall back to jiti, so they may use enum, TSX, extensionless relative imports and the paths aliases of the nearest tsconfig.json.

    --check on collect, openapi, rls generate and supabase hook generate prints a unified diff of each stale file, cut to 40 lines.

    A missing optional peer (pg, pgsql-parser, unplugin) fails with the install line for the package manager that ran the command, or the one whose lockfile the project has.

    On a terminal, skills install without --agent asks which agents to install for, and doctor --fix asks before writing. In CI, in a pipe or with --json there is no prompt.

    doctor reports are styled on a colour terminal; NO_COLOR and --no-color turn styling off. supabase/config.toml is read with a TOML parser, and the new PD045 warning reports a file that does not parse.

    The CLI's new dependencies are citty, jiti, smol-toml, package-manager-detector, @clack/prompts and diff. Runtime entries still depend on @standard-schema/spec only.

  • The permdock CLI starts faster and reports failures in a form scripts can read.

    • permdock --version (-v) prints the version. --help prints a static command list and loads no command, no config and no core.
    • --yes (-y) never prompts and takes the answer the flags give.
    • Under --json, a failure prints RFC 9457 Problem Details on stdout, with type https://permdock.dev/problems/cli-usage or cli-unavailable.
    • A database that does not answer exits with 1, not 2, and names the command. --db connections time out after 10 seconds and statements after 60. rls introspect runs its queries in parallel over a pool of four. Ctrl-C ends the connection and exits with 130.
    • Generated SQL, catalogs and who-can output sort by code unit, so the same input gives the same bytes in every locale and runtime.
  • The permdock skill names the docs MCP tools search, list_pages and get_page, the tools the docs server at /mcp now exposes.

  • The subject option of permdock/server and every adapter built on it is now typed to return a full Subject or null as well as the policy's user, matching what the kernel already accepted at runtime.

  • The bundled Agent Skills are renamed permdock-wire and permdock-audit, and six skills join them: permdock, a general skill that routes to the others, plus permdock-agents, permdock-approvals, permdock-tenancy, permdock-data and permdock-credentials. permdock skills install copies all eight, and permdock doctor (PD005) looks for the permdock skill.

  • The catalog, permdock rls and permdock doctor now call Standard JSON Schema's jsonSchema.output with { target: 'draft-2020-12' }, as the spec requires, instead of with no options.

  • The generated Supabase hook starts with a stable -- permdock:hook v1 schema=… tenant=… budget=… claims=… line, and supabase hook generate --check names the marker fields that drifted. permdock supabase inspect [--json] prints the version: 1 manifest of the hook and SQL helpers (helper names, tenant claim, budget and its measure, claims written, authz_ver); permdock/supabase exports the SupabaseHookManifest type and permdock/testing exports supabaseHookManifestFixture.

  • The npm README now covers install requirements (Node.js 24 or later, TypeScript 7), the quick start, one example per surface and every entry. The bundled wire-permdock and audit-permissions skills declare license: MIT and metadata (author, homepage, repository) in their frontmatter, and the repository exposes them to Claude Code as the permdock plugin in .claude-plugin/marketplace.json.

  • The package carries the tanstack-intent keyword. Internal non-null assertions and any flows in the core, conditions, CLI and adapters are replaced with checked narrowing; the PDP adapters' granted decision now fails closed with anonymous when a subject has no principal instead of asserting one.

  • The test runners move into permdock: @permdock/testing is now the permdock/testing subpath, with permdock/testing/saas and permdock/testing/saas/permissions. Vitest is an optional peer, and no application entry imports the testing entries. Import from permdock/testing and drop the @permdock/testing dev dependency; permdock is the only package name.

  • Tokens are bound to their issuer and audience. subjectFromJwt requires issuer with any remote jwks (a URL as well as a key set). joseTokenVerifier and permdock/ssf take the issuer from discovery when none is set and check every token against it. subjectFromIntrospection requires aud to contain the configured audience, rejects a differing iss, and no longer fills principal.issuer from the options. scimHandler no longer derives the audience from the request URL and throws when verifier is set without audience.

  • PermDockDeniedError and PermDockApprovalRequiredError carry a stable digest (PERMDOCK_DENIED;<permission> and PERMDOCK_APPROVAL_REQUIRED;<permission>;<token>), the property React keeps when an error crosses from a Server Component to the client, and parsePermDockDigest reads it. The new permdock/next/client entry exports PermissionBoundary, an error boundary built on catchError from next/error that renders its denied or approval fallback for a thrown PermDock error and lets every other error through, and usePermissionBoundary(), which gives a fallback the permission, the approval token and retry().

  • RateLimit and RateLimit-Policy now serialise the policy name as a valid RFC 9651 sf-string: " and \ are escaped instead of dropped, and a role name outside printable ASCII is percent-encoded as UTF-8.

  • cloud().approvals implements cancel(filter, meta) over POST /v1/environments/:env/approvals/cancel, so session revocation rejects pending Cloud approvals in one call.

  • cloud().policies.refresh() verifies the permdock-policy+jwt against the Cloud environment URL (iss and aud are <PERMDOCK_CLOUD_URL>/v1/environments/<env>; exp is 24 hours after issue). The audience option is removed. cloud() exposes the environment URL as issuer, jwks is the environment's JWK Set URL, and cloudEndpoints() resolves both without a key. The policy JWS fixture in permdock/testing follows the new claims.

  • cloud().snapshots.get() requests application/jwt and returns only a compact permdock-snapshot+jwt; an unsigned snapshot body now throws instead of being parsed.

  • createPermDock freezes a copy of the subject instead of the caller's user object, roles and context, so later writes to them neither throw nor reach the instance. A schema that returns a rejecting Promise at a boundary, in a claims mapping or in WebMCP tool arguments no longer leaves an unhandled rejection behind.

  • decide() evaluates a denial's alternatives at the now you pass, not the wall clock. With a pinned now and no sink or on() listener, a check reads no clock at all, so it runs in a prerendered React Server Component or Client Component under cacheComponents.

  • definePermissions(tree, { renamed }) maps former permission keys to current ones: stored custom roles, OAuth scopes, AuthZEN actions, hosted grants and findPermission accept the old key, while decisions and audit see only the new one. The catalog lists renamedFrom, permdock diff reports renamed (not breaking) and alias-removed (breaking), rls generate seeds role_permissions under former keys too and permdock_custom_keys reads a stored former key as its current key, rls generate --shims adds wrappers under legacy helper names, and permdock doctor PD055 and PD056 report what still uses an old key or helper.

  • definePolicy takes delegations: standing statements that holders of from (a role, authenticated(), a plan or assurance()) let actors matching to (actor('eve'), an actor kind, or { kind, id } for one agent) use permissions for them, with optional validFrom / validUntil. Each is normalised to { from, to, permissions: string[], validity? } on policy.delegations, is part of the fingerprint, and is listed in the catalog's new delegations section; permdock diff reports a removed one as delegation-removed and one that lost permission keys or validity as delegation-narrowed. In decide, the matching delegations form a ceiling for the actor: a permission outside it is not-delegated, inside it a token delegation on the call must still cover, and the principal's grants, conditions, denies and approvals apply first as before. The snapshot carries the ceiling as delegated and the client evaluator applies it. delegatedPermissions is exported from permdock.

  • dev.permdock.catalog drift findings are typed CatalogFinding objects { code, permission, grant? } with code one of permission-removed, not-hostable, grantee-removed or approval-tightened; parseCloudEvent and verifyWebhook reject the earlier free-text strings.

  • endpoint: false on createPermDock from permdock/next, its PermDockProvider and the permdock/react provider makes the client snapshot-only. It never fetches, and a check the snapshot cannot answer (a closure, graph relation or period grant) is denied with the new reason server-only. The provider logs the first such permission once. The same reason replaces opaque-condition whenever the client store has no endpoint. permdock doctor PD044 warns when usePermission reads such a grant and the app has neither an app/**/api/permdock/route.ts using permdockHandler nor an endpoint. The Next.js Cache Components guide adds URL slugs (a public slug lookup ahead of the private snapshot, notFound() and forbidden()), snapshot-only mode, and a cross-origin endpoint section.

  • isPermission(value) is exported from permdock: it narrows an unknown such as an MCP tool's meta to Permission without a cast, and accepts a leaf that crossed JSON. permdock/cli exports parseHookMarker(sql) and parseGrantsMarker(sql), which read the first line of a generated hook or --grants-out migration.

  • mayUse(permdock, permission) is exported from permdock. It answers whether some grant in the instance's snapshot could match for its subject, active tenant and delegation, and is the listing hint permdock/mcp and permdock/a2a already use. An MCP server that protectServer cannot wrap, such as better-supabase createMcp, filters its tool list with it and decides each call with decide; adapters/mcp.mdx has the recipe.

  • membership sink events name the scope instance the way memberships do (breaking). MembershipEvent drops tenant and team for scope, id and within, and gains expiresAt, so a role change at any depth (a contact on a site inside a customer inside an organization) is recorded. membershipEvent() takes the same fields and throws a TypeError when scope and id are not given together, or within has no scope. SCIM group changes emit { scope: 'tenant', id }, and Better Auth onRoleChange({ sink }) emits { scope: 'team', id, within: { tenant } } for a team role. The format stays v1.

  • permdock collect and permdock doctor no longer crash with Cannot read properties of null on a source file with array holes (const [, , id] = parts). Doctor PD010 and PD014 ignore comments, and PD014 no longer reads a const jwks: JSONWebKeySet declaration as a jwks option without an issuer.

  • permdock diff a b compares two policies or catalogs (a permissions.catalog.json or a module exporting policy) and lists the permissions, scopes, roles and grants that changed. It exits 1 on a breaking change: a removed permission, scope, role or allow, a narrowed allow (new or changed where / check, new approval, fewer fields, shorter validity, new limit), or a new or changed deny. --impact runs the rls verify fixtures through both policies and reports who loses or gains what; a lost grant is breaking. --json prints the report. The catalog gains a grants section listing every code grant in canonical order when built with the policy; CatalogGrant and CatalogValidity are exported from permdock/catalog.

  • permdock doctor PD002 and permdock collect no longer report valid code as unknown permission references. The false positives were role and plan vocabulary (role(roles.admin, …), plan(plans.pro)), a leaf's wire fields (permissions.post.read.scope, .key) and a resource subtree (relation(permissions.post), include: [permissions.post]).

  • permdock doctor adds two Supabase checks. PD040 warns on auth.role() in a migration, which Supabase deprecated; name the policy's roles with to authenticated instead. PD041 warns when exchangeCapability signs with alg: 'HS256', the project's shared JWT secret; sign with ES256 and an asymmetric signing key.

  • permdock doctor without --fix and permdock usage no longer write permissions.catalog.json. PD002, PD044 and usage scanned the sources through a full collect, which wrote or refreshed the catalog as a side effect, so a doctor run in CI left a changed file behind. They now scan in memory, as the usage page already said.

  • permdock rls generate --rbac supabase no longer emits custom_access_token_hook: permdock supabase hook generate is the one generator of the token hook, so a single function writes user_role, memberships and the other claims. The scaffold keeps the enums, user_roles, role_permissions, the helpers and authorize().

  • permdock rls generate --split helpers,policies,hook --out <dir>/056_permdock_{part}.sql writes one file per part for Supabase declarative schemas, and --grants-out <file> (or - to print) moves the token hook's supabase_auth_admin grants, execute revoke and read policies into a migration of their own, since supabase db diff drops them. permdock supabase hook generate takes the same --grants-out. --check compares every part and the grants file and names the part that drifted. Doctor adds PD042 (error: the hook is declared under supabase/schemas and no migration from the one that creates it on grants it to supabase_auth_admin) and PD043 (warning: schema_paths applies a file that calls the helpers before the helpers part), and doctor.migrations now defaults to supabase/schemas too.

  • permdock rls generate --target drizzle writes pgPolicy(...).link(schema.<table>) exports with the drizzle-orm/supabase roles, and --target prisma writes Prisma 8 policy_* blocks and refuses deny grants, which Prisma 8 cannot express. Both targets also write <out>.migration.sql with the helpers, grants and enable row level security, and --check compares both files. rls.drizzle.schema, rls.drizzle.exports and rls.prisma.models set import paths and names.

  • permdock rls generate applies a role's for to resource-role and nested-scope membership tables too: the exists check counts a membership only when its via column lists an allowed kind, and a table without a via column holds such a role for nothing, matching decide.

  • permdock rls generate compiles role checks to per-statement helpers. Policies call permdock_has('<grant key>') for global roles and "<tenant col>" in (select permitted_tenant_ids('<grant key>')) (or permitted_team_ids) for scoped roles: security definer, search_path = '' SQL functions over a seeded role_permissions (role, permission, grant_key, scope, effect) table, which Postgres evaluates once per statement (an InitPlan or hashed SubPlan) instead of once per row. --authorize database|jwt now drives them for every dialect. Tenant grants with only a claim and global grants with no condition no longer skip the role check.

    Policies collapse to one permissive and one restrictive policy per table and command ({table}_{op}); --policy-per-role keeps the per-role layout and --policy-name sets a template. The tenant claim is cast to --tenant-type (default uuid). Top-level definePolicy({ grants }) compile too, and a grantee Postgres cannot see fails with the permission named. --rbac supabase builds on the same helpers; authorizeSql reads the shared role_permissions. rls import reads both layouts back to roles and memberOf, rls verify passes roles and memberships in the claims and inserts whole rows, and rlsParity sets the role and memberships GUCs.

  • permdock rls generate compiles two more rules. deny(permission, { to: actor('oauth-client') }) becomes a RESTRICTIVE policy that refuses rows while the token carries client_id or act, so a third-party app's Supabase token stops where the in-process decide stops it. contains on a column the resource schema types as an array compiles to value = any(column), cast to the item type, instead of LIKE over the column's text.

  • permdock rls generate emits member_<scope>_ids() for every declared scope: the instances the caller holds any live membership of, with no permission key, for policies such as an organization switcher or "members read their organization's row". In database mode the helpers read the fromTable / fromJunction sources in the new rls.membershipSources (default supabase.hook.memberships) for every scope rls.memberships maps no table for, so the helpers, the token hook and the application's MembershipSource run the same SQL. rls import reads col in (select member_<scope>_ids()) back as a memberOf with no roles, and the hook manifest lists the new helpers.

  • permdock rls generate follows the Postgres and Splinter rules its docs cite:

    • One permissive policy per table, command and database role. anyone() grants are ORed into the authenticated policy and get their own TO anon policy, so Splinter's multiple_permissive_policies lint stays quiet.
    • The neon dialect targets Neon's anonymous role instead of anon.
    • The guc dialect wraps current_setting(...) in (select ...), so each setting is read once per statement.
    • rls import reads the wrapped form back.
  • permdock rls generate on Supabase writes member_<scope>_ids_for(p_user uuid) for each scope with a membership source (rls.membershipSources or supabase.hook.memberships) or a mapped rls.memberships table. It answers what member_<scope>_ids() answers, for p_user, from the membership tables, with the same expiry and suspension rules, so a supabase.hook.claims function can read the user's scopes before a token exists. execute is revoked from public, anon and authenticated. With supabase.hook.claims set, permdock supabase hook generate grants it to supabase_auth_admin (in the --grants-out file when given), and the hook manifest and PD039 list the member_<scope>_ids and member_<scope>_ids_for helpers.

  • permdock rls generate reads rls.dialect from the config, as permdock rls verify does; --dialect still overrides it. It used to fall back to supabase whenever --dialect was absent, so a neon or guc config silently produced Supabase SQL. An invalid rls.dialect now exits 2 like an invalid flag.

  • permdock rls generate skips every grant that requires an approval. It skipped only approval: 'human', so an approval: { by } grant compiled into a plain policy and the database allowed the action without the approval.

  • permdock rls migrate --sql <dir> rewrites a project's own SQL helper calls in create policy and alter policy statements onto permitted_<scope>_ids, permdock_has and member_<scope>_ids, as configured in rls.migrate (helper forms, exact key renames and prefixes). It is a dry run until --write, splices by parser location so formatting is kept, prints { rewrites, skipped } with --json, and exits 1 while a mapped key is unknown. Calls it cannot rewrite safely (a computed id, a dynamic key, a key with row conditions or not seeded on that scope, a call inside a function body or outside a policy) are reported with their line and left as written. rls generate --helpers-only (or rls.helpersOnly) writes only the helpers, seeds and scaffold, and rls verify --introspect with it diffs the helpers and role_permissions seeds exactly, fails on a hand-written policy that passes an undeclared or row-conditioned key, and lists RLS tables that call no helper as info.

  • permdock rls verify --introspect --db <url> compares the live database with what rls generate would write: policies (by name, command, permissive or restrictive, and roles), row level security on each table, the anon and authenticated grants, and whether each helper is still security definer with search_path = ''. It prints one line per difference and exits 1, so a hand-written permissive policy or a missing grant shows up before a user finds it.

  • permdock rls verify --tree fills the required columns its generated rows leave out, so it runs on tables with a NOT NULL tenant column or name: a foreign-key column takes an existing value of the referenced table, any other column a placeholder of its type. Before, the seed failed on any such table.

  • permdock supabase hook generate grants supabase_auth_admin usage on the schema of every table it reads outside the hook's schema, so a schema-qualified source such as fromJunction({ table: 'better_supabase.memberships', … }) works without a hand-written grant.

  • permdock supabase hook generate warns with PD039 when the helper schema has no generated permdock_has or permitted_<scope>_ids (read from rls.out and the migration folders, or the database with the new --db flag). permdock doctor PD039 reports the same and, with a doctor.claims fixture of sample tokens, each supabase.hook.claims entry larger than the memberships budget.

  • permdock supabase inspect --out with no path writes permdock.manifest.json, and inspect --check without --out checks that file instead of exiting 2. The datetime_preferences claim in supabaseClaimFixtures.full now uses CentraKit's keys: timezone, week_start, date_format and time_format.

  • permdock.explain(permission, data) (or decide with explain: true) returns the decision with a trace: how many grants were evaluated, the allows and denies that matched (trace.denies[0] is the deny that won) and the grants skipped with the reason. MatchedGrant carries the grant's name; a deny denial from a named deny carries detail: { name }. describePolicy cells take deniedBy. The trace is off by default and never emitted on a decision event.

  • permdock/a2a emits Agent Cards in the A2A 1.0 wire form, and the cards validate against the published a2a.json. The card URL moves into supportedInterfaces, security schemes use the one-of form (oauth2SecurityScheme), skill requirements become { schemes: { name: { list } } }, and skills carry tags. The card also fills description, capabilities and the default input and output modes. securitySchemes keeps its OpenAPI-style input with a typed type. sign(card, signPayload) now returns the card with a detached RFC 8785 JWS appended to signatures, instead of { card, signature }.

  • permdock/authzen follows more of AuthZEN 1.0:

    • Every response echoes X-Request-ID.
    • /access/v1/evaluations honours options.evaluations_semantic, and treats a request without an evaluations array as a single evaluation.
    • Discovery under /.well-known/authzen-configuration/<path> names the path-qualified PDP identifier.
    • Search accepts page.limit and returns page.count and page.total.
    • Subject search answers only for the user subject type.
  • permdock/eve follows eve 0.70: approval.response reads the responder from ctx.response.principal, and a Cancel from an eligible responder records the approval as rejected, so the re-check denies. permdock/drizzle accepts drizzle-orm 1.0 release candidates next to 0.40 and later, and recognises 1.0 array columns (dimensions) for contains. The @supabase/middleware peer range is now >=1.

  • permdock/jwt and permdock/supabase now take the outermost RFC 8693 act.sub as the actor. RFC 8693 section 4.1 makes that the current actor and says prior actors in nested act claims must not decide access; before, the innermost (oldest) actor was used. Every level of the chain must carry a non-empty string sub, or the subject is anonymous with cause invalid-chain. The full chain stays on delegation.chain.

  • permdock/jwt follows OpenID Connect more closely. Discovery never fetches an http: jwks_uri, and an inline metadata.jwks_uri over http: is a configuration error. With accept: 'id-token', an ID token needs iat, needs azp when it has several audiences, and its azp must be the client id whenever present. The OpenID Connect Core section 5.1 profile claims (email, name, picture and the rest) no longer reach principal.claims. claims.memberships now flattens Zitadel's role -> { orgId: domain } project roles into one membership per organisation.

  • permdock/mcp follows the 2026-07-28 specification more closely. A missing scope is challenged with the scope the call needs plus resource_metadata, not the held scopes plus the missing one; permdock/a2a and HTTP challenges use the same builder. approval-required becomes a multi-round-trip input_required result with a URL-mode elicitation and the approval token as requestState when approval.at is set and the client declares URL elicitation, and the elicitation hint is gone from MCP and WebMCP refusals. stepUp: { at } does the same for insufficient-user-authentication, with acr_values and max_age from the failing assurance grants, which a denial now carries in to. A resource option refuses tokens issued for another RFC 8707 audience. ActionMeta gains destructive and idempotent; tool annotations the author leaves out are filled from the permission, and crud() marks delete with destructive: true instead of tags: ['destructive']. The unused InsufficientScopeError export is removed.

  • permdock/mcp: createPermDock and subjectFromMcp now read RFC 9396 authorization details from both authInfo.extra.authorizationDetails and the raw claim name authorization_details. Before, each read only one of the two, so the same verifier output lost its details in one of them.

  • permdock/next targets Next.js 16.3 (the next peer range is now >=16.3). createPermDock also returns requireAccess({ permission, data?, tenant? }), which resolves to the granted Decision and interrupts a denial with unauthorized() for an anonymous subject or forbidden() otherwise (experimental.authInterrupts); approval-required and boundary validation still throw PermDockApprovalRequiredError and PermDockValidationError. The request-scoped instance is created after await io(), so decisions that read the clock no longer trip the "Date.now() while prerendering" error under Cache Components, and never block a prefetch the way connection() would. A redirect(), notFound() or other Next.js interrupt thrown by the subject or tenant resolver now propagates (unstable_rethrow) instead of turning the request anonymous. Sink writes and one flush() per render run inside after(), so a serverless function stays alive until buffered decision events are delivered.

  • permdock/openapi marks an operation with x-permdock-approval (and the optional x-badges hint) for every grant that requires an approval. It marked only approval: 'human', so approval: { by }, { distinct: false } and { staleOn } grants looked approval-free in the description.

  • permdock/supabase exports actorOf(claims) and delegationOf(claims), the rules subjectFromSupabase uses for the acting app: actorOf returns { ok: true, actor? } with the innermost act sub (or client_id) and a frozen copy of the chain, or { ok: false, reason: 'invalid-chain' }, which must deny; delegationOf returns the scope claim as { scopes }. supabaseClaimFixtures entries now state the expected actor and delegation.

  • permdock/supabase exports supabaseClaims({ tenantClaim? }), a Standard Schema v1 object for the hook's claim contract, and its types SupabaseClaims and SupabaseMembershipClaim; .extend(appSchema) merges a Zod, valibot or other Standard Schema over it, so a session library validates tokens without copying the shape. subjectFromSupabase now reads grantedBy and reason on memberships, supabase-claims-v1.json covers app_metadata, client_id, scope and act, and supabaseClaimFixtures adds full, portalContact, oauthClient and actChain.

  • permdock/supabase exports supabaseTenantClaim (tenant_id), the one default the hook, subjectFromSupabase, supabaseRls, rls generate, rls verify and rlsParity share. permdock doctor PD038 warns when a subjectFromSupabase or subjectFromSupabaseSession call reads the tenant from a different claim than rls.tenantClaim.

  • permdock/supabase reads act.kind as better-supabase 0.5.1 writes it: support becomes actor kind support with sessionId and readOnly, and impersonation becomes actor kind impersonation. Neither carries a delegation, so they reach only what a policy delegation names. An actor with readOnly: true gets only the read-only permissions of each policy delegation (readOnly on policy.delegations); other permissions deny with not-delegated, and mayUse follows the policy delegation ceiling. An unknown kind, or a support level without session_id, is the anonymous subject. supabaseClaims() and supabase-claims-v1.json validate the new act fields.

  • permdock/terminal asks for the resource id to be typed before running a destructive permission (replace the prompt with interactive: { typed }), and without a terminal exits with the new EX_USAGE (64) unless --yes or -y is passed or the yes option is set. --dry-run (or dryRun: true) decides as a simulation, prints the decision and exits with its code without running the action or consuming a limit. The ci-oidc token source takes { source: 'ci-oidc', audience } to request a GitHub Actions audience and { source: 'ci-oidc', env } to read a GitLab id_tokens variable. permdock/jwt adds subjectFromCiOidc(token, { provider, audience, issuer?, schema? }) for GitHub Actions, GitLab CI and Buildkite tokens, returning a workload principal with repository, ref and environment, never a user.

  • permdock/terminal: a network failure during the device flow now ends the device token source with no credential, so the chain moves on to the next source, the same as a failed refresh or revocation. Before, the thrown fetch error escaped the command.

  • permdock/testing/saas adds scenarios for an agent acting without a delegation, a delegation narrower than the user's grants, an exhausted api-key quota, a team role held on the tenant membership, org-1 against tenant-1 on lists and writes, and an expired membership on every permission class. Scenarios may carry actor, delegation and quotaUsed; saasUser, saasScenarioOptions and saasLimitStore build the subject and options a case runs with.

  • permdock/webmcp now validates instance-tool arguments against the resource schema when registerTools is given a resource node such as permissions.post, or a nested group, as the docs show. Before, the schema was found only from the root tree, so agent input went unvalidated. Collection actions no longer pick up the resource schema, and an explicit schema option now overrides it.

  • receiver.poll reports polled SETs that can never verify under RFC 8936 setErrs in the acknowledgement request, so a transmitter stops redelivering them. A SET whose handler failed is still left pending for redelivery.

  • resource() takes name for a resource name apart from its path, action metadata takes meta.x for application-owned JSON, rls.actions maps action verbs such as view to SQL commands (or 'none'), and describe(decision, { messages }) localises titles and details.

  • scimHandler and directoryMembershipSource read groupRoles by own key only. A group id such as constructor or __proto__ no longer resolves to an Object.prototype member, which made the SCIM endpoint answer 500.

  • sender: 'dpop' (the profile: 'fapi2' default) now resolves the anonymous subject with dpop-proof-invalid when no Request is passed, instead of skipping the proof. verifyDpopProof refuses a symmetric alg and a jwk header carrying private key members, per RFC 9449 section 4.3.

  • simulate() no longer throws on an Arazzo step whose parameters array holds null or a non-object. It skips those items and decides the step as usual.

  • snapshotTag(sub) from permdock/next returns the cache tag for a user's snapshot entries (permdock:<sub>, or permdock:anon without one), so a private-cache loader and the Server Action that changes roles agree on one string. The Next.js Cache Components guide gains the better-supabase 0.4 recipe: bs.cached({ tags }) inside 'use cache: private', stale capped by both the session and cacheLifeFor(snapshot), and bs.invalidateSession(sub, { tags: [snapshotTag(sub)] }) after a role change. Its URL-slug loader now takes the org id and never calls notFound() inside the private cache. The new apps/examples/next-better-supabase runs that recipe in snapshot-only mode against Postgres in testcontainers. permdock doctor PD014 now accepts a shorthand issuer property next to jwks.

  • subjectFromSupabase and subjectFromSupabaseSession take anonymousSignIns: 'deny', which maps a signInAnonymously() token (is_anonymous: true) to the anonymous subject, as rls.anonymousSignIns: 'deny' does in RLS. permdock doctor PD057 warns when rls.anonymousSignIns is 'deny' and a subject call does not pass the option.

  • subjectFromSupabase maps more of the token. plans: '<claim>' reads the active tenant's entry of a per-tenant plans claim (better-supabase's features) into principal.plans. act and the OAuth client_id of a third-party app become an oauth-client actor with scope as delegation.scopes, so such a token only reaches what its scopes delegate. A memberships entry the reader cannot use is still dropped, and now reports membership-dropped to the new onAuth option; permdock doctor PD039 lists those entries from the doctor.claims samples. supabaseClaimFixtures.betterSupabase is the canonical claim set better-supabase 0.4 emits.

  • supabase.hook.claims adds claims other packages own to the generated Custom Access Token Hook, for example { features: 'better_supabase.feature_claims' }. Each value is a schema-qualified (uuid) returns jsonb function; null leaves the claim out. permdock supabase hook generate refuses a claim name PermDock or Supabase Auth writes (roles, memberships, the tenant claim, sub, role and the rest) and an unqualified function, grants supabase_auth_admin execute on each function, keeps the claims outside the memberships budget, and writes none of them for a suspended user.

  • supabaseClaimFixtures in permdock/testing adds supportSession, supportSessionReadOnly, impersonation and anonymousSignIn, and each fixture states the actor and delegation it maps to.

  • supportAccess() now type-checks inside definePolicy({ roles }) when the policy's scopes are literal: it returns RoleBinding<S> for on: S and RoleBinding<'tenant'> without on, the scope it defaults to.

  • webBotAuth takes a now clock in Unix seconds for the created and expires checks, so the RFC 9421 Appendix B vectors and replayed captures can be verified.

On this page