persona-capability-matrixrun.md00_intake/stub.mdThe workspace now shows the full aggregate, but every persona sees the same thing — a customer would read
internal jargon ("service lead", "bid pool") and see manager-only panels. Persona divergence today is
handled ad hoc (if (role === "customer") branches). The workspace needs a single, declared source of
who-sees-what so each of the six personas gets the correct read-only slice, with jargon-free wording.
Add — additively, new code only — a typed capability matrix and drive the per-persona read view off it:
CAPABILITIES + can() — a typed Record<Capability, PersonaId[]> (or richer Access =
full | ownOnly | managerOnly | none) and a can(persona, capability, resource?) helper, lifted
line-for-line from feature-role-matrix/{leads,projects,proposals}. The single source of "who can
do/see what" the levers will later consume.VIEWS config mapping each persona to its ordered section list +
intro (port the demo's views.ts), so the body is config-driven, not branch-driven.resolveStatusView/resolveStatusLabel (e.g. customer sees "Finding your expert", not
"quotation_process").can() (e.g. a customer's view omits other
experts' proposals; vendor sees revenue/health, not internal panels).Still read-only: no levers, no mutations. The existing inline role === … checks and allowedRoles
arrays elsewhere in apps/web are left untouched (migrating them is a later epic).
CAPABILITIES map + can() helper exists, derived from feature-role-matrix/{leads,projects,proposals}, with a test asserting it matches the documented matrix.can() — a persona cannot see data the matrix denies (e.g. customer doesn't see rival proposals).role === … / allowedRoles call-sites onto can() (later epic).apps/docs/app/business/feature-role-matrix/{leads,projects,proposals}
(+ roles/), honouring qualifiers (own only / manager only / assigned). Design source for shapes:
apps/demo/lib/workspace/{capabilities,views,journey}.ts.can() pure/typed (no new dependency — defer CASL per the brief).apps/web/lib/workspace/{capabilities,views}.ts (new), apps/web/components/workspace/**,
loader scoping in apps/web/app/(app)/workspace/[id]/**.01_define/output/spec.mdThe workspace (stubs 1–2) renders one lead's full aggregate, but every persona sees the same
manager-level view: a customer reads internal jargon ("service lead", "bid pool") in the activity feed,
sees rival experts' bids, and gets manager-only panels. Persona divergence elsewhere in apps/web is
handled ad hoc (if (role === "customer") branches) with no single declared source of who-sees-what.
This is feature 3 of the lead-workspace epic (Refine the Bridge / Q2 2026 Objective 3 — Validate
Technical Infrastructure & Payout Flow: leads completing end-to-end, and lead-to-payout cycle time). The
bridge only proves itself if each of the six personas can trust that the one lead page shows them exactly
their slice, in their language — which needs a single, typed source of truth for access, not scattered
role checks.
Add — additively, new code only — a typed capability matrix and drive the per-persona read view off
it. Nothing outside the workspace's own files changes: the inline role === … / allowedRoles
call-sites elsewhere in apps/web are left untouched (migrating them is a later epic), and there are no
server-action authorization changes (the matrix gates the workspace UI only; existing server guards
are unchanged).
Access matrix + can() — a typed Access = "full" | "ownOnly" | "managerOnly" | "none",
a CAPABILITIES: Record<Capability, Record<UserRole, Access>>, and can(persona, capability) → Access
in apps/web/lib/workspace/capabilities.ts. Keys are UserRole (@/lib/auth), which is exactly the
six personas and equals the workflow engine's Persona. This stub ports only the view/visibility
capabilities — one per workspace satellite (lead.view, brd.view, proposal.view, quote.view,
milestone.view, invoice.view, csat.view, people.view, activity.view) — each derived from the
documented feature-role-matrix/{leads,projects,proposals} "View …"/oversight rows, with the
own-only / manager-only / assigned qualifiers preserved as the matching Access value (e.g. customer
"View lead (own only)" → ownOnly; csm/sdm "Qualify lead out (manager only)" stays out of scope as an
action). Action capabilities (submit/approve/transition/pay/…) are not ported here — stub 4's lever
registry extends the matrix when it needs them. CAPABILITIES is the single source the levers will
later consume; this stub consumes only the view subset.VIEWS) — apps/web/lib/workspace/views.ts maps each UserRole to its
ordered section list + a persona intro (ported from the demo's views.ts): customer sees the full
journey; expert sees overview/BRD/proposals/delivery/invoices/activity; csm/sdm/admin see everything;
vendor sees overview/delivery/invoices/CSAT/activity. WorkspaceView renders VIEWS[persona].sections
in declared order (titles/components looked up from the existing section registry) with
VIEWS[persona].intro, instead of the flat full list. page.tsx passes the already-resolved persona
(getRole()) into the view.buildAggregate (the stub-2 loader, which already receives persona)
narrows the aggregate by can(): a satellite the persona has full access to renders in full;
ownOnly/managerOnly renders only their own/relevant slice (the expert sees their own proposal; the
customer sees the engagement's chosen proposal, never rival bids); none is omitted. Section-level
omission is already handled by VIEWS; this is the within-section filter for sections shown to multiple
personas.statusService.resolveView(name, persona), wired in stub 2). This stub closes the
one remaining gap: the activity feed still renders raw newStatus.displayName, so a customer can
see an internal status name there. Route those events through statusService.resolveLabel(name, persona) so no persona sees internal status names in any section.Still read-only: no levers, no mutations; the levers rail and header next-best-action slot stay the stub-1 placeholders.
Access enum (full | ownOnly | managerOnly | none), a CAPABILITIES matrix, and a
can(persona, capability) → Access helper exist in apps/web/lib/workspace/capabilities.ts,
covering the view/visibility capabilities (one per workspace satellite) and keyed by UserRole.apps/web/lib/workspace/ (run by the web app's test script,
tsx --test lib/**/*.test.ts) pins each ported view capability's per-persona Access to a fixture
transcribed from the documented feature-role-matrix/{leads,projects,proposals} rows (including the
own-only / manager-only / assigned qualifiers); the test fails if a cell drifts from the docs.VIEWS-configured sections, in declared order, with its
persona-appropriate intro — driven by config, with no role === … branch in the view.can(): a persona never sees data the matrix denies — e.g. a customer's
Proposals section shows the chosen proposal only (no rival experts' bids) and the expert sees only
their own; the vendor sees no internal panels.role === … call-site outside the workspace is
modified; the matrix is additive and consumed only by the workspace; no new collection and no
server-action authorization change.role === … / allowedRoles call-sites onto can() (later epic).getRole()) only,
as settled in stub 1.can() shape (typed Access enum) and the matrix breadth (view capabilities only this
stub; action capabilities deferred to stub 4) are decided. Persona source (auth only), the section
lists (the demo's views.ts), and the satellite set (stub 2's aggregate) are settled by the stub and
its predecessors.Per the epic breakdown's recorded note, this Define read code beyond the docs-only intake band — the demo
design source (apps/demo/lib/workspace/{capabilities,views}.ts), the predecessor's loader/view
(apps/web/lib/queries/workspace.ts, apps/web/components/workspace/*,
apps/web/app/(app)/workspace/[id]/page.tsx), the auth role type (apps/web/lib/auth.ts), and the
workflow status accessors (packages/services/src/db/services/status) — alongside the
feature-role-matrix/{leads,projects,proposals} docs the stage names. Necessary because the design to
port and the surface to scope are code, not prose. Recorded per the contract.
02_build/output/notes.mdfeat: persona-capability-matrix — typed access matrix + per-persona workspace viewapps/web/lib/workspace/capabilities.ts (new) — typed Access enum
(full | ownOnly | managerOnly | none), Capability union (one view capability per
workspace satellite), the CAPABILITIES matrix keyed by UserRole, and can(persona, capability) → Access. Cells are derived from apps/docs business/roles ›
"Role-Based Data Visibility" and business/feature-role-matrix/{leads,projects,proposals},
cited inline. View capabilities only — action capabilities are deferred to the lever
registry (stub 4), which extends this same matrix.apps/web/lib/workspace/views.ts (new) — VIEWS: per-persona ordered section listviews.ts (nothing imported from apps/demo). Section
lists mirror the matrix: a section is absent exactly where that persona's capability is
none.apps/web/lib/workspace/capabilities.test.ts (new) — pins every matrix cell to an
independently-transcribed fixture (drift guard), checks can() per persona/capability,
asserts the AC-4 proposal-scoping anchors, and cross-checks that VIEWS stays consistent
with the matrix. Runs under the web app's test script (tsx --test lib/**/*.test.ts).apps/web/components/workspace/workspace-view.tsx — now takes persona and renders
VIEWS[persona].sections in order with the persona intro, instead of the flat full list.apps/web/app/(app)/workspace/[id]/page.tsx — passes the already-resolved persona
(getRole()) into the view.apps/web/lib/queries/workspace.ts — scopes the proposal pool by
can(persona, "proposal.view") (managers/admin → full pool; expert/customer → the chosen
proposal only, never rival bids; vendor → none), and resolves activity-feed status events
through statusService.resolveLabel(name, persona) so no persona sees an internal status
name in the timeline.Access enum + CAPABILITIES + can() in capabilities.ts, view capabilities
keyed by UserRole — done.capabilities.test.ts, 4/4 green via pnpm --filter web test).VIEWS sections, in order, with its intro, config-driven
(no role === branch in the view) — done.can(): customer's Proposals shows the chosen proposal only,
expert sees their own, vendor sees no internal panels (VIEWS) — done.role === call-site outside the workspace modified;
additive; no new collection; no server-action auth change — done./CONVENTIONS.md lists "no test infrastructure" under What to avoid,
but apps/web has a real test runner (tsx --test) and a pre-existing test
(lib/taxonomy-zod.test.ts), and the approved spec's AC-2 requires a matrix test — so a
test was written, following the existing file's node:test/node:assert style. (The web
test glob lib/**/*.test.ts expands one dir deep, which is why the new test lives under
lib/workspace/.)capabilities.ts.ownOnly semantics within one lead: the workspace already loads a single, route-scoped
lead, so ownOnly only meaningfully filters the multi-record satellite where rival records
exist — proposals. The loader actively scopes proposals; section-level visibility for every
other satellite is enforced by VIEWS.apps/web/components/workspace/section-registry.ts is left in place as the canonical
section catalogue; the view now sources its per-persona list from VIEWS.03_release/output/investor-update.mdWho it's for: All six roles — customer, expert, CSM, SDM, admin, vendor. What shipped: A typed access matrix now drives the lead workspace, so each role sees only its own sections, data and plain-language statuses. Why it matters: A correct, jargon-free read of every lead builds the trust the payout flow depends on (Refine the Bridge · Q2 Objective 3).
Dig deeper: <merged-PR URL> · <changelog entry URL>
03_release/output/release.mdweb preview deployed Ready. Re-checked on the review-fix commit before merge.apps/web/lib/workspace modules + edits the workspace loader/view/page; no new app, package, route, env var, build/CI step, or architecture documented in apps/docs/technical changed (the cache-tags doc already registered getWorkspaceAggregate in stub 2, and no new tag/query was added).business/roles › "Role-Based Data Visibility"), it doesn't change what the docs say. The unified lead-workspace surface is documented when it becomes actionable (stubs 4–6); this stub is the read-only persona-scoping layer.03_release/output/investor-update.md. Recorded: no end-user changelog — internal/in-progress surface./code-review medium (standard complexity) — two independent finder angles (correctness; cleanup/conventions) over the six changed files, plus manual verification.
ownOnly proposals to the engagement's accepted proposal for both customer and expert. Because the workspace route is tenant-scoped (any expert can open any lead by id), a non-winning expert would have seen the winning expert's bid. Now the loader threads the viewer's app-user id and scopes the expert to proposals they authored (createdBy._id === viewerId); the customer still sees the chosen (accepted) proposal; managers/admin see the full pool; the vendor none. The viewer id is part of the cache key so two experts never share a cached view. (Still a strict improvement over stub 2, which showed every persona the whole pool.)WORKSPACE_SECTIONS lost its only consumer when the view moved to VIEWS, and SectionFrame (its only type consumer) was already unreferenced scaffolding from the shell. Removed section-registry.ts + section-frame.tsx.type not interface, named imports, @/ alias, sentence-case copy, @sustentus/services/server path all satisfied. The test's matrix fixture duplication is intentional drift-detection.pnpm --filter web test); typecheck/lint/build run in CI + the Vercel preview.Access enum + CAPABILITIES matrix + can(persona, capability) → Access in apps/web/lib/workspace/capabilities.ts, view capabilities keyed by UserRole.capabilities.test.ts, run by the web test script.VIEWS sections, in order, with its intro — config-driven, no role === branch in the view.can() — customer sees the chosen proposal only, the expert only their own bid (review fix), the vendor no internal panels; no rival-bid leak.statusService.resolveLabel(persona).role === call-site outside the workspace modified; additive; no new collection; no server-action auth change.