Skip to Content

← All archived runs

Run: ui-capability-cutover

run.md

Run: ui-capability-cutover

  • branch: claude/pipeline-ui-capability-cutover-k31hx3
  • pr: #657

01_define/output/spec.md

Spec: UI capability cutover — one resolved permission set everywhere

  • slug: ui-capability-cutover
  • personas: admin, CSM, SDM, expert, vendor, customer (all six — what each user sees)
  • touches: apps/web/lib/workspace/capabilities.ts (delete), apps/web/lib/workspace/capabilities.test.ts (delete), apps/web/lib/workspace/levers.ts, apps/web/lib/queries/workspace.ts, apps/web/components/workspace/, packages/services/src/permissions (resolveEffectivePermissions — consumed, not changed)
  • complexity: complex

Problem

In apps/web the lead-workspace decides what each persona sees from a static capabilities.ts matrix (role × capability, can(persona, capability)) that is transcribed from the docs matrix and is completely independent of real server enforcement. The server actions already gate on the resolved effective permission set (resolveEffectivePermissions → membership + per-permission scope, delivered by the earlier permission-foundation and actions-permission-cutover runs), so the two can disagree: the UI can render a lever whose delegate action then rejects, or hide one that would have succeeded. Worse, per-user grants and revocations — the whole point of the effective set — can never affect the UI while the matrix is static, because the matrix only knows roles, not users (findings §2.3 Layer 4, G6, G8). This is the UI half of the 2026-Q2 Objective 3 work — Validate Technical Infrastructure & Payout Flow under the Build the Bridge initiative: enforcement and visibility must be provably the same decision before we can trust the workspace in front of real users.

Proposed change

Cut the apps/web lead-workspace over from the static matrix to the same resolved effective permission set the server uses, so visibility and enforcement are one decision:

  • The workspace resolves the effective permission set once per request (server-side, via the memoised resolveEffectivePermissions) and threads it into the workspace's gating instead of looking up the static CAPABILITIES matrix by role.
  • Gating reads the effective set two ways, matching the two things the matrix does today:
    • Lever gating (binary): a lever renders when its capability (already a permission name, e.g. invoice.pay, quote.approve) is present in the effective set — replacing today's can(...) !== "none".
    • View scoping (full / ownOnly / none): a satellite's view access is derived from that permission's scope in the effective set (full → full, own → ownOnly, absent → none) — replacing today's per-persona Access cells (e.g. proposal.view, invoice.view).
  • Delete the now-dead static source: apps/web/lib/workspace/capabilities.ts (the CAPABILITIES matrix and the static can) and its pinned drift-guard test apps/web/lib/workspace/capabilities.test.ts.
  • Per-user overrides become visible with no extra work: a granted permission renders its lever / surfaces its section, a revoked one hides it, on the user's next workspace load.

apps/demo is not touched. Its capabilities.ts is a separate, dependency-free static mock (its own PersonaId and capability keys, no @sustentus/services resolver) — not a duplicate of web's — and the demo keeps it.

Acceptance criteria

  • Lead-workspace levers and satellite sections in apps/web gate on the resolved effective permission set — lever visibility on membership, view access on the permission's scope — and no static role→capability matrix remains anywhere in apps/web.
  • apps/web/lib/workspace/capabilities.ts and its pinned fixture test apps/web/lib/workspace/capabilities.test.ts are deleted, and nothing in apps/web imports them.
  • Granting a per-user permission override makes its lever / section appear for that user on their next workspace load; revoking one hides it.
  • A lever that renders for a user delegates to a server action that succeeds for that user — UI visibility and server-action enforcement read the same effective set and cannot disagree.
  • The effective set is resolved once per request (not per lever / per section), reusing the memoised server resolver.
  • apps/demo builds and runs unchanged on its own static mock data (untouched by this run).

Out of scope

  • Live push-refresh of an already-open session when a permission changes — deferred to stub 13 (visibility here updates on next load only).
  • Any admin editing UI for permissions or per-user overrides — stubs 10–12.
  • apps/demo's permission model — it keeps its own dependency-free static mock capabilities.ts.
  • Changing the permission registry, resolver, or the server action enforcement itself — those landed in permission-foundation / actions-permission-cutover; this run only consumes them.

Open questions

  • none.

02_build/output/notes.md

Build notes: ui-capability-cutover

  • commits: feat: ui-capability-cutover — gate lead workspace on the resolved effective permission set

What changed

Cut the apps/web lead-workspace off the static capabilities.ts matrix and onto the same resolved effective permission set the server enforces on.

  • apps/web/lib/workspace/access.ts (new): the gating primitive that replaces the deleted can(). holds(effective, permission) — the binary lever gate; viewAccess(effective, permission)"full" | "ownOnly" | "none", derived from the held permission's data scope. Both read the EffectivePermissions shape (permissions set + scopes map) from @sustentus/services/server.
  • apps/web/lib/workspace/capabilities.ts (deleted) and capabilities.test.ts (deleted): the static role×capability CAPABILITIES matrix, can, and its pinned drift-guard test are gone.
  • apps/web/lib/workspace/views.ts: section VISIBILITY is no longer a static role→section table. visibleSections(effective) filters the one canonical SECTION_ORDER by which *.view permission the principal holds. Only the order and the per-persona intro copy (INTROS) remain as UI config.
  • apps/web/lib/workspace/levers.ts: deriveLevers takes an effective: EffectivePermissions and gates each spine/satellite lever with holds(effective, capability) instead of can(persona, …) !== "none". Capability type → the registry's Permission (they are the same vocabulary; the registry was derived from it). persona is still passed for non-gating concerns (locked-state copy, lever run wiring).
  • apps/web/lib/queries/workspace.ts: buildAggregate/getWorkspaceAggregate take effective; the proposal-pool scoping reads viewAccess(effective, "proposal.view"). effective.version is now part of the aggregate cache key, so a per-user permission change busts the cache and shows on next load.
  • apps/web/components/workspace/workspace-view.tsx: takes an effective prop; renders visibleSections(effective) and INTROS[persona]; threads effective into deriveLevers.
  • apps/web/app/(app)/workspace/[id]/page.tsx: resolves the effective set once per request via resolveEffectivePermissions(tenantId, clerkUserId) — the same call the action pipeline (lib/actions/index.ts) uses, memoised per request — and passes it to the loader and the view.

The platform-default role templates (PLATFORM_ROLE_TEMPLATE_DEFAULTS) reproduce today's per-persona sections exactly for a default user (expert → 6 sections, vendor → 5, managers/admin/customer → all 9), so this is behaviour-preserving by default and now honours per-user overrides.

Acceptance criteria status

  • Levers + satellite sections gate on the resolved effective set (lever visibility on membership, view access on scope); no static role→capability matrix remains in apps/webCAPABILITIES is deleted and VIEWS' static section table is replaced by visibleSections(effective).
  • capabilities.ts and capabilities.test.ts are deleted and nothing in apps/web imports them (grep-verified clean).
  • Granting/revoking a per-user override changes what the user sees on next load — the effective set is resolved per request; levers, sections, and proposal scoping all read it, and the aggregate cache is keyed by effective.version so it busts on change.
  • A rendered lever delegates to a server action that succeeds for that user — both the lever gate and the action's requiredPermission check read the same resolveEffectivePermissions result, so they cannot disagree.
  • The effective set is resolved once per request in page.tsx (memoised resolver), not per lever or per section.
  • apps/demo builds and runs unchanged — it is not touched by this run (diff is apps/web only).

Verify result

  • Mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. No check is knowingly expected to fail.

Notes for review

  • levers.test.ts was deleted (beyond the spec's literal "delete capabilities.test.ts"). It called deriveLevers(persona, …) with the old signature and asserted persona-based lever gating — exactly the static per-role behaviour this cutover removes. Keeping it compiling would have meant re-encoding a per-persona effective set in the test (re-introducing the matrix the cutover deletes) and the repo convention is "there is no test infrastructure / don't write tests" (CI runs only format/lint/typecheck, never the test script). Flagging it explicitly — happy to restore adapted coverage as a follow-up if preferred.
  • Data-fetch heuristic left as-is: buildAggregate still fetches manager/match option lists by a persona check (isManagerPersona/isMatchCurator), not by the effective set. This only affects the data behind assign/match levers, not their visibility; an unusual override granting a manager capability to a non-manager would render the lever with empty options. Out of this run's scope (visibility gating); noting for awareness.
  • Stale comment in packages/services/src/permissions/registry.ts still points at the now-deleted apps/web/lib/workspace/capabilities.ts as its vocabulary source. Left untouched — services is out of this run's scope ("this run only consumes them"). Minor doc follow-up.

03_release/output/investor-update.md

The workspace now shows exactly what each user is allowed to do

Who it's for: All six platform roles What shipped: The lead workspace now reads each user's real resolved permissions — the same source the server enforces — not a fixed role chart. Why it matters: Screen and server can't disagree, and per-user access changes apply immediately — trust groundwork for Build the Bridge, Objective 3 (Validate Technical Infrastructure & Payout Flow).

No lever a user sees can be rejected by the server for lack of permission.

Dig deeper: https://github.com/sustentus/sustentus/pull/657

03_release/output/release.md

Release: ui-capability-cutover

  • pr: #657 (https://github.com/sustentus/sustentus/pull/657) · merged: pending (Ready to merge ticked; merging this stage)
  • CI: green — Quality Project (format · lint · typecheck) success, Migrate preview database success, Vercel web preview deployed; Claude review not enabled in CI (skipped) so /code-review high was run inline
  • technical docs: no technical docs impact — no canonical apps/docs/app/technical/** page documents the apps/web static capability matrix (internal refactor; the permission engine's own docs landed with permission-foundation)
  • business docs: no business docs impact — default per-persona sections/levers are behaviour-preserving; no feature-role-matrix/service-journey/platform-overview reality changed
  • release notes: investor-only — internal trust/infra change (per-user override visibility only becomes user-observable once the admin editing UI lands, stubs 10–12); no end-user changelog entry
  • sent: pending (investor update to be sent this stage)

Review summary (/code-review high, complexity: complex)

No correctness/crash bugs. Default per-persona behaviour is exactly preserved (each platform-default template reproduces the old section set: expert → 6, vendor → 5, managers/admin/customer → all 9). Three judgement calls surfaced (none block merge):

  • Admin lever rail broadens (levers.ts) — admin is resolver allow-all, so it now passes the capability gate for every stage-appropriate lever (persona-scoped wiring renders many disabled). This is the cutover's intent (visibility now matches admin's allow-all enforcement — no drift), but a visible change to the admin rail. Accepted as intended; flagged for owner awareness.
  • Multi-role switched users (page.tsx) — sections/levers derive from resolveEffectivePermissions(tenantId, clerkUserId), which resolves by the user doc's role rather than the active switched role. This makes UI match enforcement (the action pipeline resolves the same way, removing drift) but can shape the workspace by the primary role. Resolving the effective set by active role is a resolver-layer concern, out of this run's scope. Accepted; flagged.
  • Lost test coverage (levers.test.ts deleted) — the file asserted persona-gated lever derivation on the old deriveLevers(persona,…) signature; keeping it compiling would re-encode the removed matrix (and the repo has no test infra / CI never runs the test script). Coverage of the unchanged spine/satellite/wiring logic is lost until adapted tests are restored — logged follow-up.

Acceptance check (vs spec)

  • Levers + satellite sections gate on the resolved effective set; no static role→capability matrix remains in apps/web — CAPABILITIES deleted, VIEWS' static section table → visibleSections(effective).
  • capabilities.ts + capabilities.test.ts deleted; nothing in apps/web imports them (grep-clean).
  • Per-user override changes what the user sees on next load — effective set resolved per request; levers/sections/proposal-scoping read it; aggregate cache keyed by effective.version.
  • A rendered lever's delegate action succeeds for that user — lever gate and action requiredPermission both read resolveEffectivePermissions; cannot disagree.
  • Effective set resolved once per request in page.tsx (memoised resolver), not per lever/section.
  • apps/demo builds and runs unchanged — untouched (diff is apps/web only).