0003: Reference-based permissions
Why permissions are typed objects like permissions.post.update instead of string keys or template-literal unions.
Status
Accepted, September 2026.
Context
Every in-process TypeScript authorization library identifies a permission with a string. permix uses template-literal unions (check('post.create')), CASL uses declared [Actions, Subjects] tuples, Better Auth infers keys from an as const statement, @zap-studio/permit parses "post:write" at runtime. Strings have known costs:
- No go-to-definition or rename refactoring; a typo is a compile error at best and a silent deny at worst.
- Template-literal unions over resources times actions multiply. TypeScript 7 compiles faster but its instantiation and recursion limits are unchanged, so large catalogs still hurt editor latency.
- A permission has no place to carry metadata. Scope strings for OAuth, titles for MCP tool descriptions and RFC 9396
authorization_detailstypes must live in a side table. - A catalog must be extracted from source or declared twice, because the type union does not exist at runtime.
permix PR #64 asked for a runtime catalog with a CI freshness check; the answer was another extraction step over string markers.
Decision
A permission is a typed reference: permissions.post.update is a plain frozen object produced by definePermissions() and resource(), typed as Permission<'post.update', Post>. It carries key, resource, action, scope and meta; the resource schema and identity field live on the resource node. Every public API takes the reference:
permdock.can(permissions.post.update, post)
allow(permissions.post.delete, { approval: 'human' })
usePermission(permissions.post.update, post)
registerTool('delete_post', { permission: permissions.post.delete }, handler)Strings appear only as .key (audit, cache, catalog) and .scope (OAuth, MCP). findPermission(permissions, 'post.update') converts inbound strings (database rows, JWT scopes, OpenAPI documents) back to references at the boundary. Helpers are functions (listPermissions, findPermission, mergePermissions) rather than methods on the tree, so a resource named list can never collide with an API.
Consequences
- The runtime definition is the catalog.
permdock collectscans usages for coverage and drift; it does not have to reconstruct the definition from source. - Types flow from the reference argument, so React hooks and server helpers are direct exports with no per-app generic factory (see 0006).
- Unknown permissions are type errors, not runtime denials; the fail-closed runtime path still exists for strings resolved through
findPermission. - No template-literal union is ever built, which keeps the type checker fast on catalogs with hundreds of leaves and keeps PermDock friendly to TS 5.9, 6 and 7.
- Leaves must stay plain JSON so they can cross RSC and wire boundaries; identity is by
key, not object identity (see 0008). - Dead permissions are detectable by unused-export analysis and by
permdock usage.
Alternatives considered
- Template-literal string keys (permix). Best-in-class autocomplete for small catalogs, rejected for type-checker cost, no metadata, no refactoring, and a second extraction pipeline for the catalog.
- Declared action and subject tuples with runtime subject detection (CASL). Rejected: the reference already names the resource, so
detectSubjectType,subject()wrappers andconstructor.namechecks are unnecessary. - Proxy-generated path trees (Kilpi). Right shape, but a Proxy cannot be serialised or introspected. PermDock materialises the same tree eagerly from the definition.
- Both strings and references. Rejected to keep one public API; strings remain a boundary concern handled by
findPermission.
Related
0002: One umbrella package with subpath exports
Why runtime adapters ship inside the single permdock package while the CLI and testing helpers are separate scoped packages.
0004: Instance actions vs collection actions
Why each resource declares actions that take an instance and collection actions that do not, with the arity encoded in the type.