Skip to Content

← All archived runs

Run: csm-blocker-queue

run.md

Run: csm-blocker-queue

  • branch: claude/focused-thompson-x5he8y
  • pr: #511

00_intake/stub.md

Stub: CSM dashboard — go-live blockers queue + action strip

  • feature-slug: csm-blocker-queue
  • epic: csm-dashboard-data
  • personas: CSM (view + resolve); Customer / Expert / Vendor / Partner as blocking actors
  • initiative: Build the Bridge / objective: Q2 2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: csm-portfolio-foundation
  • sequence: 4 of 5

Problem

The GoLiveBlockersTable and the ActionStripSection are the heart of the dashboard and are entirely dummy (apps/web/lib/mock/csm.ts blockers, stageSLA). Each row is one blocking issue across a CSM's portfolio with: account, stage (BRD | Delivery | Awaiting CSAT), status (red | amber), blocker text, who (Customer | Expert | Vendor | Partner), owner, impact, timeStuckDays, escalationCount, value (revenue at risk), daysToValue, and a resolve action. The page sorts by SLA breach → closest-to-breach → highest value, and the action strip derives actor/escalation/oldest counts from the set. None of the underlying data is portfolio-queryable today; the blocker, escalation and SLA concepts are owned by the sibling epics.

Proposed change

  • Build a portfolio blocker query in @sustentus/services/server: all open blockers across the signed-in CSM's owned accounts (reusing the foundation's portfolio read), each carrying who, owner, impact, escalationCount (from the blocker/escalation model) and stage + timeStuckDays (from the SLA timing layer).
  • Enrich each row with value (revenue at risk — the account's contract value) and daysToValue (the account's daysToGoLive), reusing the customer epic's finance + health derivations.
  • Implement the SLA-breach sort (timeStuckDays − stageSLA[stage], breached first, then closest-to-breach, then highest value) server-side or from the real stageSLA targets.
  • Wire a resolve action (CSM marks a blocker resolved → drops off the queue).
  • Derive the action strip counts (total, blocked-by-Customer/Expert/Vendor, already-escalated, oldest-issue-days) from the same blocker set.

Acceptance criteria (rough)

  • The blockers table lists real open blockers across the CSM's accounts with who/owner/impact/stage, sorted by SLA breach then value.
  • value (revenue at risk) and daysToValue are real, reused from the finance + health derivations — not re-modelled here.
  • Per-stage SLA breach is computed from the real sla-stage-targets, not the mock stageSLA map.
  • Resolving a blocker removes it from the queue and updates the action-strip counts.
  • The action strip's actor/escalation/oldest figures all derive from the live blocker set.

Out of scope (this feature)

  • Defining or extending the blocker / escalation / SLA models — those are owned by customer-dashboard-data/project-blockers, expert-dashboard-data/work-blockers-escalations and expert-dashboard-data/sla-stage-targets; this feature reads them.
  • Raising/escalating blockers from the CSM dashboard beyond the resolve action (raise/escalate capture lives with the blocker model's feature).
  • The communication/outreach per blocker — that's csm-outreach-thread (feature 5).

Notes for Define

  • Coordinate with the sibling epics before building: confirm project-blockers + work-blockers-escalations (blocker + who + escalation) and sla-stage-targets (per-stage targets + time-in-stage) have landed; read them rather than forking. The mock's who includes Partner — ensure the extended actor enum covers it.
  • value reuses customer-dashboard-data/customer-finance-summary (totalContract over quotes/ invoices); daysToValue reuses project-health-engine daysToGoLive.
  • Decide whether "resolve" closes the underlying blocker record or only the CSM's view of it — prefer closing the shared record so the customer/expert views stay consistent.
  • touches: packages/services/src/server (portfolio blocker query + SLA sort + resolve), apps/web/components/dashboard/csm/{go-live-blockers-table,blocker-table-row,action-strip-section}.tsx.

01_define/output/spec.md

Spec: CSM dashboard — go-live blockers queue + action strip

  • slug: csm-blocker-queue
  • personas: CSM (view + resolve); Customer, Expert, Vendor, Partner as blocking actors
  • touches: packages/services/src/db/services/csm-portfolio, packages/services/src/db/models/blocker.ts, packages/services/src/server, apps/web/app/(app)/csm/dashboard/page.tsx, apps/web/components/dashboard/csm/{go-live-blockers-table,blocker-table-row,action-strip-section}.tsx
  • complexity: complex

Problem

The CSM "Go live control" dashboard's two headline sections — the go-live blockers table and the action strip — are entirely dummy, reading from apps/web/lib/mock/csm.ts (blockers, stageSLA). The CSM is the platform's exception manager: per the roles doc they "handle exceptions, not the standard flow," pushing the 5–10% of stuck accounts through to go-live. Without a real, portfolio-wide blocker queue sorted by SLA urgency, a CSM can't see which accounts across their book are breaching and worth chasing first. This advances Build the Bridge → Q2 2026 Objective 3 (Validate technical infrastructure & payout flow) (okrs/2026-Q2): the delivery → go-live stretch only works if the human exception path runs on real data.

Every upstream this feature reads has now landed, so it composes them rather than re-building:

  • csm-portfolio-foundation (PR #512) — lead.csm ownership + csmPortfolioService.getPortfolioForCsm(tenantId, csmUserId, scope) returning a CsmPortfolioEnvelope (header + accounts[], each { id, account, region, status, customerId }), with the "My accounts" (mine) vs "all" (tenant) scope toggle. This is the read the queue hangs off.
  • blocker.ts model + BlockerService (raise/escalate/resolve/listOpenByLeads) — PR #482 / #464, carrying label, owner, impact, status, escalationCount, escalated, lastEscalatedAt.
  • sla-definition.ts + SlaConfigService + deriveLeadSlaTimingForLeads() (per-stage targets + time-in-stage / overdue) — PR #463.
  • customer-finance-summary (totalContract aggregate over quotes/invoices, per customer) — PR #502.

Proposed change

Replace the mock in the blockers table and action strip with a real portfolio blocker read for the signed-in CSM, composed from the landed services. Functionally:

  • Portfolio blocker read in @sustentus/services/server: reuse csmPortfolioService's CSM-scoped account selection (same scope the page already resolves — mine vs tenant), then BlockerService.listOpenByLeads() over that account set to get every open blocker. Each row carries the blocker's label, owner/actor, impact, escalationCount, plus stage + timeStuckDays from deriveLeadSlaTimingForLeads(), and the account name/region from the portfolio envelope.
    • Integration detail (settled): blockers key on lead (_id), but CsmPortfolioAccount does not surface the lead id today. Extend the foundation's envelope with a leadId per account (a small, coordinated addition to csmPortfolioService — reusing its existing scoped lead query, not forking it) so blockers join cleanly to portfolio accounts.
  • SLA-breach sort computed from the real SLA targets, not the mock stageSLA map: breached first (timeStuckDays − targetDays > 0), then closest-to-breach, then highest value.
  • Resolve action: a CSM resolves a blocker via BlockerService.resolve(), which closes the shared blocker record (so the customer/expert views stay consistent) and drops the row off the queue; the action-strip counts recompute.
  • Action strip derived from the same live blocker set: total open, blocked-by-Customer / -Expert / -Vendor, already-escalated, oldest-issue-days.
  • Actor enum: extend the blocker model's owner enum to include Vendor (coordinated with the blocker-model feature) so the action strip's blocked-by-Vendor count is real; the per-row displayed actor maps from owner.
  • Value enrichment (best-effort): value (revenue at risk) is derived from the landed customer-finance-summary totalContract, keyed by each account's customerId from the portfolio envelope; daysToValue degrades to "—" until project-health-engine lands. No fabricated numbers — a row renders without these when the source isn't there.

Acceptance criteria

  • The blockers table lists real open blockers across the signed-in CSM's portfolio (via csmPortfolioService.getPortfolioForCsmBlockerService.listOpenByLeads), each row showing actor/owner, impact, stage and time-stuck — no data from lib/mock/csm.ts.
  • The queue honours the page's existing scope toggle — mine shows the CSM's owned accounts' blockers, tenant shows the whole tenant's.
  • Rows are sorted by SLA breach computed from the real sla-definition targets (deriveLeadSlaTimingForLeads): breached first, then closest-to-breach, then highest value — not the mock stageSLA map.
  • Resolving a blocker calls BlockerService.resolve(), closes the shared record, removes the row from the queue, and recomputes the action-strip counts.
  • The action strip's figures (total, blocked-by-Customer/-Expert/-Vendor, already-escalated, oldest-issue-days) all derive from the live blocker set.
  • The blocker owner enum includes Vendor, and the blocked-by-Vendor action-strip count reflects real Vendor-owned blockers.
  • value is derived from the real finance aggregate per account's customerId, and daysToValue shows "—" when project-health-engine is absent — neither is mocked or fabricated.

Out of scope

  • Redefining the blocker / escalation / SLA / finance models, or the portfolio foundation, beyond the two small coordinated additions named above (owner += Vendor; leadId on the portfolio account envelope) — everything else is read.
  • Building project-health-engine or a portfolio finance aggregate — daysToValue degrades and value is derived per-account from the landed customer finance summary.
  • Raising/escalating blockers from the CSM dashboard beyond the resolve action (raise/escalate capture lives with the blocker model's feature).
  • The communication/outreach thread per blocker — that's csm-outreach-thread (feature 5).
  • The activation-status table and its snapshot — those are csm-activation-rollup / csm-portfolio-snapshot.

Open questions

  • None blocking. Before Build, bring the run branch up to date with main (the foundation merged in #512) so csmPortfolioService and the blocker/SLA/finance services are present to build against.

02_build/output/notes.md

Build notes: csm-blocker-queue

  • commits: feat: csm-blocker-queue — portfolio blocker read service + model/envelope extensions; feat: csm-blocker-queue — wire real go-live blockers table + action strip + resolve

What changed

Services (@sustentus/services/server)

  • db/models/blocker.ts: extended the BlockerOwner enum + BLOCKER_OWNERS with Vendor, so the action strip's blocked-by-Vendor count reflects real vendor-owned blockers (the one coordinated model touch the spec allows).
  • db/services/csm-portfolio/index.ts: added leadId to CsmPortfolioAccount (sourced from the lead's _id in the existing scoped query — no new query), so portfolio-dependent features can join per-lead records to accounts. The foundation's envelope, extended not forked.
  • db/services/csm-blocker-queue/ (new): csmBlockerQueueService.getBlockerQueueForCsm(tenantId, csmUserId, scope) composes the landed services — reuses csmPortfolioService for the scoped account set (mine/tenant), blockerService.listOpenByLeads for open blockers, deriveLeadSlaTimingForLeads for stage + time-in-stage + per-stage target, and a best-effort accepted-quote total (proposalServicequoteService) for value. Returns { rows, stageSLA }. daysToValue is null (no project-health-engine) and value is null when no finance source — both render "—", never fabricated.
  • Exported the new service + types (CsmBlockerQueue, CsmBlockerQueueRow) from the services barrel.

Web (apps/web)

  • app/(app)/csm/dashboard/page.tsx: fetches the blocker queue alongside the portfolio (one resolver, Promise.all) and renders the new CsmSections with real rows + stageSLA + the resolve action.
  • app/(app)/csm/dashboard/actions.ts (new): resolveBlockerAction server action — tenant- and CSM-scoped blockerService.resolve, returns { ok } so the queue can drop the row.
  • components/dashboard/csm/csm-sections.tsx (new, replaces csm-mock-sections.tsx): the action strip and go-live blockers table run on the real queue (client-side sort by SLA breach → closest → highest value, action-strip tallies, optimistic resolve-then-drop). The activation rollup and communication thread stay on mock — they belong to csm-activation-rollup / csm-outreach-thread.
  • components/dashboard/csm/blocker-table-row.tsx: BlockerRow now carries region (secondary label), stage: string, nullable daysToValue/value (render "—"), and a wired Resolve button.
  • components/dashboard/csm/go-live-blockers-table.tsx: the SLA footer is built from the real stageSLA targets, not the hardcoded mock map.

Acceptance criteria status

  • Blockers table lists real open blockers across the CSM's portfolio (csmPortfolioServiceblockerService.listOpenByLeads), each row showing actor/owner, impact, stage and time-stuck — no lib/mock/csm.ts data in the table.
  • Honours the scope toggle — mine vs tenant flows from the page's ?scope= param into the portfolio read, so the queried account set (and its blockers) changes.
  • Sorted by SLA breach from the real sla-definition targets (deriveLeadSlaTimingForLeads): breached → closest-to-breach → highest value.
  • Resolve calls BlockerService.resolve() (via the server action), closes the shared record, drops the row and recomputes the action-strip tallies.
  • Action-strip figures (total, blocked-by-Customer/-Expert/-Vendor, already-escalated, oldest-issue-days) all derive from the live blocker set.
  • Blocker owner enum includes Vendor; blocked-by-Vendor counts real vendor-owned blockers.
  • value from the real accepted-quote total per blocked lead; daysToValue shows "—" (no project-health-engine) — neither fabricated.

Verify result

  • Mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. No check is expected to fail.

Notes for review

  • Section order / split: the action strip + blockers table are now one real client component (CsmSections); the activation table and communication stay mock in the same component, in the original order (action strip → activation → blockers → communication). The mock activation table's CSM filter no longer cross-filters the real blocker rows (it only filtered mock identities).
  • value cost: revenue-at-risk is resolved once per blocked lead (not per blocker, not per account) via the accepted quote — best-effort per the spec. If a portfolio finance read lands later, swap valueForLead for it.
  • Stage labels: rows show the real slaStage (Request/BRD/Bidding/Delivery/Billing/CSAT), falling back to the status display name when a status doesn't map to an SLA stage; such rows simply never flag a breach (no configured target).

03_release/output/changelog.md

Changelog: csm-blocker-queue

Published at apps/help/app/changelog/2026-06-23-csm-blocker-queue/page.mdx.

You can now see every open go-live blocker across your accounts in a single queue, sorted so the closest SLA breach and the most revenue at risk sit at the top. Each row shows who is blocking, how long it has been stuck, and the stage it is held up in — and you can mark a blocker resolved the moment it clears, which drops it from the queue and updates the at-a-glance counts of who is blocking what.

03_release/output/investor-update.md

CSMs now work every go-live blocker from one prioritised queue

Who it's for: Customer success managers What shipped: A live queue of every open go-live blocker across a CSM's accounts, sorted by SLA breach and revenue at risk, with one-click resolve. Why it matters: Faster unblocking keeps lead-to-payout cycle time down — our Build the Bridge goal to validate the delivery infrastructure.

Each row's urgency and revenue-at-risk are computed from live SLA and finance data, not estimates.

Dig deeper: https://github.com/sustentus/sustentus/pull/511 · https://help.sustentus.com/changelog/2026-06-23-csm-blocker-queue

03_release/output/release.md

Release: csm-blocker-queue

  • pr: #511 · merged: yes — squash ba7bdc3 onto main (2026-06-23)
  • CI: code gates green (typecheck/lint/format) after fixing a real regression — adding Vendor to the BlockerOwner enum broke lead-blockers' hand-narrowed BlockerItem.owner; fixed by typing it from the canonical BlockerOwner. One earlier web preview failure was a Turbopack module-eval error in the unrelated /api/brd/chat route (cache flake); re-triggered via the main merge.
  • technical docs: no technical docs impact — the blocker queue is composed from existing services (csmPortfolioService, blockerService, SLA timing, finance); the new csmBlockerQueueService, the owner += Vendor enum extension and the leadId/csm envelope fields are internal and not described at service/field granularity in apps/docs/app/technical/**. No app, route, env var, or architecture change.
  • business docs: no business docs impact — feature-role-matrix/projects already grants the CSM "View project" / "Resolve blocker"; this feature surfaces those on the dashboard against real data, it adds no new persona capability or permission.
  • release notes: both — changelog entry (apps/help/app/changelog/2026-06-23-csm-blocker-queue/) + investor draft in this PR.
  • deploy: READY — production web (platform.sustentus.com) and help (help.sustentus.com) both green on ba7bdc3.
  • sent: investor update sent to 2 recipients on 2026-06-23, after the green production deploy.

Merge reconciliation (main moved during the run)

csm-activation-rollup + project-health-engine merged to main and touched the same CSM dashboard files. Resolved by merging both real wirings into one CsmSections component: the activation rollup table (real, from getActivationRollup), the action strip + go-live blockers table (real, this feature) and the still-mock communication thread. Threaded the owning CSM onto the portfolio account + blocker row so the activation table's CSM filter narrows the real blocker queue (tenant scope).

Review summary

/code-review (high effort) over the feature surface — 3 findings:

  • No-SLA-target treated as a breach (fixed on branch). A stage with no configured stageSLA entry fell back to sla = 0, so any timeStuckDays > 0 flagged a false SLA breach (corrupting the breach-first sort) and isApproaching (>= sla − 1>= −1) was always true. Added guarded, shared overdueDays / slaRemaining helpers (exported from blocker-table-row, reused in the csm-sections sort): a missing target is never overdue and sorts after targeted stages.
  • Action strip omits Your team / Partner owners (accepted — to spec). The strip surfaces exactly the three figures the spec names (blocked-by-Customer/-Expert/-Vendor); the component has three external-actor columns by design. count is all open blockers; the three are an intentional subset.
  • Verified non-issues: value ??/+ precedence is correct; bandToStatus null handling; lead-id map keying via .toString(); the CsmBlockerQueueRowBlockerRow structural contract; the barrel ordering fix; filterCsmrow.csm both via fullName.

Acceptance check (vs spec)

  • Blockers table lists real open blockers across the CSM's portfolio (csmPortfolioServiceblockerService.listOpenByLeads), no mock data.
  • Honours the mine/tenant scope toggle.
  • Sorted by SLA breach from the real sla-definition targets.
  • Resolve closes the shared blocker record and drops the row, recomputing the action strip.
  • Action-strip figures derive from the live blocker set.
  • Blocker owner enum includes Vendor; blocked-by-Vendor counts real vendor-owned blockers.
  • value from the real finance aggregate; daysToValue shows "—" (no project-health-engine) — neither fabricated. (project-health-engine has since merged to main; daysToValue can be wired in a follow-up — this run ships the degrade as specced.)