The permission CATALOG — `lib/permissions/catalog.ts`, its domain groups, and the totality guards that keep it honest
Add lib/permissions/catalog.ts — the single, code-owned, exhaustive list of the permissions Motir enforces. This card ships the vocabulary and nothing that consumes it; the policy re-expression is the next card.
Pure data and pure helpers, no IO — the same shape as lib/projects/access.ts and lib/mcp/scopes.ts, so it imports cleanly from the server, the client and a test.
The shape
PERMISSIONS— a frozen tuple of keys inresource:actionform, the mirror's convention (Plane:workitem:edit; Jira's permission names read the same way). The set covers exactly what the eleven shipped predicates inlib/projects/access.tsdecide today, one key per predicate, plus the umbrellaproject:administer:project:browse·work_item:edit·comment:add·comment:moderate·attachment:create·attachment:delete_any·watcher:manage·project:administer·public_request:submit·public_request:upvote·public_request:comment. Deriving the initial list from the shipped predicates rather than from the mirror's full catalog is deliberate — a key with no enforcement point behind it is a promise the product cannot keep.PermissionKey— the union type derived from the tuple, so every consumer is exhaustive by construction.- A
DOMAINfor each permission — the group the UI renders it under (project,work_item,comment,attachment,watcher,public_request), plus i18n label and description keys under apermissions.namespace inmessages/en.jsonandmessages/zh.json. - Helpers:
isPermissionKey(value)narrowing an untrusted string;permissionsByDomain()returning the render order.
Naming note, so a later reader does not merge two vocabularies
lib/mcp/scopes.ts deliberately says "scopes, NOT permissions" — an API-token scope NARROWS its owner's role and is a separate axis. That comment is still correct after this card and gets more so; this card does not touch that module, and the two vocabularies stay distinct.
Acceptance criteria
lib/permissions/catalog.tsexportsPERMISSIONS, thePermissionKeytype, the per-permission domain + i18n keys,isPermissionKeyandpermissionsByDomain.- Every key in
PERMISSIONShas a label and a description key present in BOTHmessages/en.jsonandmessages/zh.json— a Vitest guard asserts both catalogs, so a key added without its Chinese twin fails. - A Vitest guard asserts the catalog is a set: no duplicate key, every key matching the
resource:actionshape, every key carrying a domain that appears in the domain order. - A Vitest guard asserts every domain in the render order has at least one permission, so an emptied domain cannot survive as a heading with nothing under it.
- The module imports no Prisma client and performs no IO — asserted by the existing import-boundary guard pattern used elsewhere in
lib. - Per-file coverage on the new module meets the project's ≥90% branch/function/line floor.
Context refs
lib/projects/access.ts— the eleven shipped predicates the initial key list is derived from, one key each.lib/mcp/scopes.ts— the frozen-tuple + derived-union +is…narrowing pattern this module follows, and the module whose header records why scope and permission are separate words.messages/en.json,messages/zh.json— the two catalogs every new key needs an entry in.- The parent story — the model this vocabulary serves.