sdm-coverage-risk-outreachrun.md00_intake/stub.mdThe 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.
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.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.revenueAtRisk (value of the at-risk cells),
immediateRisks (+ the top descriptions) and gapsNotCovered (uncovered cell count).impact + riskStatus over the SDM's area.actionState and an expertsMsgd list the SDM can set; state persists.revenueAtRisk, immediateRisks (+ descriptions) and gapsNotCovered derive
from the risk set.lib/mock/sdm.actionState/expertsMsgd
are recorded, not actioned automatically.revenueAtRisk is a thin roll-up of the
at-risk cells; the full breakdown lives in service performance.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.mdThe 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).
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.
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.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.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.lib/mock/sdm.Pinned here next to feature 4's coverage thresholds; applied per under-covered cell:
availableExperts empty): the
vendor cannot even form a quote. riskStatus: red.shortfall > 0 (thin coverage, the bid is weak).
riskStatus: red when the shortfall is the majority of demand, otherwise amber.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.riskStatus: green, impact empty.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.
impact and riskStatus per the coverage-driven rule above (cannot-quote / weak-bid /
delayed-go-live).actionState (defaulted from risk, SDM-overridable) and an
expertsMsgd list the SDM can set; the state persists across reloads (per tenant + cell).revenueAtRisk (quote value, avg-job-value fallback), immediateRisks
(+ descriptions) and gapsNotCovered all derive from the same risk set and agree with the
rows.apps/web/lib/mock/sdm.actionState / expertsMsgd are recorded, not actioned.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.apps/dashboards — that app stays the dummy source of truth.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.mdfeat(sdm-coverage-risk-outreach): go-live risk + outreach layer over coverage cellspackages/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.sdmCoverageService.getCoverageForSdm for the cells (no re-aggregation of demand/supply).deriveRisk applies the spec's coverage-driven rule per cell: availableExperts === 0 →
Cannot quote (red); shortfall > 0 → Weak bid (red when shortfall is the majority of
demand, else amber); covered but Immediate → Delayed go live (amber); otherwise green.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.impact + riskStatus
per the coverage-driven rule (cannot-quote / weak-bid / delayed-go-live).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.sdmRiskService via the page; neither imports
lib/mock/sdm (the page still reads it only for the not-yet-built sections 6–8).pnpm typecheck passes across the monorepo and eslint is clean on every touched file. Nothing is
expected to fail in CI.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.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.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.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.mdYour go live readiness dashboard now turns coverage gaps into ranked go-live risks:
03_release/output/investor-update.mdWho 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.mdtechnical/packages/services is high-level (no per-service list); the new sdm-risk service follows the established sdm-* patternsdm-supply-coverage set the same precedent (no business/** edit)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)/code-review run over origin/main..HEAD (code + docs + release notes), high effort. Findings:
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.riskStatus; actionState/expertsMsgd are a
recorded disposition ("recorded, not actioned"), not a reclassification of the risk.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()s every dimension before both the cell and the persisted record are built, so the
read-side merge key matches.impact + riskStatus (coverage-driven
rule) — SdmRiskService.getRiskOverviewForSdm → deriveRisk.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.sdmRiskService; neither imports lib/mock/sdm.