0008: Plain JSON leaves, identity by key
Why a permission reference is a frozen JSON object with the schema on the resource node, and why two leaves are the same permission when their keys match.
Status
Accepted, September 2026.
Context
Reference-based permissions (0003) raise a question that string keys never had: what is the reference at runtime, and when are two references equal?
The constraints come from where references travel:
- A Server Component passes
permissions.post.updateas a prop to a client component across the RSC boundary. Only serialisable values survive. - A Vite client bundle and a Next.js server bundle may each contain their own copy of the definition module; a monorepo may load a shared package twice.
- The MCP adapter, the AuthZEN endpoint, the audit log and the catalog all send the permission over the wire.
- Standard Schema validators are not serialisable and are often large; Zod objects in particular must not be duplicated per leaf or shipped where they are not needed.
Kilpi's Proxy tree and CASL's class instances both fail the first two constraints. The chat and the CASL deep-dive converged on eager construction of a plain tree.
Decision
- A permission leaf is a plain, frozen, JSON-serialisable object:
{ key, resource, action, scope, meta }plus phantom types for input and subject. It contains no functions, no class prototype, no schema. - The resource node carries what the leaf cannot: the Standard Schema validator, the
idfield, action metadata.can,filter,validateand the MCP adapter look the schema up byresourcename. - Identity is by
key. Two leaves with the samekeyare the same permission regardless of object identity. Grants, snapshots, client caches andmergePermissionsall resolve by key.mergePermissionspreserves leaf identity where it can (permissions.post.update === postPermissions.post.update) as an optimisation, never as a requirement. - Keys are dotted paths (
billing.invoice.pay); scopes are the colon form (billing:invoice:pay). A duplicate key across merged definitions is a type error and a runtime throw.
Consequences
- References can be props, JSON bodies, log fields, catalog entries and test fixtures without conversion.
- Duplicate module copies, hot reloads and serialisation round-trips cannot produce "unknown permission" for a known key.
- The runtime permission tree is small enough to ship to clients and React Native; validators stay in the resource nodes and are tree-shaken where unused.
- Lookups are constant-time on a frozen key index built once per definition; prototype-polluting keys (
__proto__,constructor) are rejected at definition time. - Because identity is by key, keys are part of the public contract: renaming a resource is a wire-visible change and shows up in
permdock collect --check. - Conditions follow the same rule: plain JSON, no
superjson, dates as tagged ISO strings (0010).
Alternatives considered
- Class instances with methods (
permissions.post.update.can(post)). Rejected: not serialisable, not RSC-safe, and puts methods on the tree where they collide with resource names. - Proxy-generated paths (Kilpi). Rejected: cannot be enumerated for the catalog, cannot cross boundaries, and defeats
isolatedDeclarations. - Object identity as equality. Rejected: fails with duplicate bundles and after any serialisation.
- Schema on every leaf. Rejected: duplicates the validator per action and leaks it into bundles that only need the key.
Related
0007: decide() returns a Decision
Why the structured check is called decide and returns a discriminated Decision instead of an explain() method with a message, and why explain may survive as an alias.
0009: Validate at the boundary by default
Why resource schemas run only on data that crossed a trust boundary, why the default is boundary rather than always or never, and why validation is synchronous.