`getRoleCatalog` grows what the drill-down actually reads — the role-gated row set, the level-gated card beside it, the per-role member count, and the `N of M` the list row shows
Widen the read behind the Roles & permissions screens so the page card can render the asset MOTIR-2259 shipped. Carved out of the page card on 2026-08-07 (see the story's re-plan note): the design draws values the shipped DTO cannot express, so the page has no data source for its own list screen.
⚠️ AMENDED 2026-08-08, mid-run, by the card's own implementation. A FOURTH gap of the same class surfaced while reading the mock's panel 0 against this card: the list screen draws a level-gated card beneath the roles, and nothing produced its rows either. This card's original text NAMED those keys — "the level-gated
public_request:*keys … which get their own card" — and then assigned that card no producer, which is precisely the shape the planning bug records. Fixed here rather than in the page card, because the page card's scope boundary forbids it to touchlib/dto,lib/mappers,lib/repositoriesorlib/services— and the alternative (a settings component importing the catalog to decide which keys are level-gated) would put a second definition of "what the screen means" in the UI layer, the exact thing this card's rationale argues against.
The four gaps, each read off origin/main at 1273ff32
- The row set is the ROLE-GATED set, not the whole catalog.
lib/mappers/permissionMappers.ts'stoPermissionDomainDTOs()callspermissionsByDomain()with no options, which returns every key inPERMISSIONS— 31 today. The screens draw the role-gated set (ROLE_GATED_PERMISSIONSinlib/permissions/builtinRoles.ts, 28 keys today). The difference is the level-gatedpublic_request:*keys, which no role can hold or withhold — rendering them as role rows would draw a permission nobody can ever be granted.permissionsByDomainoffersinclude: 'enforced' | 'all'and no role-gated arm. RoleDTOcarries no member count. The list row reads3 membersbeside each role.projectMembershipRepositoryhascountAdminsand no grouped-by-role count; nothing else inlib/services/projectAccessService.tsexposes one.- Nothing carries the
MinN of M. The row reads10 of 28 permissions.NisRoleDTO.permissions.length;Mis the size of the role-gated set, which the client must not re-derive by importing the catalog — the whole point of the service read is that MOTIR-2257 makes the answer project-scoped. - Nothing carries the LEVEL-GATED card's rows (added 2026-08-08). Panel 0 of
roles-permissions.mock.htmlends in aPublic requestscard — the mock's own CSS comment calls it "the level-gated card — three rows, kept OUT of the role list" — explaining that those grants come from the project's access level rather than any role. Without it the screen answers "what can a role do" and silently drops three permissions the product does govern, which is the under-description the epic exists to fix.
Filter by the CONSTANT, never by a literal. ROLE_GATED_PERMISSIONS grew from 20 to 28 when MOTIR-2349 added the eight member-facing keys; a hardcoded 28 would have been wrong then and right by accident now.
Scope
In: the DTO shape, the mapper (both groupings), the grouped membership read, and the getRoleCatalog thread-through, with their unit + integration tests.
Out: the page and its components (MOTIR-2263); custom roles making the catalog genuinely project-scoped (MOTIR-2257); deciding or wiring the eight member-facing keys (MOTIR-2291). This card changes no permission's grant and no role's set.
Acceptance criteria
permissionsByDomaingains a role-gated arm (aninclude: 'role_gated'option or equivalent) that narrows toROLE_GATED_PERMISSIONS, drops domains left empty by the filter, and preserves catalog order; the existing'enforced'and'all'arms are unchanged.- ⚠️ The arm must NOT make
lib/permissions/catalog.tsimport anything.tests/permissions/catalog.test.tsasserts "has no import at all outside the module (it is a leaf)", so a named'role_gated'arm — which would have to importROLE_GATED_PERMISSIONSfrombuiltinRoles.ts— is the one shape that is closed. Take the key SET as the option's value and let the mapper pass the constant; do not relax the purity guard. PermissionDomainDTOgroups produced for the role screens contain exactly the keys inROLE_GATED_PERMISSIONS, asserted against that constant rather than a literal count, so the assertion stays true when the constant grows.RoleCatalogDTOALSO carries the level-gated groups the role rows leave out, derived as the complement of the role-gated set over the catalog rather than a second hand-written key list; the two groupings together cover every catalog key exactly once, asserted.RoleDTOcarries amemberCount, resolved from a single grouped read overProjectMembershipfor the project — not one query per role.RoleCatalogDTOcarries the role-gated total the list row'sN of Mneeds, so no client re-derives it by importing the catalog.projectMembershipRepositorygains the grouped count as a single-Prisma-op leaf taking an optionaltx, matching the 4-layer contract inmotir-core/CLAUDE.md; a role with zero members resolves to0, never a missing key.getRoleCatalogreturns the widened DTO through its existing 404-not-403 gate, and the integration test proves a project in another workspace still raisesProjectNotFoundErrorbefore any count is read.- An integration test on real Postgres seeds a project with a membership at each role and reads the counts back through
getRoleCatalog, asserting the numbers against the seeded rows. - Per-file coverage on every file this card changes meets the ≥90% floor.
Context refs
lib/permissions/builtinRoles.ts—ROLE_GATED_PERMISSIONSandBUILTIN_ROLE_PERMISSIONS, the row set and the marks.lib/permissions/catalog.ts—permissionsByDomain,PERMISSIONS,sortByCatalogOrder.lib/dto/permissions.ts—RoleDTO,RoleCatalogDTO,PermissionDomainDTO.lib/mappers/permissionMappers.ts—toRoleCatalogDTO,toBuiltinRoleDTO,toPermissionDomainDTOs.lib/services/projectAccessService.ts—getRoleCatalogand itsresolveInputsgate.lib/repositories/projectMembershipRepository.ts— where the grouped count lands;countAdminsis the nearest existing shape.lib/repositories/workItemRepository.tsis agroupByprecedent in this layer.design/projects/design-notes.md— the Roles & permissions section; the list row'sN of 28 permissions+3 members, the 28-row table, and the level-gated card.- The design · the page that consumes this · the service read this widens.