The project nav and the ⌘K actions gate on the permission their destination needs — through one shared map, not two lists
The settings area is not the only place the shell offers rooms an actor cannot enter. The project nav lists ten destinations and the command palette lists its own set of navigations and actions, and both are gated today on nothing more than "is there an active project". A viewer is offered Plans, whose page answers with the no-access state; the palette offers deep links to the same.
The approach
- One shared map, consumed twice. The sidebar's project rows and the palette's navigation entries are two independent arrays that already overlap, and gating them separately guarantees they drift — the same failure the settings registry exists to prevent one area over. Introduce a single exported map from nav destination to its required
PermissionKey(or an explicit browse-only marker), and have both surfaces resolve through it. This is a gating registry, not a rewrite of either nav: the rows, their icons, their order and their labels stay where they are. - Every pairing is READ off the destination, not guessed. For each row, open the destination and grep the key its own page or service asserts —
plansServicefor Plans, the triage page's own gate for Triage,estimationService/ the reports pages for Reports, and so on. A destination with no gate beyond browse is marked browse-only and stays visible; that is a legitimate answer and must be recorded rather than back-filled with a plausible key. - Hide versus disable is the decision's call, not this card's. Follow the treatment table from the hide-versus-disable decision — this card CONSUMES that table, which is why it waits on it. Note in particular that the 2026-06-09 directive it amends puts the palette's Create action in the visible-but-disabled family alongside the Create button and the
Cshortcut — so this card does not hide it, and any action the table marks DISABLE keeps its shipped treatment. - Panel 4 of the design asset decides what happens to the gap a removed row leaves and whether section separators survive.
Scope boundary
In: the shared nav-permission map and its tests, the project-nav rows in app/(authed)/_components/SidebarNav.tsx, and the palette's own navigation and action entries in app/(authed)/_components/AppCommandPalette.tsx.
Out: the settings rail, the settings deep-link block and the settings-area door, all of which the settings-registry card owns and this card must not re-decide; the destination pages themselves (the destination-guard card); the work-item action menus and in-place controls (the affordance sweep); every service and route gate.
Acceptance criteria
- A single exported map pairs every project-nav destination and every palette navigation entry with its required
PermissionKeyor an explicit browse-only marker; bothSidebarNavandAppCommandPaletteresolve through it and neither holds a second gating list. - The map is TOTAL over both surfaces: a test enumerates the rows each surface renders and fails if one resolves to no entry, so a row added later cannot ship ungated by omission.
- Every non-browse-only pairing is justified in the PR body by the grep that produced it — the file and the line where that destination asserts the key.
- A row whose destination refuses the actor outright is not rendered; a row whose destination merely refuses a WRITE stays, per the decision's treatment table.
- An action the treatment table marks DISABLE — the palette's Create entry among them — keeps its shipped visible-but-disabled treatment; a test asserts it is still offered to a viewer.
- A project admin sees the same project nav and the same palette entries, in the same order, as at the branch's own merge base — compared against that baseline before the PR lands.
- The rendered nav matches panel 4 of
design/projects/permission-gated-ui.mock.html, including what happens to the gap a removed row leaves. - Every new
enmessage key has itszhtwin. - The changed files meet the repo's per-file coverage floor.
Context refs
app/(authed)/_components/SidebarNav.tsx— the project-nav array (~line 212) and the Resume-onboarding row that leads it.app/(authed)/_components/AppCommandPalette.tsx— the palette's own entries:create-issue,plan-with-ai,backlog-plan-sprints,nav-*,nav-filters,resume-onboarding.app/(authed)/plans/page.tsxandapp/(authed)/triage/page.tsx— two destinations that already refuse an actor, and therefore already tell you their key.lib/permissions/builtinRoles.ts— whatviewerandmemberactually hold, which is what decides whether a row disappears for a built-in role at all.lib/settings/projectSettingsNav.ts— the shape this card's map deliberately mirrors.- The hide-versus-disable decision — the treatment table this card resolves every row against.
- The design card — panel 4 is this card's reference.