`getRoleCatalog` returns the project's OWN roles — the widened `RoleDTO`, the provenance, and the two shipped screens rendering them read-only
The read the Roles & permissions screens already use, grown to carry the project's own roles. getRoleCatalog was deliberately built as a project-scoped SERVICE read rather than a static import, with its own note saying "MOTIR-2257 will read the project's own roles here, which is the reason the projectId is threaded at all" — this is that card. The two screens likewise resolve a role by looking it up in catalog.roles, so a custom role reaches the detail page with no routing change.
The DTO widens, and that is the ripple
RoleDTO today is built-in-shaped: role: ProjectRole doubles as identity and URL segment, and labelKey / descriptionKey assume every name is an i18n string. A custom role has a name a human typed. So:
key: stringreplacesroleas the identity and the[roleKey]URL segment —admin/member/viewerfor a built-in, the definition's id for a custom role.builtInRole: ProjectRole | nullcarries the enum where it still exists, which is what the icon map and the tint choice key off.labelKey/descriptionKeybecome nullable, andname/descriptionare added — a built-in carries the i18n keys, a custom role carries the literal strings. The client renders whichever is present, because a built-in's copy must stay translatable and an author's name must never be run through a translation lookup.- No
basedOnand no±Ndelta (Yue, 2026-08-09). An earlier revision carried both to feed aBased on Member · −2chip; nothing records which built-in seeded a role, so neither the DTO nor the screens have one. A role'sN of Mis the comparison that survives. builtIn,permissions,memberCount,roleGatedPermissionCountandlevelGatedDomainskeep their present meaning.
Ordering is deterministic: the three built-ins in their canonical order, then the project's custom roles by name. A list whose order depends on insertion reshuffles between requests for no reason a reader can see, which is the same argument the DTO already makes about permission order.
Member counts stay ONE grouped read. The shipped countByRole covers built-in memberships; countByRoleDefinition covers custom ones. Two grouped reads for the whole catalog, never one query per role — and both under the same workspace context the shipped call already establishes.
Scope boundary
In: lib/dto/permissions.ts, lib/mappers/permissionMappers.ts, projectAccessService.getRoleCatalog, and the mechanical adaptation of RoleList, RoleDetail, PermissionGroups and the [roleKey] page to the widened DTO so they render a custom role correctly read-only — with the Custom chip and the Based on … provenance chip and their en + zh strings. Out: every WRITE affordance — Create role, Edit, Delete, the delete dialog and the cap state are the next card's, and this one adds no button; the editor page; the Members picker; any change to which permissions a role holds.
Acceptance criteria
getRoleCatalog(projectId, ctx)returns the three built-ins followed by that project'sProjectRoleDefinitionrows mapped toRoleDTO, in the deterministic order above; a project with no custom roles returns exactly what it returns today, asserted against the existing expectations rather than rewritten ones.- The gate still runs before any read: a project in another workspace raises
ProjectNotFoundError, and its custom roles are never counted or returned — asserted under a foreign workspace GUC, not only by a service-level check. - Every role's
memberCountis real, from two grouped reads for the whole catalog; a test with three custom roles asserts the query count does not scale with the number of roles. - A custom role's
basedOnand its±Ndelta are on the DTO and computed in the mapper againstBUILTIN_ROLE_PERMISSIONS; a component never derives it. RoleListandRoleDetailrender a custom role: its literal name and description, theCustomchip in the tint the design pairs with it, itsN of M permissionsand its member count — with the tile glyph taken from panel 0 of the mock rather than chosen fresh, and the built-in rows visually unchanged. No role renders aBased on …chip, which a test pins.- The
ROLE_ICONmap keyed byProjectRoleno longer breaks totality — a custom role has a defined tile treatment, and the code does not index aRecord<ProjectRole, …>with a string that may not be one. /settings/project/roles/[roleKey]resolves a custom role by its id and 404s an unknown segment, with no change to how the page looks the role up.- The new i18n key — the
Customchip — lands inmessages/en.jsonandmessages/zh.json; the catalog-parity test stays green. - Both screens stay read-only for every actor, including a project admin: nothing in this card renders a control.
- The existing screen tests (
tests/settings/rolesPermissionsScreens.test.tsx) and mapper tests (tests/permissions/roleCatalogMappers.test.ts) are extended rather than replaced, and the custom-role cases are added totests/permissions/getPermissions.integration.test.ts'sgetRoleCatalogblock.
Context refs
lib/services/projectAccessService.ts—getRoleCatalogand its two notes on why it is project-scoped and why the gate runs before the count.lib/dto/permissions.ts—RoleDTO,RoleCatalogDTO,PermissionDomainDTOand the catalog-order rule;lib/mappers/permissionMappers.ts—toRoleCatalogDTO.app/(authed)/settings/project/roles/_components/RoleList.tsx·RoleDetail.tsx·PermissionGroups.tsx·app/(authed)/settings/project/roles/[roleKey]/page.tsx— the four consumers, includingROLE_ICONand the AA-driven ink rules inRoleList's header comment.design/projects/roles-permissions.mock.htmlpanels 0 and 2 + thedesign-notes.md§ Primitives composed — the custom-role tile, theCustomchip tint and the provenance chip.lib/repositories/projectMembershipRepository.ts—countByRoleand thecountByRoleDefinitionthe schema card adds.messages/en.json/messages/zh.json— thesettings.rolesPageandsettings.rolesnamespaces.- The card that grew this read last and the screens it feeds; the schema card supplying the rows.