Grow the catalog to the inventory — the full key set, its domains, and the `enforcement` marker
Take the inventory's map and make it the catalog. lib/permissions/catalog.ts currently holds eleven keys — the ones the catalog card derived from the eleven predicates in lib/projects/access.ts. This card grows it to every key the inventory justifies, and adds the one field that lets it do so honestly.
The shape
-
The full key set + domains. Every operation the inventory maps to a permission gets its key, in the same
resource:actionform, under the domain the inventory assigned it. The MOTIR-2260 guards (no duplicates,resource:actionshape, every domain non-empty, both i18n catalogs total) all still apply — now over the larger set, unchanged in strength. -
⚠️
enforcement: 'enforced' | 'planned'on every entry. This is what resolves a real contradiction between two cards that are each individually right:- The story test gate guards that every
PermissionKeyhas a production consumer outsidelib/permissions/*— the ORPHAN guard, which is what stops a key existing that nothing enforces. - Re-pointing the ~150
assertCan*call sites onto the new keys is the per-domain split's job, deliberately not this story's. - So every key this card adds would be an orphan and turn that guard red.
Resolve it in the MODEL rather than by weakening the guard:
enforcedmeans a gate consults it today,plannedmeans the inventory justified it and the split has not wired it yet. The orphan guard then keeps full strength overenforced— nothing shipped loses protection — andplannedbecomes a commitment recorded in typed code instead of in a document that rots. - The story test gate guards that every
-
A
plannedkey is never offered to a user. It is excluded fromgetRoleCatalog, so it cannot appear in the grid or, later, in the role editor. This is what keeps MOTIR-2260's principle intact across the gap: a switch never appears for something the code does not enforce. -
The built-in role sets are re-derived over the grown catalog so that no actor's capabilities change. Admin holds every
enforcedrole-gated key; Member and Viewer keep exactly what they hold today. The MOTIR-2261 parity truth table must pass unchanged — it is the proof that growing the vocabulary changed no behaviour.
Scope boundary
In: the catalog growth, the domains, the enforcement field, the i18n for every new key in both catalogs, the orphan-guard amendment, the role-set re-derivation.
Out: the inventory itself (the previous card); the no-ungoverned-operation guard (the next one); re-pointing any assertCan* call site (the split) — which is exactly why planned exists.
Acceptance criteria
lib/permissions/catalog.tsholds a key for every operation the inventory maps to one, each carrying its domain, itsenforcementvalue, and its two i18n keys.- Every new key has a label AND a description in both
messages/en.jsonandmessages/zh.json; the shipped i18n parity test and the MOTIR-2260 totality guards pass unchanged over the larger set. - The orphan guard requires a production consumer for every
enforcedkey, and is green. A test proves it still FAILS when anenforcedkey is deliberately left unconsumed. - A test pins the
plannedset against the inventory document, so a key cannot be parked asplannedwithout a row justifying it. getRoleCatalogreturns noplannedkey — asserted directly, so an unenforced permission can never render as a switch.- The three built-in role sets are re-derived over the grown catalog and the MOTIR-2261 parity truth table passes unmodified — all 64 rows, all eleven predicates, no actor's capabilities changed.
- Per-file coverage on every changed module meets the ≥90% floor.
Context refs
- The inventory — the map this implements; its domain set is this card's grouping.
lib/permissions/catalog.ts·lib/permissions/builtinRoles.ts— what grows.tests/permissions/catalog.test.ts— the MOTIR-2260 guards that must keep passing.tests/permissions/accessParity.test.ts— the 64-row truth table that must pass UNMODIFIED.- The story test gate — owns the orphan guard this card amends.