PermDock
Decisions

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.update as 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 id field, action metadata. can, filter, validate and the MCP adapter look the schema up by resource name.
  • Identity is by key. Two leaves with the same key are the same permission regardless of object identity. Grants, snapshots, client caches and mergePermissions all resolve by key. mergePermissions preserves 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.

On this page