Skip to Content

← All archived runs

Run: lever-registry

run.md

Run: lever-registry

  • branch: claude/pipeline-lever-registry-9176ks
  • pr: #570

00_intake/stub.md

Stub: Lever registry — the typed catalogue and Levers rail (no mutations)

  • feature-slug: lever-registry
  • 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: persona-capability-matrix
  • sequence: 4 of 6

Problem

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

Proposed change

Add the lever registry and the Levers rail, ported from apps/demo/lib/workspace/levers.ts + components/workspace/levers-rail.tsx:

  • Typed registry — each 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.
  • DerivationisAvailableisTransitionAllowed(status, target) && can(user, capability, lead), so the rail renders registry.filter(isAvailable) for the current lead + persona + stage.
  • Levers rail + next-best-action — render the available levers grouped (primary = the single 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.

Acceptance criteria (rough)

  • A typed lever registry exists; spine levers are derived from the workflow engine's transitions, satellite levers from a declared catalogue, each bound by reference to its existing server action.
  • The Levers rail renders only levers where isAvailable is true for the current lead/persona/stage (transition legal ∩ capability held).
  • The single positive transition surfaces as the next-best-action CTA in the header; other levers group as secondary.
  • Clicking a lever performs no mutation (preview/disabled) — verified nothing is persisted.
  • Adding a transition in workflows.json makes its lever appear with no rail-component edit (statechart-driven), demonstrated by a test.

Out of scope (this feature)

  • Invoking the server actions / performing real transitions — stub 5.
  • Cache invalidation, optimistic UI, forms for levers that need input — stub 5.
  • Final CTA polish, empty/locked states, unhappy-path levers — stub 6.

Notes for Define

  • Reuse the existing actions by reference only (e.g. acceptCustomerQuoteAction, BRD approve, invoice approve, milestone complete) from apps/web/components/** and apps/web/app/(app)/** — do not reimplement, do not yet call them.
  • Levers must resolve status/transitions only through @sustentus/services workflow accessors.
  • Lever scope = all engagement-mutating levers (matches the demo's exhaustive catalogue), gated by can().
  • touches: apps/web/lib/workspace/levers.ts (new), apps/web/components/workspace/{levers-rail,action-button}.tsx.

01_define/output/spec.md

Spec: Lever registry — the typed catalogue and Levers rail (no mutations)

  • slug: lever-registry
  • personas: Customer, Expert, CSM, SDM, Admin, Vendor
  • touches: apps/web/lib/workspace/levers.ts (new), apps/web/lib/workspace/capabilities.ts (extend), apps/web/lib/workspace/{levers.test.ts (new), capabilities.test.ts (extend)}, apps/web/components/workspace/{levers-rail.tsx, action-button.tsx (new), workspace-view.tsx}
  • complexity: complex

Problem

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

Proposed change

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

  1. Click behaviour = toast preview on live buttons (not disabled buttons). Matches the demo, keeps the next-best-action visibly prominent, and is a one-line swap for stub 5. The rail becomes a client component.
  2. Primary CTA renders at the top of the Levers rail, not the page 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.

Acceptance criteria

  • A typed lever registry (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).
  • Spine levers are derived at runtime from the workflow engine's transitions and satellite levers from a declared per-stage catalogue; all status/transition data is read only through @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.
  • The Levers rail renders only levers whose availability is true for the current lead + persona + stage — transition legal (spine) or stage/data precondition met (satellite) and can(persona, capability) !== "none".
  • The single positive forward transition surfaces as the next-best-action CTA at the top of the Levers rail; other available levers group below as secondary; a persona with no positive forward lever sees a per-persona "no action needed" locked message.
  • Clicking any lever performs no mutation — it shows a transient preview toast and persists nothing (verified: nothing is written to the database).
  • Adding a transition to 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.

Out of scope

  • Invoking the referenced server actions / performing any real transition — stub 5 (wire-action-levers).
  • Cache-tag invalidation, optimistic UI, and input forms for levers that need data — stub 5.
  • Final next-best-action CTA polish, per-persona empty/locked-state design, and unhappy-path levers (qualify out, return to bidding, dispute) — stub 6 (next-best-action-polish).
  • Promoting the primary CTA into the page-level WorkspaceHeader — stub 6.
  • Any refactor of existing role === … checks / detail pages, or consolidation/redirect of service-leads, projects, proposals, finance/BRD pages — epic-level out of scope (coexist, deprecate later).
  • Importing anything from apps/demo — it is the design source, ported, never a dependency.

Open questions

  • none. (The two forks the stub left open — click behaviour and primary-CTA location — are resolved under Decisions resolved at Define above; both are steerable by editing this spec before the Spec approved gate.)

Context budget

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

Build notes: lever-registry

  • commits:
    • feat: lever-registry — action capabilities in the workspace matrix
    • feat: lever-registry — typed lever registry + derivation
    • feat: lever-registry — Levers rail + action-button, wired in workspace-view
    • test: lever-registry — lever derivation + action-capability cells

What changed

  • apps/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 &lt;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).

Acceptance criteria status

  • Typed lever registry exists — levers.ts; each lever declares id/label/intent/capability/availability + a lazy, never-invoked reference to its server action.
  • Spine derived from the engine's transitions, satellite from the catalogue; status/transition data read only through @sustentus/services (getStatus in workspace-view).
  • capabilities.ts extended with the action capabilities; every new cell pinned in capabilities.test.ts.
  • Rail renders only available levers — transition legal (spine) or stage/data precondition met (satellite) and can(persona, capability) !== "none".
  • Single positive forward transition promoted as the rail-top next-best-action CTA; others secondary; a persona with no forward lever sees the locked message.
  • Clicking a lever performs no mutation — a preview toast, nothing persisted (actions held by reference, never invoked).
  • Adding a transition surfaces its spine lever with no rail-component edit — demonstrated by the statechart-driven test in levers.test.ts.

Verify result

  • Ran locally (not the full CI sweep): 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).
  • Format · lint · typecheck · build otherwise run in CI + the Vercel preview.

Notes for review

  • Invoice data preconditions dropped by necessity. The real aggregate exposes only a resolved 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 bindings are partial by design. Six confirmed server actions are bound by reference (quote accept/reject, invoice approve/reject/dispute, raise-invoice). Levers whose backing action doesn't cleanly exist yet carry no 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.
  • Define decisions honored: toast preview on live buttons; primary CTA at the top of the Levers rail (not the page 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.md

Every lead now shows each role its next best action

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

Release: lever-registry

  • pr: #570 (https://github.com/sustentus/sustentus/pull/570) · merged: pending — both gates ticked (Spec approved + Ready to merge); CI re-running after the review fix, merging in this stage once green.
  • CI: green before the fix (Quality Project ✅, Migrate preview database ✅, production-migrate skipped); re-running on ac78dad after the review fix — merge gated on green.
  • technical docs: no technical docs impact — no 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 docs: no business docs impact — the levers rail is a non-mutating preview (clicking is a no-op), and 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).
  • release notes: investor-only — a non-functional preview with no new end-user capability, matching the epic's prior stubs (workspace-shell, lead-data-display, persona-capability-matrix). Investor draft at 03_release/output/investor-update.md. Recorded: no end-user changelog — a "you can now…" entry would overstate a preview whose clicks do nothing.
  • 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 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.

  • CONFIRMED → fixed on branch (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.)
  • Accepted (no change): 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.
  • Deferred to stub 6: the 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).

Acceptance check (vs spec)

  • Typed lever registry exists — levers.ts; each catalogue lever declares id/label/intent/capability/availability + a lazy, never-invoked server-action reference.
  • Spine derived from the engine's transitions, satellite from the catalogue; status/transition data read only via @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).
  • Rail renders only available levers — transition legal (spine) or stage/data precondition met (satellite) and can(...) !== "none" — strengthened by the invoice/milestone-start fix.
  • Single positive forward lever promoted as the rail-top CTA; others secondary; no forward lever → per-persona locked message — the fix ensures a phantom invoice/milestone lever no longer hijacks the CTA.
  • Clicking a lever performs no mutation — preview toast, nothing persisted (actions held by reference, never invoked).
  • Adding a transition surfaces its spine lever with no rail-component edit — statechart-driven test in levers.test.ts.