# Snapshots

Source: https://permdock.com/docs/concepts/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](/docs/research/landscape)).

## Producing a snapshot [#producing-a-snapshot]

```ts
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: true
```

`snapshot` 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](/docs/concepts/snapshots), [tenancy](/docs/concepts/tenancy), [UI](/docs/concepts/ui)). Full grants with normalised conditions ship; `include` is the size lever. `parseSnapshot(json)` is the public reader.

### Without an instance: `snapshotFor` [#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.

```ts
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` [#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 [#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 [#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](/docs/getting-started/larger-apps).

## Wire format [#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](/docs/concepts/wire-formats#snapshot); 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](/docs/concepts/ownership)). |
| `grants` | One entry per grant, keyed by permission `key`; conditions in the [portable JSON form](/docs/concepts/conditions); `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](/docs/concepts/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](/docs/concepts/policies#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](/docs/concepts/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](/docs/security/delegation#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](/docs/concepts/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 [#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:

```ts
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](/docs/concepts/wire-formats), [JOSE](/docs/standards/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](/docs/adapters/cloud)).

## The client PermDock [#the-client-permdock]

```tsx
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_2
```

The 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](/docs/concepts/ui) tenant switcher).

### status [#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 [#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](/docs/standards/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](/docs/concepts/validation) and the [threat model](/docs/security/threat-model).

## Invalidation [#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](/docs/standards/shared-signals-caep)) |
| 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 [#nextjs-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](https://nextjs.org/docs/app/guides/instant-navigation)). 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](/docs/adapters/next).

## React Native and Expo Router [#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.

```tsx
<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](/docs/adapters/react-native).

## Testing with snapshots [#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:

```ts
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](/docs/adapters/testing).

## Size [#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 [#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](/docs/concepts/custom-roles)).
* Quota state for `limit` grants; those are always `server-only` (`portable: false`), and no snapshot carries a decision's `quota`.
* The principal's `claims`. A grant condition that reads `principal.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`, `tenant` and the other fields the snapshot principal carries stay references.
* Anything from the actor beyond what appears in `delegation`.

## Why [#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 combines `tenants: 'all'` with `include` to ship only the groups the switcher needs, and the default (the active tenant) stays the right answer for everyone else. `include` already filters by permission, which is the axis that grows with policy size; a separate tenant filter on `include` was rejected because `tenants` is that filter.
* **Per-tenant snapshots at the edge are a `SnapshotSource` job.** Serving a pre-built snapshot per tenant from a CDN needs a store, invalidation and signing, which is what `SnapshotSource` exists for; the Cloud's implementation serves signed snapshots from its edge and invalidates them on CAEP events ([PermDock Cloud](/docs/adapters/cloud)). Core keeps producing snapshots on demand and never grows a cache of its own.
