`projectRoleDefinitionService` — create, rename, re-permission and delete-with-reassign, its caps, its locks and its typed refusals
Every rule about what a custom role may be lives here, in one service, so no route and no component re-implements a policy. It owns the transactions and the DTO mapping; the repositories underneath it are single-op leaves and the routes above it are HTTP-only, per the four-layer split.
The operations
create— name and permission set. No base: the editor'sStart frompick seeds the grid in the browser and is never sent (Yue, 2026-08-09). Gated onproject:manage_accessviaprojectAccessService.assertPermission(the keyprojectMembersServicealready gates add-member and set-role on — a role definition IS project access, and this story adds no catalog key).rename/setPermissions— the same validation on the same fields, amending in place.delete— with the reassignment, below.
The rules each operation enforces
- A permission must be in the ROLE-GATED,
enforcedset. Derived fromROLE_GATED_PERMISSIONSandisEnforced, never a literal list — the whole point of theenforcementmarker is that a key no gate consults must never become a switch that controls nothing. TodayPLANNED_PERMISSIONSis empty, so this refuses nothing in practice; it is written from the constants so that the next planned key is refused with no code change. A request naming a level-gatedpublic_request:*key is refused for the same reason: no role can hold one. - A name is trimmed, non-empty and bounded, and unique within the project. The unique index raises
P2002; the service catches it and rethrows a typedRoleNameTakenError, so a raw database error never escapes. - The cap is a COUNT-THEN-CREATE, so it must lock. A plain read-then-write races — two concurrent creates both read
nand both insert, and the project ends up over its cap. Count inside the transaction with the project row lockedSELECT … FOR UPDATEand re-read before inserting, and the test must drive genuine concurrency rather than two sequential calls.lib/permissions/limits.tsholdsMAX_CUSTOM_ROLES_PER_PROJECTand the name bound as pure constants — no Prisma import — so the editor can render the cap state from the same source of truth the service enforces, exactly aslib/customFields/limits.tsdoes for fields. - A built-in is not a row and cannot be edited.
admin/member/viewerlive inlib/permissions/builtinRoles.ts; any operation naming one is refused with a typedBuiltInRoleImmutableErrorrather than a not-found, because the caller asked for something meaningful and impossible. - Delete refuses to strip anybody.
countByRoleDefinitionfirst: if it is zero, delete. If it is not and no destination was given, throwRoleInUseErrorcarrying the count — the cue the dialog reads to name how many people are affected. If a destination was given, reassign then delete inside one$transaction, so the two can never half-happen; the destination may be another custom role or a built-in, must belong to the same project, and may not be the role being deleted. The database'sRestrictforeign key is the backstop underneath this, not a substitute for it. - No external side effect runs inside a transaction — there are none in this card, and none may be added inside one.
Scope boundary
In: the service, lib/permissions/errors.ts, lib/permissions/limits.ts, the DTO mapping for a single role definition, and their tests including the real-concurrency cap test. Out: the HTTP routes and their status-code mapping, which are their own card; getRoleCatalog, which is the read the screens use and its own card; the resolution, which reads these rows but is written by the resolution card; assigning a role to a MEMBER, which is projectMembersService's path and the Members card's; any direct Prisma access — every write goes through the repository leaves.
Acceptance criteria
lib/services/projectRoleDefinitionService.tsexposescreate,rename,setPermissions,deleteandfindById, each taking the actor context and each callingprojectAccessService.assertPermission(projectId, ctx, 'project:manage_access')before doing anything else; a non-admin project member is refused and a cross-workspace project id raisesProjectNotFoundError, never a 403-shaped error.- A create or update naming a key outside the role-gated,
enforcedset is refused with a typed error naming the offending key — and the check readsROLE_GATED_PERMISSIONS+isEnforced, asserted by a test that adds a synthetic non-enforced key rather than by a hardcoded list. - A duplicate name within a project raises
RoleNameTakenError, notP2002; the same name in a DIFFERENT project succeeds. - The cap holds under real concurrency: a test firing
MAX_CUSTOM_ROLES_PER_PROJECT + 1creates simultaneously against a warm pool ends with exactly the cap stored, the surplus refused with the typed limit error, and accepts either legitimate interleaving. rename,setPermissionsanddeleteonadmin/member/viewerare refused withBuiltInRoleImmutableError— including when the identifier arrives from an untrusted string.deletewith members and no destination throwsRoleInUseErrorcarrying the member count and writes nothing.deletewith a destination moves every membership and removes the role in one transaction, and a test asserts that a failure injected after the reassign leaves both the memberships and the role unchanged.- A destination that is the role being deleted, or belongs to another project, is refused before any write.
- Every membership moved by a delete lands with its
role_definition_idandroleconsistent, throughprojectMembershipRepository.reassignRoleDefinition— this service writes that column through no other path. lib/permissions/limits.tsis a pure-constant module with no Prisma import, importable from a client component.
Context refs
lib/services/workflowsService.ts—deleteStatus,reassignToStatusIdandStatusInUseError: the shipped delete-with-reassign this mirrors, including the no-target-throws-with-a-count cue.lib/services/customFieldsService.ts·lib/customFields/limits.ts·lib/customFields/errors.ts— the caps posture (FieldLimitReachedError), the P2002 translation, and the pure-constants module shape.lib/services/projectMembersService.ts— itsassertPermission(…, 'project:manage_access')call, the gate this reuses.lib/services/projectAccessService.ts—assertPermissionand its 404-not-403 ordering.lib/permissions/catalog.ts—isEnforced,ENFORCED_PERMISSIONS,isPermissionKey;lib/permissions/builtinRoles.ts—ROLE_GATED_PERMISSIONSand the three immutable sets.motir-core/CLAUDE.md— the four-layer split, the lock-before-a-read-derived-write rule, and the side-effects-outside-the-transaction rule.- The schema card — the repository leaves and the
RestrictFK backing the delete rule.