Skip to Content

← All archived runs

Run: persona-capability-matrix

run.md

Run: persona-capability-matrix

  • branch: claude/pipeline-persona-capability-matrix-oulv5a
  • pr: #569

00_intake/stub.md

Stub: Persona capability matrix — per-persona read-only view, one typed policy

  • feature-slug: persona-capability-matrix
  • epic: lead-workspace
  • personas: Customer, Expert, CSM, SDM, Admin, Vendor
  • initiative: Refine the Bridge / objective: Validate Technical Infrastructure & Payout Flow (Q2 2026 · Objective 3)
  • depends-on: lead-data-display
  • sequence: 3 of 6

Problem

The 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.

Proposed change

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.
  • Per-persona section lists — a 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.
  • Jargon-free per-persona status labels — resolve status wording per persona via the workflow engine's resolveStatusView/resolveStatusLabel (e.g. customer sees "Finding your expert", not "quotation_process").
  • Per-persona data scoping — narrow the stub-2 aggregate by 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).

Acceptance criteria (rough)

  • A typed CAPABILITIES map + can() helper exists, derived from feature-role-matrix/{leads,projects,proposals}, with a test asserting it matches the documented matrix.
  • Each of the six personas renders only its configured sections, in order, with a persona-appropriate intro.
  • Status wording is jargon-free per persona, resolved through the workflow engine (customer never sees internal status names).
  • The aggregate is scoped by can() — a persona cannot see data the matrix denies (e.g. customer doesn't see rival proposals).
  • No existing component or action is modified; the matrix is consumed only by the workspace.

Out of scope (this feature)

  • The lever registry / Levers rail — stub 4.
  • Wiring any action or mutation — stubs 5–6.
  • Refactoring existing role === … / allowedRoles call-sites onto can() (later epic).
  • Server-action authorization changes — the matrix gates the workspace UI only here; existing server guards are unchanged.

Notes for Define

  • Source of truth for the matrix: 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.
  • Keep can() pure/typed (no new dependency — defer CASL per the brief).
  • touches: 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.md

Spec: Persona capability matrix — typed access matrix driving the per-persona workspace view

  • slug: persona-capability-matrix
  • personas: Customer, Expert, CSM, SDM, Admin, Vendor
  • touches: apps/web/lib/workspace (new: capabilities.ts, views.ts, capabilities.test.ts), apps/web/components/workspace/workspace-view.tsx, apps/web/app/(app)/workspace/[id]/page.tsx, apps/web/lib/queries/workspace.ts
  • complexity: standard

Problem

The 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.

Proposed change

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).

  • Typed 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.
  • Per-persona section lists (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.
  • Per-persona data scopingbuildAggregate (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.
  • Jargon-free status wording, everywhere — section status chips already resolve per-persona through the workflow engine (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.

Acceptance criteria

  • A typed 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.
  • A unit test under 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.
  • Each of the six personas renders only its VIEWS-configured sections, in declared order, with its persona-appropriate intro — driven by config, with no role === … branch in the view.
  • The aggregate is scoped by 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.
  • Status wording is jargon-free per persona in every section, including the activity feed — a customer never sees an internal status name (e.g. sees "Finding your expert", not "quotation_process"), resolved through the workflow engine.
  • No existing component, loader, action, or 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.

Out of scope

  • The lever registry / Levers rail and next-best-action — stub 4.
  • Wiring any action or mutation, and porting action capabilities into the matrix — stubs 4–6.
  • Refactoring existing role === … / allowedRoles call-sites onto can() (later epic).
  • Server-action authorization changes — the matrix gates the workspace UI only; existing server guards are unchanged.
  • Any admin "view-as" / persona impersonation or override — persona is read from auth (getRole()) only, as settled in stub 1.

Open questions

  • none — the 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.

Context budget

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.md

Build notes: persona-capability-matrix

  • commits: feat: persona-capability-matrix — typed access matrix + per-persona workspace view

What changed

  • apps/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 list
    • intro, ported from the demo's views.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.

Acceptance criteria status

  • Typed Access enum + CAPABILITIES + can() in capabilities.ts, view capabilities keyed by UserRole — done.
  • Unit test pins each cell to a docs-transcribed fixture and fails on drift — done (capabilities.test.ts, 4/4 green via pnpm --filter web test).
  • Each persona renders only its VIEWS sections, in order, with its intro, config-driven (no role === branch in the view) — done.
  • Aggregate scoped by can(): customer's Proposals shows the chosen proposal only, expert sees their own, vendor sees no internal panels (VIEWS) — done.
  • Status wording jargon-free per persona in every section incl. the activity feed — done (chips were already persona-resolved in stub 2; the activity feed was the remaining gap and now routes through the engine).
  • No existing component/loader/action/role === call-site outside the workspace modified; additive; no new collection; no server-action auth change — done.

Verify result

  • Mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. The new unit test was run locally and is green (4/4).
  • Note on conventions: /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/.)

Notes for review

  • Documented divergences, by design: the roles "Role-Based Data Visibility" grid grants a vendor a BRD/proposal summary and a customer ranked proposals; the lead-workspace deliberately narrows this read-only view — the vendor is a revenue/health summary persona (no internal panels) and the customer sees only the chosen proposal (the spec's "no rival bids"). These choices are encoded in the matrix and cited in 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.md

The lead workspace now speaks each role's language

Who 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.md

Release: persona-capability-matrix

  • pr: #569 (https://github.com/sustentus/sustentus/pull/569) · merged: pending (CI green + Ready-to-merge ticked; merging in this stage)
  • CI: green on the build commit — Quality Project (lint · format · typecheck) success, Migrate preview database success, Migrate production database skipped (expected on PR), Vercel Preview Comments success; the web preview deployed Ready. Re-checked on the review-fix commit before merge.
  • technical docs: no technical docs impact — the change adds two 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 docs: no business docs impact — consistent with stubs 1–2: the feature implements the already-documented role visibility (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.
  • release notes: investor-only — read-only persona-scoping of an as-yet-unannounced surface (no levers/actions until stubs 4–6), so a customer-facing "you can now…" changelog would be premature. Investor draft at 03_release/output/investor-update.md. Recorded: no end-user changelog — internal/in-progress surface.
  • deploy: pending — gated behind the merge (step 7 runs after the squash-merge).
  • sent: pending — investor email gated behind merge + green production deploy (step 8); audience cut is investor-only.

Review summary

/code-review medium (standard complexity) — two independent finder angles (correctness; cleanup/conventions) over the six changed files, plus manual verification.

  • Correctness — expert could see a rival's accepted bid (FIXED). The first cut scoped 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.)
  • Cleanup — dead section registry (FIXED). 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.
  • Conventions — clean. Arrow functions, 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.
  • Re-verified after fixes: the capability-matrix unit test is green (4/4 via pnpm --filter web test); typecheck/lint/build run in CI + the Vercel preview.

Acceptance check (vs spec)

  • Typed Access enum + CAPABILITIES matrix + can(persona, capability) → Access in apps/web/lib/workspace/capabilities.ts, view capabilities keyed by UserRole.
  • Unit test pins each cell to a docs-transcribed fixture and fails on drift — capabilities.test.ts, run by the web test script.
  • Each persona renders only its VIEWS sections, in order, with its intro — config-driven, no role === branch in the view.
  • Aggregate scoped by can() — customer sees the chosen proposal only, the expert only their own bid (review fix), the vendor no internal panels; no rival-bid leak.
  • Status wording jargon-free per persona in every section incl. the activity feed — chips were persona-resolved in stub 2; the activity feed now resolves through statusService.resolveLabel(persona).
  • No existing component/loader/action/role === call-site outside the workspace modified; additive; no new collection; no server-action auth change.