Skip to Content

← All archived runs

Run: sdm-coverage-risk-outreach

run.md

Run: sdm-coverage-risk-outreach

00_intake/stub.md

Stub: SDM dashboard — coverage risk + outreach (risks table + action strip)

  • feature-slug: sdm-coverage-risk-outreach
  • epic: sdm-dashboard-data
  • personas: SDM
  • initiative: Build the Bridge / objective: Q2 2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: sdm-supply-coverage
  • sequence: 5 of 8

Problem

The go-live risks table (goLiveRisks in apps/web/lib/mock/sdm.ts, rendered by risks-table.tsx) and the action strip (actionStrip, action-strip.tsx) are the exception view over the under-covered cells: each risk row carries riskStatus, an impact ("Cannot quote" / "Weak bid" / "Delayed go live"), an actionState ("Auto outreach" / "Manual escalation" / "Monitoring" / "Covered"), a timeBand and expertsMsgd; the action strip rolls up revenueAtRisk, immediateRisks (+ descriptions) and gapsNotCovered. The coverage output from feature 4 has the shortfall/RAG, but there is no risk classification, no outreach state, and no record of which experts were messaged.

Proposed change

  • Derive risk rows from the under-covered cells: map shortfall + time-band + stage to an impact (no bid-ready experts at quote stage → "Cannot quote"; thin coverage at bid → "Weak bid"; near-start shortfall → "Delayed go live") and a riskStatus RAG.
  • Add a lightweight outreach/action state per risk cell — actionState plus the expertsMsgd list — so the SDM can record that experts were contacted (auto-outreach / manual-escalation / monitoring). State only — no automated messaging engine this round.
  • Derive the action strip from the same set: revenueAtRisk (value of the at-risk cells), immediateRisks (+ the top descriptions) and gapsNotCovered (uncovered cell count).
  • Wire the risks table and the action strip.

Acceptance criteria (rough)

  • Under-covered cells produce risk rows with a derived impact + riskStatus over the SDM's area.
  • Each risk cell carries an actionState and an expertsMsgd list the SDM can set; state persists.
  • The action strip's revenueAtRisk, immediateRisks (+ descriptions) and gapsNotCovered derive from the risk set.
  • The risks table and action strip render from real data — no lib/mock/sdm.

Out of scope (this feature)

  • An automated outreach/escalation engine (auto-messaging, reminders) — the actionState/expertsMsgd are recorded, not actioned automatically.
  • The revenue/margin analytics (feature 8) — the action strip's revenueAtRisk is a thin roll-up of the at-risk cells; the full breakdown lives in service performance.

Notes for Define

  • Pin the impact mapping (which shortfall/stage/time-band combination yields cannot-quote vs weak-bid vs delayed-go-live) — this is SDM policy, keep it with the coverage thresholds from feature 4.
  • Decide where revenueAtRisk per cell comes from (cell demand × avg job value, or the leads' quote value) — align it with feature 8's revenueAtRisk so the two agree.
  • touches: packages/services/src/server (risk derivation + a small outreach-state model/field), apps/web/components/dashboard/sdm/{risks-table.tsx,action-strip.tsx}.

01_define/output/spec.md

Spec: SDM dashboard — coverage risk + outreach

  • slug: sdm-coverage-risk-outreach
  • personas: SDM
  • touches: packages/services/src/db/models (new outreach-state model), packages/services/src/db/services/sdm (risk + action-strip derivation), apps/web/components/dashboard/sdm/{risks-table.tsx, action-strip.tsx}, apps/web/app/(app)/sdm/dashboard/page.tsx
  • complexity: standard

Problem

The SDM "Go live readiness" dashboard (apps/web/app/(app)/sdm/dashboard/page.tsx) carries an exception view over the supply↔demand grid: the go-live risks table (apps/web/components/dashboard/sdm/risks-table.tsx) and the action strip (action-strip.tsx). Today both read dummy data from apps/web/lib/mock/sdm.ts (goLiveRisks, actionStrip). The upstream supply-coverage feature classifies each grid cell's coverage (availableExperts / shortfall / coverage RAG), but there is no risk classification over the gaps, no outreach/action state, and no record of which experts were messaged. The SDM cannot see, in one place, which under-covered cells will actually stop customer work from progressing, how much revenue is exposed, or what has been done about each gap — the last mile of the supply-readiness cockpit that advances Build the Bridge / Q2 2026 Objective 3 — Validate Technical Infrastructure & Payout Flow (a trustworthy supply-coverage view is what lets the SDM keep the bridge fulfillable).

Proposed change

Derive a risk + outreach layer over the under-covered coverage cells (from feature sdm-supply-coverage), scoped to the signed-in SDM's area, and persist a per-cell outreach state.

  • Risk rows. For each under-covered cell, derive an impact and a riskStatus RAG using a coverage-driven rule (see Impact mapping below). Each row carries the cell key (vendor/platform, product/module, region, language, skillLevel), demand, availableExperts, shortfall, riskStatus, impact, timeBand, plus the outreach fields below. Covered cells (shortfall 0) resolve to riskStatus: green / actionState: Covered and are filtered out of the active list by the table as today.
  • Outreach / action state (state only). Persist, per cell, an actionState (Auto outreach | Manual escalation | Monitoring | Covered) and an expertsMsgd list. actionState defaults from risk (red → Auto outreach, amber → Monitoring, green → Covered); the SDM can override it and add experts to expertsMsgd. The state persists across reloads and is the SDM's record that experts were contacted. No automated messaging engine this round — the state is recorded, not actioned.
  • Action strip. Derive revenueAtRisk, immediateRisks (+ immediateRiskDescriptions) and gapsNotCovered from the same risk set: revenueAtRisk = sum of the at-risk cells' exposed value (Revenue basis below); immediateRisks = count of red-status risks in the Immediate time band, with the top descriptions; gapsNotCovered = count of cells with shortfall > 0.
  • Wire the risks table and action strip to this real data and drop their reads from lib/mock/sdm.

Impact mapping (coverage-driven rule, SDM policy)

Pinned here next to feature 4's coverage thresholds; applied per under-covered cell:

  • Cannot quote — no bid-ready experts available for the cell (availableExperts empty): the vendor cannot even form a quote. riskStatus: red.
  • Weak bid — at least one bid-ready expert but shortfall > 0 (thin coverage, the bid is weak). riskStatus: red when the shortfall is the majority of demand, otherwise amber.
  • Delayed go live — coverage is met or marginal (shortfall <= 0 or a single-expert margin) but the cell sits in the Immediate time band, so a near-start gap risks slipping go-live. riskStatus: amber.
  • Otherwise (covered, not near-start) — riskStatus: green, impact empty.

Revenue basis (revenueAtRisk)

Per at-risk cell, use the actual quote/proposal value of the at-risk leads in that cell; for Cannot quote cells that have no quote yet, fall back to demand × tenant average job value. The action strip's revenueAtRisk is the formatted sum across at-risk cells. This basis is the one feature 8 (sdm-service-performance) must reuse so the two figures agree — recorded here as the shared convention.

Acceptance criteria

  • Under-covered cells over the signed-in SDM's area produce risk rows, each with a derived impact and riskStatus per the coverage-driven rule above (cannot-quote / weak-bid / delayed-go-live).
  • Each risk cell carries an actionState (defaulted from risk, SDM-overridable) and an expertsMsgd list the SDM can set; the state persists across reloads (per tenant + cell).
  • The action strip's revenueAtRisk (quote value, avg-job-value fallback), immediateRisks (+ descriptions) and gapsNotCovered all derive from the same risk set and agree with the rows.
  • The risks table and action strip render from the SDM read service — no imports from apps/web/lib/mock/sdm.

Out of scope

  • An automated outreach/escalation engine (auto-messaging, reminders, auto-triggered outreach on red) — actionState / expertsMsgd are recorded, not actioned.
  • The full revenue/margin analytics (feature 8, sdm-service-performance) — the action strip's revenueAtRisk is a thin roll-up of the at-risk cells; the per-region/product/expert-type breakdown lives in service performance.
  • Redefining the upstream coverage classification (feature 4) or the demand/area/taxonomy layers (features 1–3) — this feature consumes their cell output and adds the risk + outreach layer on top.
  • Replicating any change back into apps/dashboards — that app stays the dummy source of truth.

Open questions

  • none. (Sequencing note, non-blocking: Build consumes the coverage cells produced by sdm-supply-coverage (feature 4) and the area-scoped read envelope from sdm-area-foundation (feature 2). Those upstream runs are not yet in pipeline/runs/; Build for this feature should follow them, reading their cell output rather than forking it. This does not change what is specified here.)

02_build/output/notes.md

Build notes: sdm-coverage-risk-outreach

  • commits: feat(sdm-coverage-risk-outreach): go-live risk + outreach layer over coverage cells

What changed

  • packages/services/src/db/models/sdm-outreach-state.ts (new): persists the SDM's per-cell outreach disposition — actionState (Auto outreach | Manual escalation | Monitoring | Covered) + expertsMsgd[], keyed (tenantId, sdm, platform, product, region, language) with a unique compound index. Exports OUTREACH_ACTION_STATES (the canonical list) and OutreachActionState. Same plugin set as tenant-setting (schema/softDelete/tenant). State only — nothing is actioned.
  • packages/services/src/db/services/sdm-risk/ (new): SdmRiskService.getRiskOverviewForSdm(tenantId, sdmId, scope) returns { risks, actionStrip } from one risk set so the two always agree.
    • Consumes sdmCoverageService.getCoverageForSdm for the cells (no re-aggregation of demand/supply).
    • deriveRisk applies the spec's coverage-driven rule per cell: availableExperts === 0Cannot quote (red); shortfall > 0Weak bid (red when shortfall is the majority of demand, else amber); covered but ImmediateDelayed go live (amber); otherwise green.
    • Merges the persisted outreach state — actionState defaults from risk (red → Auto outreach, amber → Monitoring, green → Covered) and is overridden by any saved value; expertsMsgd from the saved record.
    • actionStrip: revenueAtRisk = Σ at-risk cells' exposed value (actual quote value per cell, falling back to demand × tenant avg job value for cannot-quote cells with no quote); immediateRisks = red risks in the Immediate band (+ top-3 descriptions); gapsNotCovered = cells with shortfall > 0.
    • setOutreachState upserts a cell's disposition (the mutation behind the UI).
  • packages/services/src/db/services/index.ts + models/index.ts: barrel exports for sdmRiskService (after its sdm-coverage dependency) + the risk/outreach types and the new model.
  • apps/web/app/(app)/sdm/dashboard/actions.ts (new): setSdmOutreachState server action — Zod-validated, allowedRoles: ["sdm"], calls the service then revalidatePath("/sdm/dashboard").
  • apps/web/components/dashboard/sdm/outreach-control.tsx (new): the per-row client control — a Select to override the action state + an input/badge list to record experts messaged, persisted via the server action (useTransition). The action-state list is passed in from the server component so the model stays the single source of truth without importing server-only code into the client bundle.
  • apps/web/components/dashboard/sdm/risks-table.tsx: row type now carries the real cell key (platform/product/region/language) + expertsMsgd; the Vendor column reads platform; the action cell renders <OutreachControl>. The static action-state badge is gone.
  • apps/web/app/(app)/sdm/dashboard/page.tsx: resolves the risk overview alongside area + coverage (degrading to a zeroed strip / empty rows on failure, never a blanked dashboard) and feeds ActionStrip + RisksTable from it. action-strip.tsx is unchanged — its prop shape already matches SdmActionStrip.

Acceptance criteria status

  • Under-covered cells over the SDM's area produce risk rows with derived impact + riskStatus per the coverage-driven rule (cannot-quote / weak-bid / delayed-go-live).
  • Each risk cell carries an actionState (defaulted from risk, SDM-overridable) and an expertsMsgd list the SDM can set; persisted per (tenant, sdm, cell) and merged on read.
  • revenueAtRisk (quote value, avg-job-value fallback), immediateRisks (+ descriptions) and gapsNotCovered all derive from the same risks array as the table — they cannot disagree.
  • The risks table and action strip render from sdmRiskService via the page; neither imports lib/mock/sdm (the page still reads it only for the not-yet-built sections 6–8).

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview. Locally pnpm typecheck passes across the monorepo and eslint is clean on every touched file. Nothing is expected to fail in CI.

Notes for review

  • New sdm-risk service rather than the legacy sdm service the spec's touches named. The upstream demand/coverage/area features each shipped as their own sdm-* service; the legacy sdm service holds unrelated widget aggregations. Following that pattern keeps the risk layer isolated and the legacy service untouched — the spec is the contract for what, and this matches the established epic architecture.
  • Cell key omits skillLevel. The spec listed it in the risk-row key, but the upstream coverage cells intentionally don't carry skillLevel (per sdm-supply-coverage's notes). Risk rows key on (platform, product, region, language) to align exactly with the cells they enrich.
  • Revenue is a thin roll-up. Quote/proposal values are summed by raw amount and formatted as £ — no FX normalisation across EUR/USD/GBP (consistent with "thin roll-up"; the per-region/product breakdown is feature 8's job). tenantAvgJobValue = mean quote value, falling back to mean proposal price. The revenue read re-resolves the SDM's area the same way sdm-demand does (each sibling service resolves its area independently) and joins the cells' demand leads to their quotes — worth a sanity check that the keying matches the demand cells.
  • Merge of main. This branch predated the upstream merges; origin/main was merged in to pick up sdm-area / sdm-demand / sdm-coverage before building on them.

03_release/output/changelog.md

See which gaps will stop go-live — and track your outreach

Your go live readiness dashboard now turns coverage gaps into ranked go-live risks:

  • The go live risks table flags each under-covered area as cannot quote, weak bid or delayed go live, and the action strip shows the revenue at risk, how many immediate risks you have, and how many gaps remain — all from real coverage data, not placeholders.
  • You can set how each gap is being handled — auto outreach, manual escalation, monitoring or covered — and record which experts you have messaged. Your notes are saved and stay put across reloads.

03_release/output/investor-update.md

SDMs now see which gaps will stop go-live — and track their outreach

Who it's for: Service delivery managers (SDM) What shipped: The go live readiness dashboard now ranks coverage gaps as go-live risks — cannot quote, weak bid or delayed go live — with the revenue at risk, and lets SDMs record their outreach per gap. Why it matters: SDMs triage supply gaps before they stall delivery — Build the Bridge, Q2 2026 Objective 3 (validate technical infrastructure & payout flow).

Dig deeper: <merged-PR URL> · <changelog entry URL>

03_release/output/release.md

Release: sdm-coverage-risk-outreach

  • pr: https://github.com/sustentus/sustentus/pull/517 · merged: <pending — awaiting Ready-to-merge tick + green CI>
  • CI: <pending — Format/Lint/Typecheck on the latest push>
  • technical docs: no technical docs impact — technical/packages/services is high-level (no per-service list); the new sdm-risk service follows the established sdm-* pattern
  • business docs: no business docs impact — the SDM role page already covers "expert pool gaps"; the sibling sdm-supply-coverage set the same precedent (no business/** edit)
  • release notes: investor + changelog — changelog entry apps/help/app/changelog/2026-06-24-sdm-coverage-risk-outreach/page.mdx; investor draft in 03_release/output/investor-update.md (Dig deeper links filled after merge)
  • deploy: <pending>
  • sent: <pending — investor update sends after green production deploy>

Review summary

/code-review run over origin/main..HEAD (code + docs + release notes), high effort. Findings:

  • Silent action-state flip on expert-add (sdm-risk + outreach-control) — adding an expert to a not-yet-persisted red/green cell inserted actionState: "Monitoring", so the dropdown flipped away from the displayed default on reload. Fixed on branch: the client now persists the shown actionState alongside the new expert, so the stored row matches what the SDM is looking at.
  • Misleading subtitle — the risks-table subtitle claimed red "automatically triggers outreach", but this round is state-only. Fixed on branch: reworded to "set how each gap is being handled and record the experts you've messaged".
  • Action-state override doesn't re-classify the row (risks-table) — accepted, correct by spec: row visibility + RAG derive from the coverage-driven riskStatus; actionState/expertsMsgd are a recorded disposition ("recorded, not actioned"), not a reclassification of the risk.
  • Collation duplicate-key risk on the free-text cell keyaccepted: mirrors the existing product (tenantId, platform, name) unique-index pattern; the key fields derive from canonical taxonomy/location docs, so case/accent variants within one tenant's area are not expected.
  • Trim mismatch between stored key and coverage keyaccepted, not reachable: the demand service .trim()s every dimension before both the cell and the persisted record are built, so the read-side merge key matches.

Acceptance check (vs spec)

  • Under-covered cells produce risk rows with derived impact + riskStatus (coverage-driven rule) — SdmRiskService.getRiskOverviewForSdmderiveRisk.
  • Per-cell actionState (defaulted from risk, SDM-overridable) + expertsMsgd, persisted per (tenant, sdm, cell) — new sdm-outreach-state model + setOutreachState + server action.
  • revenueAtRisk / immediateRisks (+ descriptions) / gapsNotCovered all derived from the same risks array — deriveActionStrip.
  • Risks table + action strip render from sdmRiskService; neither imports lib/mock/sdm.