wire-action-leversrun.md00_intake/stub.mdThe Levers rail shows the right levers but nothing happens when pulled. This is the core of the action phase: make the workspace operable — a CSM accepts a proposal, the customer approves the quote, the manager approves an invoice, the expert completes a milestone — all from the one page, by invoking the server actions that already exist, with no new business logic and without touching the old pages.
Wire each registry lever to its bound server action so it performs the real transition:
can(), and every action keeps its existing
resolveActionContext({ allowedRoles }) → runActionBody → assertTransition pipeline (unchanged), so
an illegal transition can never persist even if the UI is wrong.Activity/StatusHistory entry being written).resolveActionContext/assertTransition is rejected even if the lever were shown.apps/web/components/{service-leads,proposals,finances}/**/actions.ts
and apps/web/app/(app)/{projects/brd,customer/satisfaction}/**/actions.ts.apps/docs/app/technical/cache-tags) for invalidation.apps/web AGENTS rule).apps/web/components/workspace/**, apps/web/lib/workspace/levers.ts, lever form components.01_define/output/spec.mdStubs 1–4 of the strictly-sequential lead-workspace epic delivered a per-persona workspace that
shows the right levers but does nothing when one is pulled: clicking any lever fires a
"preview / not wired up yet" toast and persists nothing (stub 4, lever-registry, deliberately shipped
the catalogue non-mutating, with each lever's server action held by reference but never invoked). This
is stub 5 of 6 and the core of the action-levers phase (depends-on: lever-registry, satisfied):
make the workspace operable — a CSM approves an invoice, the customer accepts the quote or pays an
invoice, the expert raises an invoice or clears a blocker, a manager qualifies a lead out — all from the
one page, by invoking the server actions that already exist, with no new business logic and without
touching the legacy pages.
This directly advances 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 cannot complete a lead-to-payout cycle from one page until the levers on that page actually move money and state. Enumerating them (stub 4) was the groundwork; wiring them (this stub) is what makes the cycle runnable.
Turn each cleanly-backed registry lever into a real, guarded mutation invoked from the Levers rail, reusing the server actions that already exist — nothing new in the domain, nothing removed from the legacy pages (both surfaces coexist).
How a click becomes a transition (the one wiring seam). Stub 4 kept every lever's action reference
server-side and shipped only a serialisable LeverView (id/label/intent/hint) to the client, so
the rail cannot call an action directly. This feature adds a single, explicit wiring seam:
A workspace-scoped server-action module (apps/web/components/workspace/actions.ts, new,
"use server"). One explicit thin action per wired lever, each of which: Zod-validates its input,
composes resolveActionContext / runActionBody (or delegates to the existing action, which already
does), invokes the existing action/service, and on success calls revalidateWorkspaceCache (and
revalidateServiceLeadCache) so the workspace re-derives. This is the call site the pre-existing
revalidateWorkspaceCache helper was written for (its own comment: "Stub 5 wires the action levers and
calls revalidateWorkspaceCache after a transition"). It is not an opaque validation/auth factory —
each action stays explicit, per the apps/web AGENTS rule; it is the thin adapter that (a) supplies the
workspace-tag invalidation the existing quote/invoice actions lack and cannot be edited to add, and
(b) resolves any id the aggregate doesn't already expose (e.g. the accepted quote's ObjectId, resolved
server-side from leadId — the aggregate exposes the quote's human id, not its _id), so the client
passes only serialisable primitives.
LeverView carries what the rail needs to act (apps/web/lib/workspace/levers.ts). Extend the
serialisable view with a wired marker, the wiring key that maps to the workspace action, an optional
form descriptor (which fields the lever needs), and the resolved target entity id where the lever
acts on a satellite the aggregate already exposes by _id (invoice, milestone, blocker, action item).
The predicate and the raw action reference stay server-side as today. Unwired levers carry
wired: false.
ActionButton / LeversRail invoke instead of preview (action-button.tsx, levers-rail.tsx).
previewLever is replaced by a real dispatch: a wired lever with no input calls its workspace action
directly; a wired lever needing input opens a small shared Zod-validated dialog
(lever-form.tsx, new) that collects the field(s), submits to the action, and surfaces success/error
via a sonner toast. Unwired levers render visibly disabled with an honest "not available here
yet" affordance (no silent no-op) — they are deferred, not broken.
Defence in depth (unchanged). The UI is gated by can(...) (stub 3/4), and every invoked
action keeps its own resolveActionContext({ allowedRoles }) → runActionBody → domain
assertTransition pipeline. An illegal transition can never persist even if the rail is wrong.
Refresh on success. After a successful action the workspace cache tag(s) for the lead are invalidated so the status chip, journey strip, sections and rail all re-derive without a manual reload.
The wired set (clean-reuse cut — see Decisions resolved at Define). Every lever below maps to an
existing action with a typed input → ActionResult shape and ids available from the aggregate (or
resolvable server-side from leadId):
quote.accept → acceptCustomerQuoteAction; quote.reject → rejectCustomerQuoteAction
(reason form). (Accepting the quote advances the lead awaiting_confirmation → work_in_progress via
the existing service — the happy-path spine step named in the AC.)invoice.approve / invoice.reject / invoice.dispute → the invoice-approvals
actions; invoice.pay → markInvoicePaidAction; invoice.submit → raiseInvoiceFromMilestoneAction.blocker.raise (form) / blocker.clear; actionItem.add (form) /
actionItem.complete; changeControl.record (form).pending→qualified_out & backlog→qualified_out → qualifyOutLead (reason
form); lead.flagRework → setLeadReworkAction; lead.archive → archiveLead.expert.rate → setExpertVendorRatingAction (1–5 score form).Each persona therefore has at least one lever that works end to end (customer: accept/reject quote, pay invoice; expert: raise invoice, raise/clear blocker; CSM/SDM: approve/reject/dispute invoice, qualify out, flag rework; admin: archive, qualify out; vendor: rate expert).
Activity/StatusHistory entry being written; no new domain/service code is added (reuse only).awaiting_confirmation → work_in_progress, and the workspace reflects the new stage.error message and persists nothing.resolveActionContext/assertTransition pipeline is rejected and nothing persists, even if the lever were shown — the UI can() gate is defence-in-depth, not the only check.revalidateWorkspaceCache (and the shared service-lead tag) is invalidated so the status chip, journey strip, sections and rail re-derive.lead.reject, expert.reassign, quote.approve, milestone.block/unblock, invoice.resolveDispute, escalation.raise, concern.raise, csat.view, lead.export, and the shape-mismatched/rich-form ones below) render visibly disabled with an honest affordance and perform no mutation — they are not silently no-op.pending→backlog, backlog→quotation_process, quotation_process→awaiting_confirmation, awaiting_confirmation→work_in_progress as a direct lever, awaiting_confirmation→quotation_process, work_in_progress→delivered, delivered→survey_sent, survey_sent→completed), plus lead.reject, expert.reassign, quote.approve, milestone.block, milestone.unblock, invoice.resolveDispute, escalation.raise, concern.raise, csat.view, lead.export.milestone.start / milestone.complete (backed only by the PATCH /api/milestones/[id]/status route, not a "use server" action); outreach.send (positional, CSM-queue-validated signature); message.team (customer-project-scoped { text }, does not target this lead); brd.approve / brd.signOff (need the full BRD text + a dedicated flow); csat.submit (full multi-field survey + redirect); proposal.submit / proposal.resubmit / proposal.delete / proposal.review (per-proposal, expert-onboarding-gated, and "submit a bid" is a create flow, not a draft toggle); lead.assignManager (needs a candidate-manager picker — data-backed, beyond a reason/amount form). brd.flag is also deferred with this group.next-best-action-polish).service-leads / projects / proposals / finance / BRD pages — epic-level out of scope (coexist, deprecate later).apps/demo — design source, ported, never a dependency.input → ActionResult; ids from the aggregate or resolvable
server-side from leadId; at most a simple reason/amount/score form). This strictly honors the epic's
"reuse only — no new business logic / no new server actions" rule and keeps the blast radius small.
The lever-registry build note's phrasing "stub 5 binds/creates the rest" is superseded by the stub's
and epic's explicit "reuse only": levers whose backing action does not cleanly exist are deferred
(rendered disabled), not created here. Every forward spine transition falls in this deferred set — no
"use server" action exposes a generic lead-status advance, and adding one would be new domain code.
(Steer: to widen to the shape-mismatched levers, move them from Out of scope into the wired set and
note the adapters needed.)lever-form.tsx) on the workspace page and submit to the wired action — no redirect to a legacy
page. This matches the stub's "a small Zod-validated form for levers that need a reason/amount" and
keeps the workspace self-contained/operable. (Steer: to shrink the form surface, defer the
multi-field levers — blocker / action-item / change-control — and wire only single-field ones.)"use server" wrapper in components/workspace/actions.ts delegates to the existing action and adds
the workspace-tag invalidation the existing actions lack — respecting the apps/web AGENTS rule
against a centralized validation/auth factory. Existing actions are invoked, never modified.Define read beyond the docs-only intake band (recorded per the intake contract): the current workspace
implementation (apps/web/lib/workspace/levers.ts, components/workspace/{action-button,levers-rail, workspace-view,aggregate}.tsx), the action pipeline (apps/web/lib/actions/index.ts), the cache
helpers (apps/web/lib/queries/{workspace,workspace-cache,service-leads-cache}.ts), the apps/web
AGENTS server-action rule, and an enumeration of the existing server actions across
components/{service-leads,proposals,finances} and app/(app)/{projects,customer,service-leads} — the
lever→action mapping is code, not prose, so the reuse-only scope had to be grounded in the real
signatures, the id shapes the aggregate exposes, and the uneven cache-invalidation coverage.
02_build/output/notes.mdfeat: wire-action-levers — LeverView.run wiring metadata + wiring tablefeat: wire-action-levers — workspace action wrappers (delegate + cache refresh)feat: wire-action-levers — shared lever form + rail dispatch, disabled unwiredtest: wire-action-levers — run-payload derivationapps/web/lib/workspace/levers.ts — extended the serialisable LeverView
with an optional run payload ({ key, args, form? }) and added the WIRING
table: lever id → dispatch key + a resolver that reads the single target entity id
from the aggregate (first invoice / billable milestone / open blocker / open action
item) + an optional Zod-collected form. deriveLevers now takes leadId (optional,
defaults "" so existing call sites/tests are unaffected) and bakes it into every
wired lever's run.args. A lever absent from WIRING, or whose target entity is
missing, gets no run → the rail renders it disabled. This is the clean-reuse cut:
every forward spine transition and the rich-form/shape-mismatched levers stay
unwired by design (see the spec's deferred set).apps/web/components/workspace/actions.ts (new, "use server") — one thin
wrapper per wired lever. Each invokes the existing server action (quote
accept/reject, invoice approve/reject/dispute/pay, raise-invoice, qualify-out,
flag-rework, archive, blocker raise/clear, action-item add/complete, change-control,
expert-rate) and, on success, calls revalidateWorkspaceCache +
revalidateServiceLeadCache. It is not an opaque auth/validation factory (the
apps/web AGENTS rule): the delegated action keeps its own
resolveActionContext → runActionBody → assertTransition pipeline unchanged; the
wrapper only adds the workspace-tag invalidation those actions lack (and cannot be
edited to add) and resolves the accepted quote's ObjectId server-side (the aggregate
exposes only the human quote id).apps/web/components/workspace/lever-form.tsx (new) — the one shared
Zod-collected dialog every input-taking lever opens (reason / blocker / action-item
/ change-control / expert score), built from @sustentus/ui primitives (Dialog,
Input, Textarea, Select, Label, Button). It hands raw string values back to the
rail; the workspace action validates them, so invalid input surfaces the action's
own error and persists nothing.apps/web/components/workspace/levers-rail.tsx — replaced the preview toast
with a real dispatch: an explicit DISPATCH map (run key → workspace action),
useTransition for pending state, success/error sonner toasts, and the form
dialog for input levers. Unwired levers render disabled (opacity-50,
cursor-not-allowed, "not available here yet" title) — never a silent no-op.apps/web/components/workspace/action-button.tsx — the primary CTA now takes
onActivate + disabled (removed previewLever); the rail owns dispatch.apps/web/components/workspace/workspace-view.tsx + app/(app)/workspace/[id]/page.tsx
— thread the route's leadId into deriveLevers.apps/web/lib/workspace/levers.test.ts — added run-payload tests (quote levers
wired + form-only-where-needed, invoice targeting + id threading, blocker form
fields, and an unwired spine lever with no run). 10/10 pass.Activity change needs the Vercel preview + a DB (no DB access in this build environment) — to confirm on the preview.lever-form.tsx + the delegated actions' Zod schemas.resolveActionContext/assertTransition pipeline; the UI can() gate is defence-in-depth only.revalidateWorkspaceCache (+ the shared service-lead tag) on success.run → disabled control) — never a silent no-op.leadId prop threaded through the workspace's own view/page; the legacy surfaces are untouched.apps/web unit tests (tsx --test lib/workspace/levers.test.ts) — 10/10 pass.lead.reject, expert.reassign, quote.approve, milestone.start/complete
(API-route only), milestone.block/unblock, invoice.resolveDispute,
escalation/concern, csat.view/submit, brd.*, proposal.*, message.team,
outreach.send, lead.assignManager (needs a candidate-manager picker),
lead.export. Because many positive forward levers are unwired, the primary CTA
is often shown disabled with a "not available here yet" caption — honest per the
clean-reuse cut, and stub 6 (next-best-action-polish) owns the empty/locked design.action refs on the registry are now superseded by the WIRING
table; left in place to keep this change focused (harmless, never crossed to the
client). A later cleanup can drop them.03_release/output/changelog.mdThe Actions panel on a lead's workspace now works for real — the buttons that used to only preview now perform the action and refresh the page to show the new state. Depending on your role you can accept or reject a quote, approve, reject, dispute or pay an invoice, raise an invoice against a delivered milestone, qualify a lead out, flag delivery for rework, raise or clear a blocker, add or complete an action item, record a scope change, or rate an expert — all from the one page, without hopping between screens. Anything you can't do at the current stage stays visible but greyed out, so it's always clear what's available to you now.
03_release/output/investor-update.mdWho it's for: All six roles. What shipped: The workspace's action levers now perform real transitions — accept a quote, approve or pay an invoice, clear a blocker — reusing existing logic. Why it matters: Running a lead from one page advances the lead-to-payout flow (Refine the Bridge · Q2 Objective 3).
Dig deeper: <merged-PR URL> · <changelog entry URL>
03_release/output/release.mddee59bc6 — Quality Project ✅, Migrate preview database ✅, Migrate production database skipped (no migration), Vercel web preview READY. mergeable_state clean; no review threads (only the Vercel preview bot comment).apps/docs page documents the lead workspace / levers (verified; same as the lever-registry precedent), and the change adds no app, package, route, env var, or build/CI step (the /workspace/[id] route + link button already existed from earlier stubs).feature-role-matrix), and no new service-journey step. The workspace becoming operable is a UI-surface milestone (captured in the changelog), not a change to the documented product model — consistent with the epic's "additive, coexist, document when primary" framing.WorkspaceLinkButton on the lead/project detail pages, open to all six personas). Changelog entry apps/help/app/changelog/2026-07-02-wire-action-levers/page.mdx; investor draft at 03_release/output/investor-update.md./code-review high (complexity: complex) — correctness (line-by-line, removed-behavior, cross-file) + conventions + cleanup finders, verified.
WIRING[].key has a DISPATCH entry; every form field name is read under the same args.* key; every wrapper calls its delegate with the exact shape its Zod schema expects. leadId threading is correct (page → view → deriveLevers → run.args). The previewLever removal left no dangling references. The apps/web AGENTS "no centralized validation/auth factory" rule is respected (the wrappers delegate; afterSuccess/refreshWorkspace do cache-invalidation only, no auth). UI-primitive and data-access import rules are compliant.action / LeverActionRef mechanism in levers.ts was dead (set-but-never-read, superseded by the WIRING table — exactly the layer stub 4 deferred to stub 5). Removed the type, the six lazy loaders, the action? fields on Lever/SpineLever/SatelliteLever, the six action: assignments, and the two conditional spreads in deriveLevers. Unit tests still 10/10.assertTransition/service guard, so nothing illegal persists; per-row targeting is the sections' job (out of scope per spec, and the aggregate exposes no raw per-invoice status to target more precisely — owned by stub 2). Change-control leaves timeline/cost blank → coerced to 0, which the delegate's own schema defines as "no change" (by design).refreshWorkspace re-resolves the action context (idempotent, negligible per click); secondary levers render a styled <button> rather than the shared Button (pre-existing from stub 4; the status-dot + left-aligned row diverges from a standard button).Activity change is confirmed on the Vercel preview (reviewer's check before the Ready-to-merge tick; no DB in the build environment).lever-form.tsx + delegate schemas.can() gate is not the only check.revalidateWorkspaceCache (+ shared service-lead tag) on success.run → disabled control.leadId prop; legacy surfaces untouched.