lever-registryrun.md00_intake/stub.mdThe read-only, per-persona workspace is complete. Now begins the action levers phase (deliberately the end of the sprint). The levers a persona can pull are scattered as right-rail buttons, embedded cards, full pages and dialogs across the platform. The first action-phase step is to catalogue every lever once — declaratively, derived from the workflow transitions ∩ the capability matrix — and render them in the workspace's Levers rail, without yet performing any mutation.
Add the lever registry and the Levers rail, ported from apps/demo/lib/workspace/levers.ts +
components/workspace/levers-rail.tsx:
Lever declares id, entity, label, intent
(positive | negative | loop | neutral), the capability it needs, an isAvailable(ctx) predicate,
and a reference to the existing server action that will perform it (bound but not yet invoked).
Spine levers derive from getStatus(name).transitions; satellite levers (proposal/quote/milestone/
invoice + cross-cutting delivery levers) from a per-stage catalogue. The registry is an index, not a
wrapper — it does not wrap auth/validation.isAvailable ≈ isTransitionAllowed(status, target) && can(user, capability, lead),
so the rail renders registry.filter(isAvailable) for the current lead + persona + stage.positive transition, promoted as the header CTA; secondary = the rest). Clicking a lever is a no-op
preview this stub (e.g. disabled or a toast) — nothing mutates.isAvailable is true for the current lead/persona/stage (transition legal ∩ capability held).workflows.json makes its lever appear with no rail-component edit (statechart-driven), demonstrated by a test.acceptCustomerQuoteAction, BRD approve, invoice
approve, milestone complete) from apps/web/components/** and apps/web/app/(app)/** — do not
reimplement, do not yet call them.@sustentus/services workflow accessors.can().apps/web/lib/workspace/levers.ts (new), apps/web/components/workspace/{levers-rail,action-button}.tsx.01_define/output/spec.mdThe read-only, per-persona lead workspace is complete (stubs 1–3): the route, the real-data
aggregate, and the persona capability matrix + jargon-free status labels all land as a thin
projection over machinery that already exists. This is stub 4 of 6 in the strictly-sequential
lead-workspace epic and the first step of the action-levers phase (deliberately end-of-sprint).
Today the actions a persona can take on a lead are scattered across the platform as right-rail buttons, embedded cards, full pages and dialogs — there is no single place that answers "what can this persona do on this lead right now". This feature catalogues every engagement-mutating lever once, declaratively, derived from the workflow transitions ∩ the capability matrix, and renders them in the workspace's Levers rail — without performing any mutation yet. That catalogue is the groundwork the next stub wires to the real server actions, advancing the Refine the Bridge initiative's Q2-2026 Objective 3 (Validate Technical Infrastructure & Payout Flow — lead transactions completed end-to-end; lead-to-payout cycle time): you can't complete a lead-to-payout cycle on one page until every lever that moves it is enumerated and gated correctly.
Port the lever registry and the Levers rail from the demo design source
(apps/demo/lib/workspace/levers.ts + apps/demo/components/workspace/{levers-rail,action-button}.tsx)
onto the real platform, wired to real lead data — as an index, not a wrapper (it does not wrap
auth or validation). Nothing is imported from apps/demo; the structure is re-implemented against
apps/web types and @sustentus/services.
Typed registry (apps/web/lib/workspace/levers.ts, new). Each Lever declares its id,
label, intent (positive | negative | loop | neutral), the capability it requires, an
availability predicate, and a reference to the existing server action that will perform it —
imported and held on the lever, but never invoked this feature (stub 5 calls it). Spine levers
are derived at runtime from the workflow engine's transitions (getStatus(status).transitions /
transitionTargetByType via @sustentus/services); satellite levers (proposal / quote / milestone
/ invoice sub-workflows + cross-cutting delivery and oversight levers) come from a declared
per-stage catalogue. Scope = all engagement-mutating levers, matching the demo's exhaustive
catalogue.
Capability-matrix extension (apps/web/lib/workspace/capabilities.ts). Stub 3 shipped
view capabilities only and explicitly deferred the action capabilities "to the lever
registry, which extends this same matrix". This feature adds those action capabilities (e.g.
lead.activate, lead.qualifyOut, brd.approve, proposal.submit, quote.respond,
milestone.complete, invoice.approve, …) additively to the existing Capability union and
CAPABILITIES table, with a per-persona value for each new cell transcribed from the documented
feature-role-matrix and the demo capability matrix (the design source) — pinned by the existing
capabilities.test.ts fixture pattern so any drift fails the build. Note can() returns an
Access enum, so a lever's capability gate is can(persona, capability) !== "none" (not a
boolean).
Levers rail + next-best-action (apps/web/components/workspace/{levers-rail,action-button}.tsx,
wired in workspace-view.tsx). workspace-view computes the lever set for the current
persona + lead status + aggregate and passes it to <LeversRail leverSet={…} /> (today the rail is
a props-less empty frame). The rail renders registry.filter(isAvailable) grouped: the single
positive forward transition is promoted as the next-best-action CTA at the top (header) of the
rail card, the rest group below as secondary; when a persona has no positive forward lever, the
rail shows a per-persona "no action needed" locked message instead of a primary CTA. Clicking any
lever is a no-op preview — a transient "preview / not wired up yet" toast, persisting nothing —
routed through a single ported ActionButton (the one place the "surface-level operation" contract
lives), so stub 5 swaps the toast for the real invocation in one place.
Decisions resolved at Define (grounded in the design source + the stub's touches list; steer by
editing this spec before approval):
WorkspaceHeader. This matches
the stub's touches list (which omits workspace-header.tsx) and the demo; promoting the CTA into
the page header is stub 6's explicit "primary-CTA surfacing" polish.apps/web/lib/workspace/levers.ts, new) exists; each Lever declares id, label, intent (positive | negative | loop | neutral), its required capability, an availability predicate, and a reference to its existing server action (imported and held, never invoked this feature).@sustentus/services workflow accessors (no re-reading of workflows.json directly, no ported status logic).capabilities.ts is extended with the action capabilities the levers require (additive to the existing view capabilities); each new per-persona cell is pinned in capabilities.test.ts against the documented feature-role-matrix / demo source.can(persona, capability) !== "none".workflows.json surfaces a corresponding spine lever in the rail without editing the rail component (levers-rail.tsx) — the derivation is statechart-driven; demonstrated by a test in levers.test.ts.WorkspaceHeader — stub 6.role === … checks / detail pages, or consolidation/redirect of service-leads, projects, proposals, finance/BRD pages — epic-level out of scope (coexist, deprecate later).apps/demo — it is the design source, ported, never a dependency.Define read the demo design source (apps/demo/lib/workspace/levers.ts,
apps/demo/components/workspace/{levers-rail,action-button}.tsx) and the current apps/web workspace
files (lib/workspace/{capabilities,views}.ts, components/workspace/{levers-rail,workspace-view}.tsx,
packages/services/src/db/workflows/index.ts) beyond the docs-only intake band — necessary because the
design to port is code, not prose, and the spec must be grounded in the real (not demo) accessor and
capability APIs. Recorded per the intake contract's context-budget note.
02_build/output/notes.mdfeat: lever-registry — action capabilities in the workspace matrixfeat: lever-registry — typed lever registry + derivationfeat: lever-registry — Levers rail + action-button, wired in workspace-viewtest: lever-registry — lever derivation + action-capability cellsapps/web/lib/workspace/capabilities.ts — extended the matrix with the 43 action
capabilities the levers gate on (the deferred set stub 3's header note reserved),
additive to the existing view capabilities. Action capabilities are binary access
(full/none); declared compactly as holder lists (transcribed from the demo
capability matrix + feature-role-matrix) and expanded via a helper. can() is
unchanged; the lever gate is can(persona, capability) !== "none". csat.view
(a view capability) doubles as the "view scores" lever gate, so it is not repeated.apps/web/lib/workspace/levers.ts (new) — the typed registry + deriveLevers.
Spine levers come from the workflow engine's transitions (read in workspace-view
via getStatus(...).transitions and passed in, so this module stays pure and
unit-testable); satellite levers from a declared per-stage catalogue. Each lever
declares id, label, intent, capability, weight, an isAvailable
predicate and a lazy reference to its existing server action — held, never
invoked this feature. The single positive forward lever (weight > 0) is promoted
as the next-best-action; the rest group as secondary; no forward lever → a
per-persona locked message. Ported from apps/demo/lib/workspace/levers.ts;
nothing is imported from apps/demo.apps/web/components/workspace/action-button.tsx (new) — the one place the
"preview" click contract lives: previewLever shows a sonner toast and persists
nothing; ActionButton is the primary CTA. Stub 5 swaps the preview for the real
invocation here.apps/web/components/workspace/levers-rail.tsx — replaced the empty frame with
the real rail (client): primary CTA at the top, secondary levers below with intent
dots. The rail defines no levers — it renders the LeverSet it is handed.apps/web/components/workspace/workspace-view.tsx — reads the workflow engine's
transitions for the lead's status and calls deriveLevers(persona, status, aggregate, transitions), passing the result to <LeversRail leverSet={…} />.apps/web/lib/workspace/{levers.test.ts (new), capabilities.test.ts} — lever
derivation tests (spine promotion, statechart-driven surfacing, capability gating,
data-precondition hiding) and the action-capability cells pinned as an independent
transcription (deepEqual drift guard).levers.ts; each lever declares id/label/intent/capability/availability + a lazy, never-invoked reference to its server action.@sustentus/services (getStatus in workspace-view).capabilities.ts extended with the action capabilities; every new cell pinned in capabilities.test.ts.can(persona, capability) !== "none".levers.test.ts.apps/web unit tests (tsx --test) — 9/9 pass;
apps/web typecheck (tsc --noEmit) — clean after rebuilding @sustentus/services@sustentus/ui (a stale local dist/ had surfaced unrelated vendor/sdm errors,
gone after the rebuild; getStatus resolved throughout).chip for invoices (no raw status), so the demo's draft/sent invoice
preconditions can't be reproduced without reshaping the aggregate (owned by stub 2,
out of this stub's touches). Invoice levers therefore gate on stage + capability;
milestone/quote/BRD/CSAT/blocker/action-item preconditions use the aggregate's real
signals (statusName, issued/acceptedAt, approved, submitted, array lengths).action; the field is
declared on every lever and stub 5 (wire-action-levers) binds/creates the rest and
swaps the preview for the real invocation.WorkspaceHeader, which is stub 6's polish).deriveLevers is pure (the engine read lives in workspace-view) so it is testable
under tsx --test without loading server-only code; the lazy action refs are never
resolved at import, keeping tests and the client bundle clean.03_release/output/investor-update.mdWho it's for: All six roles — customer, expert, CSM, SDM, admin, vendor. What shipped: One typed registry now catalogues every action a role can take on a lead and surfaces each stage's next best action in the workspace — as a preview, nothing changes yet. Why it matters: Enumerating and gating every engagement action is the groundwork the lead-to-payout flow depends on (Refine the Bridge · Q2 Objective 3).
Dig deeper: <merged-PR URL> · <changelog entry URL>
03_release/output/release.mdac78dad after the review fix — merge gated on green.apps/docs page documents the lead workspace / levers / capability matrix (verified by search), and the change adds no app, package, route, env var, or build/CI step.business/{feature-role-matrix,service-journey,platform-overview} do not reference the workspace; a "who can do what" update would be premature before the actions are wired (stub 5).03_release/output/investor-update.md. Recorded: no end-user changelog — a "you can now…" entry would overstate a preview whose clicks do nothing./code-review high (complexity: complex) over the diff — 8 finder angles, verified. Everything
outside one root cause came back clean and verified: the client/server boundary (levers-rail/
action-button import levers.ts type-only; the lazy action thunks never load at import or in the
client bundle), cross-file shapes (getStatus(...).transitions matches StatusTransition; every
WorkspaceAggregate field the predicates read exists), the capability-matrix exhaustiveness (all 43
action keys via Record<ActionCapability, …>, no duplicate csat.view, LOCKED covers all six
roles, the deepEqual drift guard is real), and the primary/secondary selection.
ac78dad): the invoice levers (submit/approve/reject/dispute/pay/
resolveDispute) and milestone.start had no isAvailable precondition, so on an empty
work_in_progress lead the highest-weight one (invoice.pay 95 / invoice.approve 90 / milestone.start 80) was promoted as the primary next-best-action CTA — e.g. a customer saw "Pay invoice" with
nothing to pay. Gated them on the real aggregate signals (invoices.length, milestones), mirroring
the blocker/action-item/milestone-complete pattern; added regression tests. (Corrects a build-note
claim — invoices.length was available, only the raw per-invoice status is not.)deriveLevers builds an intermediate Lever candidate whose
capability/isAvailable/action fields aren't read after construction (toView keeps only the
LeverView fields). Cleanup only, no behaviour impact — the Lever type documents the registry
contract (AC #1) and stub 5 (wire-action-levers) reworks this layer to carry the action through to
invocation, so collapsing it now would be churn.csat.view "View CSAT scores" lever borrows the view capability, so the
customer sees a manager-worded label ("see the customer's feedback"). Per-persona label/empty-state
polish is explicitly stub 6's scope (next-best-action-polish).levers.ts; each catalogue lever declares id/label/intent/capability/availability + a lazy, never-invoked server-action reference.@sustentus/services (getStatus in workspace-view) — verified by the correctness finder.capabilities.ts extended with the action capabilities; every cell pinned in capabilities.test.ts (deepEqual drift guard).can(...) !== "none" — strengthened by the invoice/milestone-start fix.levers.test.ts.