The actor's permission SET reaches the client — `ProjectAccessProvider` carries the resolved set and a `can(key)` reader
Every client-side affordance decision the product makes today is made from two booleans. app/(authed)/_components/ProjectAccessProvider.tsx carries { canEdit, canManage }, resolved in app/(authed)/layout.tsx by projectAccessService.getSettingsCapabilities. Two bits cannot express a role that holds board:configure and not member:manage, so every sibling card in this story is waiting on the same thing: the actor's real permission SET, in the client tree, once per request.
The read already exists. The permission model story shipped projectAccessService.getPermissions and its serialisable getPermissionsDTO — an array in catalog order, because a Set cannot cross to a client island — and deliberately left the five getXCapabilities methods alone, noting that re-pointing them belongs with the surfaces that consume the set. This card is that re-pointing, for the one call the shell makes.
The approach, and why it changes no pixel
lib/projects/access.ts is already a thin layer over the set: canBrowse is hasPermission(i, 'project:browse'), canEdit is hasPermission(i, 'work_item:edit'), canManageProject is hasPermission(i, 'project:administer'). So the three booleans the layout spends a round trip on are derivable from the set by construction, and swapping the source is provably behaviour-neutral rather than merely believed to be.
- The layout calls
getPermissionsDTOinstead ofgetSettingsCapabilities— oneresolveInputsround trip, exactly as today. Not both: two calls would double the trip for no gain. ProjectAccessProvidertakes the key array and exposescan(key).canEditandcanManagestay on the context value, derived from the set, so not one of the ~15 components readinguseProjectAccess()changes in this PR — retiring them is the affordance sweep's job, deliberately separated so this card is reviewable as a pure substitution.- The
settingsAccesspropSidebarNavreads keeps its shipped{ canBrowse, canManage }shape here, derived the same way. The registry's predicate is the settings-registry card's to change. - The no-provider fallback must keep its shipped direction.
useProjectAccess()outside a provider answers{ canEdit: true, canManage: true }today, so that a component mounted in a unit test keeps its pre-gating behaviour — the gate only ever TIGHTENS. The set-based reader inherits that: absent provider ⇒can()is true.
Scope boundary
In: app/(authed)/layout.tsx, app/(authed)/_components/ProjectAccessProvider.tsx, and their tests. Out: every consumer of useProjectAccess() (unchanged here by design), the settings-nav registry, the command palette, the project nav, and any server-side gate — this card adds no enforcement and removes none.
Acceptance criteria
ProjectAccessProvideraccepts the actor's permission keys and exposescan(key: PermissionKey): boolean;canEditandcanManageremain readable fromuseProjectAccess()and are DERIVED from the set rather than passed separately.- The authed layout resolves the actor's permissions with
getPermissionsDTOand makes exactly oneprojectAccessServicecall for the active project: a grep forgetSettingsCapabilitiesinapp/(authed)/layout.tsxreturns nothing. - A test proves the substitution is behaviour-neutral: across every combination of project access level × workspace role × project role,
{ canBrowse, canEdit, canManage }derived from the resolved set equals whatprojectAccessService.getSettingsCapabilitiesreturns for the same actor. useProjectAccess()with no provider present answerstruefor every key, preserving the shipped tighten-only fallback; a test asserts it.- The permissions cross the server/client boundary as the shipped
ActorPermissionsDTOarray in catalog order — deterministic ordering, noSetin a prop. - No component outside the two files above is modified:
git diff --name-onlylists only the layout, the provider, and test files. - The rendered shell is unchanged for all three built-in roles: the settings rail, the palette and every affordance render identically to the branch's own merge base — compare the branch against that baseline, which is a check this PR can run before it lands.
- The new and changed lines meet the repo's per-file coverage floor.
Context refs
app/(authed)/_components/ProjectAccessProvider.tsx— the provider being widened, and its documented tighten-only fallback.app/(authed)/layout.tsx— the single settings-capabilities round trip, around line 110.lib/services/projectAccessService.ts—getPermissions,getPermissionsDTO,getSettingsCapabilities, and the note that re-pointing the capability methods belongs to this story.lib/dto/permissions.ts+lib/mappers/permissionMappers.ts—ActorPermissionsDTOand its mapper.lib/projects/access.ts— the three predicates whose equivalence to the set makes this substitution provable.- The permission model story — the story that shipped the read this card consumes.