Assigning a custom role — the Members picker, the `PATCH` that accepts a role definition, and the chip that names it
A role nobody can be put on is a definition, not a role. This card is the step that makes an authored role take effect for a real person: Project settings → Members, change a teammate's role, and the resolution they get on their next request is the one the admin composed.
The three layers it touches
- The picker.
ProjectMembersSettings's per-row roleComboboxis built fromPROJECT_ASSIGNABLE_ROLEStoday — three hardcoded built-ins. It is built from the project's role catalog instead: the three built-ins, then its custom roles, each labelled and each carrying the kind in words as well as in its tint. The list comes from the server page's existing read and is passed down as props — the client never fetches a role list of its own. - The write.
PATCH /api/projects/[key]/members/[userId]accepts a role definition alongside a built-in role, andprojectMembersService.setRolewrites it throughprojectMembershipRepository.setRoleDefinition, which sets the pointer and theroletier together. Assigning a built-in clears the pointer through the same method. This service is the only single-member assignment path; the bulk move that happens when a role is deleted belongs to the role service, and neither reimplements the other. - The chip. A member row wearing a custom role shows that role's name in the custom tint, beside built-in rows that are unchanged — the amended
access-membersasset owns that grammar.
The one rule the assignment adds
A role definition must belong to this project. A cross-project or cross-workspace id is refused before any write, with the same 404-not-403 posture the members routes already take, so the endpoint cannot be used to test whether a role in another workspace exists.
Scope boundary
In: the picker, the route and service change, the repository call, the member-row chip, and the en + zh strings. Out: the role EDITOR and its doors; Delete and its dialog; the bulk reassignment a delete performs; the resolution itself, which the resolution card owns and this card only makes reachable; any change to how members are added or removed.
Acceptance criteria
- The per-row role picker lists the three built-ins followed by the project's custom roles, in the same deterministic order the role list uses; a project with no custom roles renders exactly the picker that ships today.
- Choosing a custom role persists it, the row's chip updates to that role's name in the custom tint, and the change survives a reload — asserted by reading the row back through the members DTO, not the database.
- Choosing a built-in for a member who held a custom role clears the pointer and sets the tier, so no membership is ever left pointing at a role it does not hold.
- The assignment takes effect end to end: an integration test assigns a custom role that withholds
sprint:manage, then resolves that member's permissions throughprojectAccessService.getPermissionsand shows the key is absent — the read-back proves the seam rather than the write. - A role definition id belonging to another project or workspace is refused with 404, before any write, and a test drives it under a foreign workspace context.
- Only an actor holding
project:manage_accessmay change a role — the existing gate, re-asserted rather than re-implemented; the picker is read-only for everyone else exactly as it is today. projectMembersServicewritesrole_definition_idonly throughprojectMembershipRepository.setRoleDefinition; a guard test asserts no second write path to that column exists inlib/services/.- The members DTO carries what the chip needs — the role's name and whether it is built-in — and the existing members tests are extended rather than replaced.
- Every new string is in
messages/en.jsonandmessages/zh.json.
Context refs
app/(authed)/settings/project/members/_components/ProjectMembersSettings.tsx—roleOptions,changeRole, the row chip, and the read-only degradation the picker already implements.app/api/projects/[key]/members/[userId]/route.ts·lib/services/projectMembersService.ts— the shippedPATCHand itsassertPermission(…, 'project:manage_access')gate.lib/projects/roles.ts—PROJECT_ASSIGNABLE_ROLESandasProjectRole, the constants the picker is built from today.design/projects/access-members.mock.htmland the amendment adding the custom-role picker and chip;design/projects/design-notes.md§ Roles & permissions (Story 6.4) for the chip grammar.- The schema card —
setRoleDefinitionand the paired-column invariant; the read card — the catalog the picker is built from; the resolution card — what makes the assignment mean anything; the design amendment.