The resolution arm — a membership on a custom role resolves THAT role's set, with both rails and the access-level subtraction untouched
The one change that makes a custom role mean anything. lib/permissions/resolve.ts is the whole project access policy expressed once as a function from three resolved facts to a permission set; this card gives it a fourth fact and one new arm, and changes nothing else about it.
The change
ProjectPermissionInputs gains a nullable custom role permission set. In resolvePermissions the only line that moves is the one that picks the BASE:
const base = customRolePermissions ?? (projectRole is a ProjectRole
? BUILTIN_ROLE_PERMISSIONS[projectRole]
: IMPLICIT_WORKSPACE_MEMBER_PERMISSIONS);
Everything above and below it stays exactly as it is, and each of those is load-bearing:
- The level-gated grants stay layer 1. A
publicproject'spublic_request:*keys are decided by the access level for every actor; no role, custom or not, can hold or withhold them. - The workspace-manager rail stays layer 2 and stays ABOVE the custom set. An owner or admin holds the whole role-gated catalog on every access level and returns early — a custom role can never narrow them, which is what keeps a project's own admins from being locked out by a role somebody authored.
- The null-deny rail stays layer 3. Someone outside the workspace holds nothing beyond layer 1, whatever a project role says.
levelGrantsis not touched at all. It names onlywork_item:edit,comment:addandattachment:create; every other key takes the default arm. Because a membership on a custom role carriesrole = CUSTOM_ROLE_TIER(member), thelimitedandprivatesubtractions take NOTHING away from a custom role — it grants exactly what it lists, on every access level (Yue, 2026-08-09). That is deliberate: the tier subtraction narrows the COARSE built-ins, and a set an admin enumerated by hand is not coarse. Adding a branch here for a custom role would re-introduce exactly that second-guessing; do not.
The IO half is resolveInputs in lib/services/projectAccessService.ts: it already reads the project, the workspace membership and the project membership. It gains the role definition for a membership that has one — through the existing tx when a caller supplies one, so the snapshot and the RLS workspace GUC are shared. getPermissions, getPermissionsDTO, assertPermission, assertCanManage and every assertCan* predicate in lib/projects/access.ts inherit the new behaviour with no signature change, because they all go through this one resolve.
Scope boundary
In: the resolvePermissions arm, the resolveInputs read, and the tests that prove both. Out: any change to levelGrants, to either rail, or to BUILTIN_ROLE_PERMISSIONS / IMPLICIT_WORKSPACE_MEMBER_PERMISSIONS; the five getXCapabilities methods, which the permission-gated UI story re-points when it delivers the set to the client; anything that WRITES a role definition or a membership pointer.
Acceptance criteria
resolvePermissionsreturns a membership's custom role set as the base when one is present, and the built-in / implicit set when it is not — with the level-gated layer, both rails andlevelGrantsbyte-identical to what shipped.- A workspace owner or admin resolves the full role-gated catalog even when their project membership points at a custom role that grants almost nothing — the rail wins, asserted directly.
- An actor with no workspace membership resolves nothing beyond the level-gated layer even with a custom role attached — the null-deny rail wins, asserted directly.
tests/permissions/accessParity.test.tsstill passes UNCHANGED: with no custom roles in play the resolved answer is identical for all 64 actors. A card that has to edit that table has changed behaviour it was not asked to change.- A new truth table extends the parity proof across the access levels for a membership on a custom role, showing that every level leaves its set intact — and its mirror asserts a BUILT-IN role is still narrowed exactly as before, so the change cannot leak past custom roles.
- A permission in a stored role's array that is not in
ROLE_GATED_PERMISSIONS— a key retired from the catalog after the role was authored — is ignored by the resolution rather than granted, and a test pins that: the catalog is the source of truth over a stored array, and stale data may never widen access. resolveInputsreads the role definition in the same round trip and the sametxas the membership, so a caller inside a transaction shares the snapshot and the workspace GUC; a test drivesassertPermissioninside a transaction and shows no extra connection is used.lib/permissions/resolve.tsstays pure — no Prisma import, no IO — and a test imports it in a bare context to prove it.- The integration tests in
tests/permissions/getPermissions.integration.test.tsgain the custom-role cases against real Postgres, including one under a foreign workspace GUC showing the definition is invisible and the actor resolves as if it did not exist.
Context refs
lib/permissions/resolve.ts—ProjectPermissionInputs,resolvePermissions, its three layers, andlevelGrantswith its "deliberately unchanged" note.lib/services/projectAccessService.ts—resolveInputs,getPermissions,getPermissionsDTO,assertPermission.lib/permissions/builtinRoles.ts—BUILTIN_ROLE_PERMISSIONS,IMPLICIT_WORKSPACE_MEMBER_PERMISSIONS,ROLE_GATED_PERMISSIONS.lib/projects/access.ts— the eleven predicates that inherit this with no change.tests/permissions/accessParity.test.ts— the 64-row table that must stay green;tests/permissions/getPermissions.integration.test.ts— the integration suite this extends.- The schema card — the table this reads and the paired
role/roleDefinitionIdinvariant this depends on. - The card that introduced
getPermissionsand the one that expressed roles as sets.