Skip to Content

← All archived runs

Run: wire-action-levers

run.md

Run: wire-action-levers

  • branch: claude/pipeline-wire-action-levers-0o3uel
  • pr: #571

00_intake/stub.md

Stub: Wire action levers — make the levers perform real transitions

  • feature-slug: wire-action-levers
  • 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: lever-registry
  • sequence: 5 of 6

Problem

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

Proposed change

Wire each registry lever to its bound server action so it performs the real transition:

  • Invoke existing actions — on lever click, call the existing server action (proposal accept/reject, quote accept/reject, BRD approve/flag, invoice approve/reject/dispute/pay, milestone start/complete/ block/unblock, assign manager, qualify out, archive, etc.), passing the lead/satellite id and any required form input (a small Zod-validated form for levers that need a reason/amount).
  • Defence in depth — the UI is gated by can(), and every action keeps its existing resolveActionContext({ allowedRoles })runActionBodyassertTransition pipeline (unchanged), so an illegal transition can never persist even if the UI is wrong.
  • Refresh on success — invalidate the lead's cache tag(s) so the workspace (status chip, journey strip, sections, rail) re-derives after a transition; surface success/error feedback.
  • The existing detail pages keep calling the same actions — both surfaces coexist (no redirects, no deletions).

Acceptance criteria (rough)

  • Pulling a lever invokes its existing server action and performs the real transition (verified by the lead's status/satellite changing and an Activity/StatusHistory entry being written).
  • Every persona's levers work end-to-end for at least one full happy-path stage progression (e.g. quote accept → work in progress).
  • Server-side guards are intact: an action denied by resolveActionContext/assertTransition is rejected even if the lever were shown.
  • After a successful transition the workspace reflects the new state without a manual reload (cache tag invalidated).
  • No existing server action is modified and no existing page is touched/redirected; both surfaces still work.

Out of scope (this feature)

  • Next-best-action CTA emphasis, per-persona empty/locked states, unhappy-path-only levers — stub 6.
  • New business logic / new server actions (reuse only).
  • Consolidating or redirecting the legacy detail pages (later epic).

Notes for Define

  • Map each lever id → existing action: enumerate from apps/web/components/{service-leads,proposals,finances}/**/actions.ts and apps/web/app/(app)/{projects/brd,customer/satisfaction}/**/actions.ts.
  • Reuse cache-tag conventions (apps/docs/app/technical/cache-tags) for invalidation.
  • Keep server actions explicit — do not introduce an opaque auth/validation factory (the apps/web AGENTS rule).
  • touches: apps/web/components/workspace/**, apps/web/lib/workspace/levers.ts, lever form components.

01_define/output/spec.md

Spec: Wire action levers — make the levers perform real transitions

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

Problem

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

Proposed change

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

  • Quotequote.acceptacceptCustomerQuoteAction; quote.rejectrejectCustomerQuoteAction (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.)
  • Invoiceinvoice.approve / invoice.reject / invoice.dispute → the invoice-approvals actions; invoice.paymarkInvoicePaidAction; invoice.submitraiseInvoiceFromMilestoneAction.
  • Delivery cross-cuttingblocker.raise (form) / blocker.clear; actionItem.add (form) / actionItem.complete; changeControl.record (form).
  • Lead / oversightpending→qualified_out & backlog→qualified_outqualifyOutLead (reason form); lead.flagReworksetLeadReworkAction; lead.archivearchiveLead.
  • Vendorexpert.ratesetExpertVendorRatingAction (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).

Acceptance criteria

  • Pulling a wired lever invokes its existing server action and performs the real transition — verified by the lead's status or the target satellite changing and an Activity/StatusHistory entry being written; no new domain/service code is added (reuse only).
  • At least one full happy-path progression works end to end for each persona through the rail — at minimum the customer accepting the quote moves the lead awaiting_confirmation → work_in_progress, and the workspace reflects the new stage.
  • Levers that need input (reject/qualify-out reason, blocker label+owner+impact, action-item label+due+urgency, change-control change+timeline+cost, expert score) collect it via a shared Zod-validated dialog in the rail and submit it to the action; invalid input surfaces the action's field/error message and persists nothing.
  • Server-side guards are intact: an action denied by its 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.
  • After a successful transition the workspace reflects the new state without a manual reload — revalidateWorkspaceCache (and the shared service-lead tag) is invalidated so the status chip, journey strip, sections and rail re-derive.
  • Unwired levers (no cleanly-callable existing action — all forward spine transitions, 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.
  • No existing server action is modified and no existing page is touched, redirected, or deleted; the legacy detail-page surfaces still work. Success/error feedback is shown for every wired lever.

Out of scope

  • Creating any new server action or domain logic. Levers with no cleanly-callable existing action stay unwired (deferred): every forward spine transition (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.
  • Shape-mismatched / rich-flow levers, deferred this run: 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.
  • Per-row / multi-instance satellite targeting. A rail lever acts on the lead or the single unambiguous target the aggregate surfaces; choosing among several open invoices/milestones/blockers/bids belongs in the section rows, not the rail — a later concern.
  • Reshaping the aggregate loader beyond the minimal id/precondition additions the wired levers need (raw statuses, extra satellite fields) — owned by stub 2.
  • Next-best-action CTA polish, per-persona empty/locked-state design, and unhappy-path-only levers — stub 6 (next-best-action-polish).
  • Consolidating/redirecting the legacy service-leads / projects / proposals / finance / BRD pages — epic-level out of scope (coexist, deprecate later).
  • Importing anything from apps/demo — design source, ported, never a dependency.

Open questions

  • none. The two forks this stub left open are resolved under Decisions resolved at Define below, and both are steerable by editing this spec before the Spec approved gate.

Decisions resolved at Define

  1. Wiring scope = clean-reuse cut (not maximal, not deep-link). Wire only levers backed by a cleanly-callable existing action (typed 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.)
  2. Lever input = inline dialog forms. Levers needing input open a small shared Zod-validated dialog (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.)
  3. Wiring seam = explicit thin workspace actions, not an opaque dispatcher. A per-lever "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.

Context budget

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

Build notes: wire-action-levers

  • commits:
    • feat: wire-action-levers — LeverView.run wiring metadata + wiring table
    • feat: wire-action-levers — workspace action wrappers (delegate + cache refresh)
    • feat: wire-action-levers — shared lever form + rail dispatch, disabled unwired
    • test: wire-action-levers — run-payload derivation

What changed

  • apps/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.

Acceptance criteria status

  • Pulling a wired lever invokes its existing server action and performs the real transition — implemented (delegates to the existing action; derivation unit-tested). End-to-end status/Activity change needs the Vercel preview + a DB (no DB access in this build environment) — to confirm on the preview.
  • At least one full happy-path progression per persona — implemented: each persona has ≥1 wired lever (customer: accept quote → WIP, pay invoice; expert: raise invoice, raise/clear blocker; csm/sdm: approve/reject/dispute invoice, qualify out, flag rework; admin: archive, blocker/action-item/change-control; vendor: rate expert). Live progression to confirm on the preview.
  • Levers needing input collect it via a shared Zod-validated dialog and submit to the action; invalid input surfaces the action's error and persists nothing — lever-form.tsx + the delegated actions' Zod schemas.
  • Server-side guards intact — the wrappers delegate to the unchanged existing actions, each keeping its resolveActionContext/assertTransition pipeline; the UI can() gate is defence-in-depth only.
  • After a successful transition the workspace reflects the new state without a manual reload — every wrapper calls revalidateWorkspaceCache (+ the shared service-lead tag) on success.
  • Unwired levers render visibly disabled with an honest affordance and perform no mutation (no run → disabled control) — never a silent no-op.
  • No existing server action is modified and no existing page is touched/redirected/deleted — only new workspace files + a leadId prop threaded through the workspace's own view/page; the legacy surfaces are untouched.

Verify result

  • apps/web unit tests (tsx --test lib/workspace/levers.test.ts) — 10/10 pass.
  • Format · lint · typecheck · build run in CI + the Vercel preview (per the Build contract, not re-run here). No check is known to fail.

Notes for review

  • Clean-reuse cut, as specced. Wired = only levers with a cleanly-callable existing action. Deferred (rendered disabled): all forward spine transitions, 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.
  • Single-target simplification. A satellite lever acts on the first target the aggregate surfaces (first invoice / open blocker / open action item; first done milestone else first). Choosing among several is the sections' job (out of scope per the spec); the server guard rejects a wrong target, so nothing illegal persists.
  • Quote ObjectId is resolved server-side (accepted proposal → quote) because the aggregate exposes only the human quote id — no aggregate reshaping needed.
  • The stub-4 lazy 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.md

Take action on a lead straight from its workspace

  • personas: customer, expert, CSM, SDM, admin, vendor
  • live entry: apps/help/app/changelog/2026-07-02-wire-action-levers/page.mdx

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

Leads can now be actioned end-to-end from one workspace

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

Release: wire-action-levers

  • pr: #571 (https://github.com/sustentus/sustentus/pull/571) · merged: pending — both gates ticked (Spec approved + Ready to merge); squash-merging in this stage now CI is green on the cleanup commit.
  • CI: green on the cleanup commit dee59bc6 — 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).
  • technical docs: no technical docs impact — no 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).
  • business docs: no business docs impact — the levers reuse existing capabilities and existing server actions on an existing additive route; no new capability, no change to who-can-do-what (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.
  • release notes: both — user-facing behaviour changed (the workspace is now operable and is reachable via the 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.
  • 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 both, so it sends after a READY deploy.

Review summary

/code-review high (complexity: complex) — correctness (line-by-line, removed-behavior, cross-file) + conventions + cleanup finders, verified.

  • Verified clean: the three cross-file mappings are consistent — every 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.
  • CONFIRMED cleanup → fixed on branch: the stub-4 lazy 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.
  • Accepted (no change — documented): single-target simplification — an entity-backed lever acts on the first candidate the aggregate surfaces (first invoice / open blocker / open action item; first done-else-first milestone). A wrong target is rejected by the delegate's 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).
  • Accepted (minor): 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).

Acceptance check (vs spec)

  • Pulling a wired lever invokes its existing server action and performs the real transition — wiring verified by unit tests + code review; end-to-end status/Activity change is confirmed on the Vercel preview (reviewer's check before the Ready-to-merge tick; no DB in the build environment).
  • At least one full happy-path progression per persona — each persona has ≥1 wired lever (customer: accept quote → WIP, pay invoice; expert: raise invoice, raise/clear blocker; csm/sdm: approve/reject/dispute invoice, qualify out, flag rework; admin: archive, blocker/action-item/change-control; vendor: rate expert).
  • Input levers use a shared Zod-validated dialog; invalid input surfaces the action's error and persists nothing — lever-form.tsx + delegate schemas.
  • Server-side guards intact (defence in depth) — wrappers delegate to unchanged existing actions; UI can() gate is not the only check.
  • Successful transition refreshes the workspace without a manual reload — revalidateWorkspaceCache (+ shared service-lead tag) on success.
  • Unwired levers render disabled with an honest affordance, no mutation — no run → disabled control.
  • No existing server action modified, no existing page touched/redirected/deleted — only new workspace files + a leadId prop; legacy surfaces untouched.