ui-capability-cutoverrun.md01_define/output/spec.mdIn 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.
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:
resolveEffectivePermissions) and threads it into the workspace's gating instead of
looking up the static CAPABILITIES matrix by role.capability (already a permission name, e.g.
invoice.pay, quote.approve) is present in the effective set — replacing today's
can(...) !== "none".full → full, own → ownOnly, absent → none) —
replacing today's per-persona Access cells (e.g. proposal.view, invoice.view).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.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.
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.apps/demo builds and runs unchanged on its own static mock data (untouched by this run).apps/demo's permission model — it keeps its own dependency-free static mock capabilities.ts.permission-foundation / actions-permission-cutover; this run only consumes them.02_build/output/notes.mdfeat: ui-capability-cutover — gate lead workspace on the resolved effective permission setCut 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.
apps/web — CAPABILITIES
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).effective.version so it busts on change.requiredPermission check read the same resolveEffectivePermissions result,
so they cannot disagree.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).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.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.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.mdWho 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/code-review high was run inlineapps/docs/app/technical/** page documents the apps/web static capability matrix (internal refactor; the permission engine's own docs landed with permission-foundation)feature-role-matrix/service-journey/platform-overview reality changed/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):
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.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.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.CAPABILITIES deleted, VIEWS' static section table → visibleSections(effective).capabilities.ts + capabilities.test.ts deleted; nothing in apps/web imports them (grep-clean).effective.version.requiredPermission both read resolveEffectivePermissions; cannot disagree.page.tsx (memoised resolver), not per lever/section.