The permission MODEL — a catalog derived from the real operation surface, and a role becomes a permission SET
Introduce the permission catalog and re-express the shipped access policy over it, so the question the code asks is "does this actor hold work_item:edit?" rather than "is this actor a member?" — with no change to what any actor can do. This is the foundation the rest of the Permissions epic stands on, and it earns its place by being provably behaviour-neutral: the built-in roles are defined as exactly the permission sets that reproduce today's eleven predicates, and a truth table proves the equivalence over every combination of access level and role.
⚠️ Re-sliced 2026-08-06 (Yue) — the page moved out
This story originally ended in the read-only Roles & permissions page, so that it finished in something a person could accept. That page is now its own story.
The bundling produced a straddle that surfaced three times: the page needs work that lands AFTER the per-domain split, while the split needs only the model — so this story kept having to sit on both sides of a sibling. Each time it looked like a wiring mistake and each time it was the slice. The epic's own shape is already layered (MOTIR-2256 is model-only, MOTIR-2258 is UI-only), so the model/page boundary is the honest one, and it is what lets the page ship once, against a fully enforced catalog, instead of against a model that is a quarter wired.
So this story is now the MODEL, end to end — and it is complete.
What it ships
- The catalog is CODE, not data.
lib/permissions/catalog.ts— a frozen, exhaustive list of keys in the mirror'sresource:actionform, each with a domain, i18n keys, and anenforcementmarker. Permissions are never rows a user authors: a key nothing enforces would be a promise the product cannot keep. - The catalog is derived from the REAL operation surface. The inventory walked all 251 routes, the 22 Server Action files and 122 services and recorded a decided policy per operation — because the eleven shipped predicates turned out to be one corner of the enforcement surface, not the whole of it. 32 permissions across 16 domains; 11 wired today, 21
plannedand awaiting the split. - A role is a SET over that catalog.
BUILTIN_ROLE_PERMISSIONSmaps admin / member / viewer to their sets;resolvePermissionsturns{ accessLevel, workspaceRole, projectRole }into the actor's effective set, with both shipped rails expressed INSIDE it. - The eleven predicates SURVIVE as their public API — same names, same signatures, now one-line membership tests. None of the ~150
assertCan*call sites moves; re-pointing them is MOTIR-2256's job. - The service read —
getPermissions/getPermissionsDTO/getRoleCatalogonprojectAccessService, keeping the 404-not-403 posture. - Both guards, closing the model in each direction — every key has a consumer, and every operation has an answer.
Scope boundary
In: the catalog + its domains + enforcement; the built-in role sets and the resolution; the re-expression of all eleven predicates; the service read and its DTO; the operation inventory; the no-ungoverned-operation guard.
Out: the page and its design; re-pointing any call site (MOTIR-2256); custom roles (MOTIR-2257); hiding surfaces by permission (MOTIR-2258).
Verification recipe
This story has no user-visible surface by design — that is what makes it safe, and it is why the page is a separate story. It is verified by its tests rather than by a click-path:
pnpm test tests/permissions/— the 64-row parity truth table passes: for every (accessLevel×workspaceRole×projectRole), each of the eleven predicates returns the same answer as the pre-story policy. Green means no actor's capabilities changed.- The catalog guards: no duplicate key,
resource:actionshape, every domain non-empty, both i18n catalogs total over all 32 keys, the module importing no Prisma and doing no IO. - The inventory coverage guard: every route and Server Action named in
docs/decisions/permission-inventory.mdwith a decided policy and a cited reason. - The no-ungoverned-operation guard: every actor-initiated operation either gated or carrying a justified inventory decision.
- Read
docs/decisions/permission-inventory.md— the map, and the product decisions it records (a dashboard is workspace-scoped; the v1 API inherits its in-app twin's permission;_testroutes are a logged finding).