Snapshots
A snapshot serialises roles, grants and portable conditions so clients evaluate permissions offline; closures stay server-only and invalidation is explicit.
The client needs to know what the current user may do, without a round trip per button and without duplicating the policy in a second setup() call. PermDock's answer is the snapshot: permdock.snapshot() serialises the subject, the roles and every portable grant, including conditions, as JSON. The browser or React Native app builds a snapshot-backed PermDock from it and answers usePermission(permissions.post.update, post) locally, ownership check included. Grants that cannot be serialised are marked, and the client asks a decision endpoint for those.
This is the design response to permix's hydration, which collapsed function rules to booleans and forced a duplicated client setup(), and to CASL's undocumented packRules format (landscape).
Producing a snapshot
const permdock = await createPermDock(policy, user);
const full = permdock.snapshot();
const scoped = permdock.snapshot({
include: [permissions.post, permissions.billing.plan],
});
const all = permdock.snapshot({ tenants: "all" }); // every membership, for a local tenant switcher
const preview = permdock
.simulate({ tenant: "o_globex", roles: ["viewer"] })
.snapshot(); // simulated: truesnapshot is synchronous and pure (asynchronous only when a signer is passed, see below). It includes only what the current subject's roles and memberships grant, so a member's snapshot never reveals admin grants. By default it is scoped to the active tenant: a member of ten organisations ships the grants of the one this request is about, plus global grants. tenants: 'all' includes every membership so the client can switch tenants without a round trip; it stays opt-in, with no membership-count threshold (snapshots, tenancy, UI). Full grants with normalised conditions ship; include is the size lever. parseSnapshot(json) is the public reader.
Without an instance: snapshotFor
snapshotFor(policy, user, options) builds the same snapshot without creating an instance. It is synchronous and deterministic: pass now and the same inputs always give the same JSON, which makes it safe to call inside a function the app marks 'use cache: private'. PermDock never adds that directive itself.
import { snapshotFor } from "permdock";
const snapshot = snapshotFor(policy, claims, {
tenant: org, // the org in the URL; ignored without a matching membership
plans: [orgPlan], // plans are per tenant; pass the active org's plan
customRoles: orgRoles, // CustomRole[] loaded by the app, filtered to the subject's orgs
assignable: { [org]: offered }, // optional: RoleSource.assignable per tenant
memberships: rowsFromDatabase, // database mode: replaces the memberships in the claims
});It throws a TypeError when the policy's subject or context mapper is async, because a cached function must return the snapshot, not a promise. Load async inputs first and pass them as options.
Optimistic checks: mayAccess
mayAccess(policy, user, permission, { tenant }) answers "could this user ever pass?" from the claims alone, for a proxy or middleware that has no row. It returns false only when no declared role the subject holds in that tenant grants the permission, or when an unconditional deny matches. Row conditions, plan gates, custom roles and claims without memberships all return true, and the page makes the real decision.
Simulated snapshots
simulate({ roles, memberships, tenant }).snapshot() produces a snapshot for a hypothetical subject ("view as a Globex viewer"). It carries simulated: true; the decision endpoint and approvalsHandler refuse requests whose snapshot is simulated, so a preview can render every guard but can never produce a real token or mutation. Producing one is itself a permission you declare (for example permissions.admin.previewAs).
Scoped snapshots
include limits the snapshot to the listed groups or leaves. Anything outside reports status: 'server-only' on the client and is resolved through the endpoint. This is how a large policy ships only the grants a route needs, the equivalent of passing one message namespace to the client in next-intl. See larger apps.
Wire format
The TypeScript type is Snapshot; v is the format major, 1, and parseSnapshot rejects any other. The full JSON shape with an example is on wire formats; the fields mean:
| Field | Meaning |
|---|---|
v | Format major, 1. Readers reject unknown majors. |
subject | The frozen principal (including memberships and the active tenant), delegation and context the conditions reference. The actor's secrets are never included. |
roles | The role names the subject holds in the active tenant, custom role names included, ranked by the policy's assigns graph when it has one. |
audiences | The distinct meta.audience values of those roles, in rank order (["staff", "portal"]); absent when no held role has one. Read by audiences() to pick a layout or landing page (ownership). |
grants | One entry per grant, keyed by permission key; conditions in the portable JSON form; to is the grantee union. |
vocabulary | The declared roles and plans (vocabulary.roles, vocabulary.plans), so the client renders titles without the catalog. |
scope, membership | On grants from scoped roles: the scope kind and the membership that supplied the role, so the client evaluates the scope match exactly as the server did (tenancy). Absent on global grants. |
portable: false | The grant exists but uses a closure or an opaque condition; the client must ask the server. |
validity | { from?, until? } in Unix seconds on a grant with validFrom or validUntil; the client evaluates it against its own clock (or the now it is given), so a snapshot taken before a window opens starts granting when it does (validity). |
assignable | One { tenant, roles, permissions, levels? } entry per tenant in tenants: what the subject may hand out there, read by assignableRoles, assignablePermissions, assignableLevels and useAssignablePermissions (custom roles). |
notEntitled | One { permission, role, to } entry per allow grant whose roles the subject holds but whose plan grantee it lacks. It grants nothing; the client evaluator adds a not-entitled denial from it, so usePermission and describe name the plan without a round trip. Absent when there are none. |
delegated | The sorted permission keys the policy's delegations let the subject's actor use for the principal. Present only when one applies; outside it the client evaluator denies with not-delegated, inside it the subject's token delegation, when present, must still cover. When it is present an actor without a token delegation does not get the empty scopes list (policy delegations). |
ids | The row id field per resource whose id option is not id ({ "doc": "uuid" }), for the resources in grants. The client reads a membership on one row and the decision token's resource id from that field. Absent when every resource reads id. |
tenants | The tenants whose grants are present: the active tenant by default, every membership tenant with tenants: 'all'. |
simulated | true when produced by simulate; the decision endpoint refuses it. |
include | Present on scoped snapshots so the client knows which groups are authoritative. |
issuedAt, expiresAt | Unix seconds. expiresAt is optional and copied from the subject (min(exp, session_expiry) of the token it was built from, see authentication); the earliest membership expiresAt also bounds it. Past it the client reports 'stale' regardless of maxAge. |
Conditions are plain JSON (no superjson; dates as tagged ISO strings), so the snapshot, the catalog and RLS generation share one condition format.
Signed snapshots
A snapshot that is persisted on a device, served from an edge that is not the application, or consumed by code in another language can be signed:
import { joseTokenSigner, joseTokenVerifier } from 'permdock/jwt'
const signer = joseTokenSigner({ key: privateJwk, alg: 'Ed25519', kid: '2026-09' })
const jws = await permdock.snapshot({ include: [permissions.post], signer, audience: 'https://app.example.com' })
// client, or any service in any language with a JOSE library and the JWKS
<PermDockProvider snapshot={jws} verifier={joseTokenVerifier({ jwks: 'https://app.example.com/.well-known/jwks.json', typ: 'permdock-snapshot+jwt' })} />With signer, snapshot returns a Promise<string>: a compact JWS with header alg, kid, typ: 'permdock-snapshot+jwt', registered claims iss, aud, sub (the principal id), iat (= issuedAt), exp (= expiresAt), jti, and the unchanged snapshot object under the snapshot claim (wire formats, JOSE). The client verifies before hydrating; a snapshot whose signature, typ, aud or exp fails is treated as absent and every guard reports 'server-only' until a refresh succeeds. The type follows the call: snapshot() and snapshot({ include, tenants }) are typed Snapshot, snapshot({ signer }) is typed Promise<string>, and only an options value whose signer the compiler cannot see is typed as either (SnapshotOptions). An instance from fromSnapshot signs the same way. Without signer the plain JSON form is returned, and it remains the default for the common case where the server that computed the snapshot also serves the page.
SnapshotSource implementations may serve either form; the client accepts both and applies verifier only to a string. memorySnapshotSource(snapshot) is the in-process default: set replaces the snapshot and calls every subscriber. permdock/cloud only ever returns the signed form (an unsigned body is an error) and publishes its keys at the environment's /.well-known/jwks.json (cloud().jwks); the client verifies it with joseTokenVerifier({ jwks: cloud().jwks, typ: 'permdock-snapshot+jwt' }) (Cloud adapter).
The client PermDock
import {
PermDockProvider,
usePermDock,
usePermission,
Protected,
} from "permdock/react";
<PermDockProvider snapshot={snapshot} endpoint="/api/permdock">
<App />
</PermDockProvider>;
const permdock = usePermDock();
permdock.can(permissions.post.update, post); // boolean, from the snapshot
permdock.decide(permissions.post.update, post); // Decision, from the snapshot
permdock.status(permissions.post.publish); // 'server-only'
permdock.invalidate(permissions.post); // drop cached endpoint answers under post.*
permdock.tenants(); // ['o_1'] or every membership tenant with tenants: 'all'
permdock.tenant("o_2").can(permissions.post.read); // derived instance; denied unless the snapshot carries o_2The client instance has the same can, decide, filter, tenant, team, memberships, tenants, roles, audiences and assignable as the server one (decideRoleChange always denies with unsupported there: role changes are server decisions), evaluated against the snapshot with the same in-memory evaluator. It adds status, invalidate, refresh and subscribe, and it consults the endpoint for anything the snapshot marks as portable: false or does not include. usePermDock subscribes through useSyncExternalStore, so a snapshot refresh re-renders exactly the components that read it. refresh({ tenant }) asks the server for a snapshot with another active tenant; the server resolves it against the subject's memberships (UI tenant switcher).
status
usePermission returns { allowed, status }:
| Status | Meaning | What the UI should do |
|---|---|---|
'ready' | Answered from the snapshot, or the endpoint has replied | Render on allowed |
'pending' | Not in the snapshot; an endpoint request is in flight | Render the pending slot; never block navigation |
'stale' | Answered from a snapshot or cache that has been invalidated; a refresh is in flight | Render on allowed, expect a change |
'server-only' | Not in the snapshot and no endpoint configured, or the permission is outside include and offline | Render fallback; treat as denied |
allowed is always a boolean so guards never see undefined. During 'pending' it is false. <Protected> maps the statuses to its pending and fallback props.
The decision endpoint
endpoint is a URL that answers closure grants and refreshes. The React provider batches requests within a tick, dedupes by permission key plus resource id, and caches by that key. The request and response bodies are AuthZEN evaluations messages (AuthZEN), so the same server route serves the React client, the pdp provider and any AuthZEN PEP. permdockHandler() from the Next.js adapter and the authzen adapter both implement it.
Data sent to the endpoint has crossed a trust boundary. The server validates it against the resource schema (validate: 'boundary') and re-evaluates with its own subject, never the client's. The endpoint must be behind real authentication, not a shared public secret; see validation and the threat model.
Invalidation
A snapshot is a point-in-time view. Three things refresh it:
| Trigger | Mechanism |
|---|---|
| Role or grant change on the server (Next.js) | updateTag(snapshotTag(user)) (snapshotTag from permdock/next) in a Server Action (or revalidateTag(tag, { expire: 0 }) in a Route Handler), where the tag is the one your 'use cache: private' loader set with cacheTag; the App Shell prefetch is refreshed and the client receives a new snapshot on the next navigation |
| Identity provider signal | permdock/ssf receives a CAEP session-revoked, credential-change or assurance-level-change event and calls the same tag update, so staleness is bounded by the IdP, not by a TTL (Shared Signals) |
| Client-side knowledge | permdock.invalidate(permissions.post) drops endpoint answers under post.* and marks snapshot-derived answers 'stale' until the provider refreshes |
invalidate takes a reference (a group or a leaf), never a string prefix, so it is typed and rename-safe; it is the typed version of Kilpi's namespace invalidation. Snapshots also carry issuedAt, and the provider accepts a maxAge after which everything is reported 'stale' while a refresh is fetched. There is no default maxAge: without it and without expiresAt, a snapshot never goes stale by age.
Next.js and Instant Navigations
Next.js 16.3 puts session-derived output in the prefetched App Shell only when it is produced inside 'use cache: private' with a stale time of at least five minutes (guide). Wrap snapshotFor in a function you mark that way, size its lifetime with cacheLife(cacheLifeFor(snapshot)), tag it with cacheTag, and pass the unawaited result to PermDockProvider from permdock/react as snapshotPromise. Guards then resolve from the shell without blocking: the components that read permissions answer status: 'pending' until the snapshot lands, and never suspend unless the provider sets suspend. The server PermDockProvider from permdock/next also streams a snapshotPromise but does not cache it. Anything the snapshot cannot answer streams under Suspense with status: 'pending'. The next example app runs this pattern. See the Next.js adapter.
React Native and Expo Router
Expo Router's Stack.Protected guard={boolean} is synchronous, so the first frame needs an answer before any network. permdock/react-native adds a storage option (MMKV, AsyncStorage or any key-value store) to the provider: the last snapshot is persisted, read on launch, used for the first render, and revalidated in the background.
<PermDockProvider
storage={mmkvStorage}
endpoint="https://api.example.com/permdock"
>
<Stack>
<Stack.Protected guard={usePermission(permissions.admin.access).allowed}>
<Stack.Screen name="admin" />
</Stack.Protected>
</Stack>
</PermDockProvider>A persisted snapshot is reported as 'stale' until the refresh completes, which is the honest status: the guard shows the last known answer immediately and corrects itself if roles changed. See the React Native adapter.
Testing with snapshots
Snapshots are plain JSON, so UI tests do not need a server. permdock/testing builds fixtures from a policy and a user:
import { snapshotFixture } from 'permdock/testing'
import { render } from '@testing-library/react'
const snapshot = snapshotFixture(policy, memberUser, { include: [permissions.post] })
render(
<PermDockProvider snapshot={snapshot}>
<EditButton post={ownPost} />
</PermDockProvider>,
)Without an endpoint, closure grants resolve to status: 'server-only', which is the correct thing to assert against in a component test: the component should render its fallback, not hang on pending. Storybook stories use the same fixtures. See the testing adapter.
Size
A grant entry is roughly the size of its condition. A policy with two hundred portable grants serialises to a few kilobytes; scoping with include and gzip on the transport keep it small. Snapshots contain no schemas, no metadata and no closure source, and the same subject always produces byte-identical output for the same policy, so they cache well behind 'use cache: private' and in storage.
What is not in a snapshot
- Closures and opaque conditions: only their existence (
portable: false). - Grants of roles the subject does not have, and grants of tenants outside
tenants(the active one by default). - Custom-role definitions: only their resolved grants, each bounded by the ceiling and carrying the custom role name as
role(custom roles). - Quota state for
limitgrants; those are alwaysserver-only(portable: false), and no snapshot carries a decision'squota. - The principal's
claims. A grant condition that readsprincipal.claims.*gets the subject's value as a literal when the snapshot is built, so the client evaluates the same attribute without holding the claim set.principal.id,tenantand the other fields the snapshot principal carries stay references. - Anything from the actor beyond what appears in
delegation.
Why
tenants: 'all'has no cap and no paging. A cap would silently drop tenants from a switcher, which is worse than a large payload the application chose; paging would turn one pure, cacheable value into a sequence of requests with a consistency problem between pages. A subject with many memberships combinestenants: 'all'withincludeto ship only the groups the switcher needs, and the default (the active tenant) stays the right answer for everyone else.includealready filters by permission, which is the axis that grows with policy size; a separate tenant filter onincludewas rejected becausetenantsis that filter.- Per-tenant snapshots at the edge are a
SnapshotSourcejob. Serving a pre-built snapshot per tenant from a CDN needs a store, invalidation and signing, which is whatSnapshotSourceexists for; the Cloud's implementation serves signed snapshots from its edge and invalidates them on CAEP events (PermDock Cloud). Core keeps producing snapshots on demand and never grows a cache of its own.
Last updated on
Decisions
decide returns a discriminated Decision with three outcomes, matched grants, denials, alternatives and a replay-safe token; assert and simulate build on it.
Streams and sockets
A long-lived WebSocket, SSE stream or subscription gets a frozen Connection with per-message checks, an outbound filter and an AbortSignal that ends it when the subject is revoked, expires or loses the permission that opened it.