Skip to Content

← All archived runs

Run: vendor-revenue-at-risk

run.md

Run: vendor-revenue-at-risk

  • branch: claude/pipeline-vendor-revenue-risk-4jepg2
  • pr: #868

02_define/output/spec.md

Spec: Revenue at Risk — a labelled slice of the order book, each engagement counted once

  • slug: vendor-revenue-at-risk
  • personas: Vendor, CSM
  • touches: packages/services/src/shared, packages/services/src/db/services/vendor, packages/services/src/db/services/metric-dictionary, apps/web/app/(app)/vendor/dashboard, apps/web/app/(app)/vendor/metrics, apps/web/components/dashboard/vendor, apps/web/lib
  • complexity: complex

Problem

The vendor dashboard already shows a "revenue at risk" figure, and it is built by adding four overlapping risk categories together — revenue-leakage-card.tsx renders totalAtRisk beside slaBreach, deliveryDelay, lowCSAT and stuckOver30Days, and an engagement that is both late and unhappy is counted in two of them. The total therefore describes no set of engagements at all, and it sits beside the order book as though it were a second pot of money rather than a part of the same one. It is the clearest example on the dashboard of the failure this scope exists to prevent, and the vendor cannot tell from the screen.

Meanwhile the accounts genuinely at risk are already known: M5 scores every customer and flags renewal risk, and M8 records what each in-flight engagement is worth and how long it has sat in its current stage. Nobody has put a number on what that risk is worth. This is the last metric family in the batch before reconciliation, and it advances Refine the bridge / Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners: a vendor cannot defend a renewal conversation on a figure that double-counts its own evidence.

This is a tenth metric family, added after the scope was agreed. scope.md §5 excludes one ("Nine families are defined here; a tenth is a separate conversation"). Paul asked for it in his dashboard mock-up at Design; the prototype built it properly rather than shipping his €44,000 figure, which is the existing dashboard's four overlapping categories added together. The exclusion was amended in breakdown.md and the M10 definition record was seeded with the dictionary in stub 1 — this run computes it, renders it, and removes what it replaces.

Proposed change

M10 on the claim surface: the value of in-flight work carrying at least one risk flag, presented as a labelled slice of Active Service Revenue.

The population is exactly M8's active service revenue population — the in-flight engagements buildActiveServiceRevenue already returns — narrowed to those carrying at least one risk flag:

Flag Test Source
Customer at renewal risk The engagement's customer has atRisk === true in the M5 health cohort vendor-health.tsScoredCustomer
Stalled over 30 days in stage daysInStage > 30 on the engagement vendor-money.tsActiveEngagement
Customer satisfaction 2 or below The customer's mean satisfaction, on the displayed 1–5 scale, is ≤ 2 vendor-satisfaction.tsasDisplayScore

Each engagement is counted once, however many flags it carries. The figure is rendered through the existing claim surface as a MoneyClaim on the order book, with the per-flag totals shown as reconciliation lines and an explicit statement on screen that adding them is meaningless. The drill-down opens to the flagged engagements with every flag each one holds.

The existing RevenueRiskSection is cleared and rebuilt: the M10 claim figure plus the M5 renewal-risk table. RevenueLeakageCard, the "Biggest risk … €X at risk" topIssue banner and the "Drivers of risk" topDrivers card all go — three undefined figures, two of them unlabelled money, which cannot stand in the same section as the figure that replaces them (BR-10).

Both thresholds are named settings on the M10 definition record, already seeded ("Stalled in stage: Over 30 days", "Low satisfaction: 2 or below"), read by the computation from the definition rather than duplicated as constants in the query.

Acceptance criteria

  • Every engagement in the figure is counted exactly once, whatever combination of flags it carries, and the drill-down lists each one with every flag it holds.
  • The engagements listed in the drill-down have contracted values that add up to exactly the figure shown, and the reconciliation line states that addition (BR-2).
  • The figure is stated on screen as a share of Active Service Revenue, naming the ASR total it is a slice of; no screen adds the two together, compares them, or presents them as separate pots (BR-11).
  • The per-flag totals are shown with their counts, and the screen states in words that their sum is not a number that means anything — the flags overlap by design.
  • The M10 population is derived from M8's own active-service-revenue result, not from a second query over engagements, so a change to what counts as in-flight moves both figures together and the two can never disagree (BR-4).
  • The renewal-risk flag reads M5's atRisk verdict — Red, or Amber with collected spend down more than 15% — rather than re-deriving it, and the engagement-to-customer join is by customer id, never by customer name.
  • The satisfaction flag compares against the displayed 1–5 scale: a customer whose mean response is 2 or below on that scale is flagged, and a test asserts the stored 1–10 raw score is converted before the comparison, not compared raw.
  • A flagged customer with no recorded satisfaction is not flagged on satisfaction — the absent response is not read as a low one (BR-17).
  • The stalled-in-stage and low-satisfaction thresholds are read from the M10 definition record's settings and are shown on the definition at /vendor/metrics and /admin/metrics; no constant in the computation carries a second copy of either number.
  • The two things the data cannot support are stated exclusions on the definition and visible on the claim: delay against an agreed date (in-flight work carries none, so days-in-stage stands in for it and the screen says it is a weaker test), and scope reductions (recorded nowhere, so not measured at all). Neither is approximated.
  • Engagements already Delivered are excluded — the risk left with them — and engagements excluded from M8 for a non-reporting currency are excluded here too, with that count stated beside the figure exactly as M8 states it.
  • RevenueLeakageCard, the topIssue banner and the topDrivers card are deleted from the vendor dashboard along with the revenueAtRisk, topIssue and topDrivers fields they read; no route renders the overlapping-categories figure any more, hidden or otherwise.
  • M10 reaches the screen only through the claim surface, and a claim with no record set or no reconciliation lines does not render.
  • M10 appears on the /vendor/metrics grid as a live figure beside the other nine, in euros in whole units, never abbreviated (BR-19).
  • Every read is scoped through vendorClaimFilter, so one vendor's engagements and customers are unreachable from another's by any route including the drill-down (BR-23).
  • Where the records give zero flagged engagements, zero renders with the count of in-flight engagements behind it; nothing renders as illustrative, indicative or placeholder (BR-3).
  • A soft-deleted customer's engagement stays in the figure while their name is replaced in the drill-down, as it is everywhere else on the surface.

Out of scope

  • Alerting a vendor when the figure moves. It is shown on the dashboard, never pushed — scope.md §5 rules alerting out for the whole scope.
  • Predicting which engagements will fail. The flags are stated facts over stated thresholds.
  • Recording scope reductions, or putting an agreed date on in-flight work. Both are real gaps this stub surfaces; neither is this run's job to close. They are recorded as stated exclusions.
  • A period-over-period change on the figure. M10 is a stock as at the moment of reading; the old card's "+£4k vs last period" is exactly the comparison BR-11 forbids and does not come back.
  • An in-product write path for the two thresholds. admin-metric-definition-governance was retired on 2026-08-19, so BR-24's dated, attributed, announced change history does not exist and no stub owns it. The thresholds live on the definition record and change only by a code change, a migration and a deploy — a stricter control than BR-24 asked for and a slower one. /admin/metrics stays read-only.
  • The rest of the vendor dashboard's undefined figures — the ARR movement strip, the time-to-value section, NRR/GRR. vendor-dashboard-reconciliation (the next and last stub) owns them.
  • apps/demo. The stub's touches: offers to keep the demo in step; the demo app was frozen in 2026-08 and takes no new development, so its prototype revenue-risk-section and vendor-metric-claims.ts M10 stay as they are. They are the reference this spec was written from, not a target.
  • Any other persona's dashboard. CSM reads this vendor surface; no CSM-specific screen changes.

Open questions

  • none — the one open decision (how far the section clear-out reaches) was settled in the Define conversation: the whole section, per BR-10.

Notes

Context budget: within band. Read beyond the Inputs table: apps/demo/lib/mock/vendor-metrics.ts and vendor-metric-claims.ts (the prototype's M10, which the stub names as worth carrying), the seeded M10 definition in 1784600000000-seed-metric-definitions.ts, and the shapes of ActiveEngagement / ScoredCustomer — needed to write criteria that name the real join key and the real satisfaction scale rather than leaving both for Build to discover.

Two findings for Build, both from reading the dependencies' output rather than the stub:

  1. MoneyEngagementInput and ActiveEngagement carry customer as a name string and no customer id. The health cohort keys on customerId. The join has to be by id, so one of the two types needs the id threaded through — a name join would silently mismatch two customers who share a name.
  2. Satisfaction is stored on a 1–10 scale (MAX_RESPONSE_SCORE) and displayed on 1–5 (CSAT_SCALE_FACTOR, asDisplayScore). "2 or below" is the displayed number. Comparing the raw score against 2 would flag only customers who scored 1 of 5, quietly halving the population.

03_build/output/notes.md

Build notes: vendor-revenue-at-risk

  • commits: b27d9be — M10, the flagged slice of the order book
  • ci: GREEN on b27d9be — Quality Project (format, lint, typecheck, tests), the CONVENTIONS review, both migration jobs, and the web + marketing previews

What changed

  • packages/services/src/shared/vendor-risk.ts (new): all of M10's logic, pure and DB-free. The two thresholds, the three flags, buildRevenueAtRisk over M8's own stock, the claim and its two record sets. revenueAtRiskClaim carries kind: ORDER_BOOK / basis: "stock" — the same labels M8 carries, because it is the same money over a narrower population. That is what makes "a slice of active service revenue" a type-level fact rather than a caption.
  • packages/services/src/shared/vendor-money.ts: customerId added to MoneyEngagementInput and ActiveEngagement. The join key — see finding 1 below.
  • packages/services/src/shared/vendor-health.ts: satisfactionMean added to ScoredCustomer, so M10's low-satisfaction flag reads the mean M5 already computed instead of deriving a second one over the same responses.
  • packages/services/src/db/services/metric-dictionary/vendor-money.ts: supplies customerId, and names customers through the shared customer-name.ts helper it had a second copy of. That copy fell back to "Unnamed customer" for a soft-deleted customer; the shared helper lets the call site distinguish removed from never recorded, which is what makes the soft-delete criterion true in M10's drill-down rather than nearly true.
  • packages/services/src/db/migrations/1787875600000-m10-risk-threshold-settings.ts (new): writes M10's settings from the shared constants — see finding 3.
  • apps/web/lib/vendor-risk-figures.ts (new): M10 as a LiveFigure. Its one breakdown uses truthy mode rather than a partition, because the flags overlap by design and rows that do not sum to their headline must not be narrowed as though they did.
  • apps/web/app/(app)/vendor/metrics/page.tsx and .../vendor/dashboard/page.tsx: compose M10 from the M8 and M5 reports both pages already load. The dashboard now also reads M5's cohort.
  • apps/web/components/dashboard/vendor/revenue-risk-section.tsx: rebuilt as M10's claim figure plus the M5 renewal-risk table.

What was deleted, not hidden

  • revenue-leakage-card.tsx — the 19k + 8k + 6k + 11k figure and its four cause tiles.
  • apps/web/app/(app)/vendor/risk/page.tsx — a second route rendering the same overlapping figure, found during the build. Its only inbound link was the leakage card, so removing the card orphaned it; leaving it would have left the criterion "no route renders it, hidden or otherwise" false. Its route-policy entry went with it.
  • VendorRevenueAtRisk, VendorRiskDriver, VendorTopIssue and the revenueAtRisk / topIssue / topDrivers fields on VendorRevenueRisk, plus the computation behind them. The prior-window read (loadCustomerWindow) and the prior SLA-breach read fed only the period-on-period delta, so getRevenueRisk now makes two fewer DB reads per render.

Acceptance criteria status

  • Each engagement counted exactly once, drill-down lists every flag it holds — flagsFor collects flags per engagement and the engagement enters the population once; tested.
  • Drill-down values add to the figure, reconciliation states it — riskRecordSet.
  • Stated as a share of ASR, never added or presented as separate pots — shareSentence on the reconciliation and the first caveat; the section's own copy says the same.
  • Per-flag totals with counts, and the sum stated as meaningless — flagRecordSet, whose reconciliation names the overlap total and says it is not a number that means anything.
  • Population derived from M8's own result — buildRevenueAtRisk takes the stock; there is no second query anywhere in vendor-risk.ts.
  • Renewal-risk flag reads M5's atRisk; join by customer id — riskFactsFrom, and a test that two customers sharing a display name do not merge.
  • Satisfaction flag on the displayed 1–5 scale, with a test — isLowSatisfaction divides by CSAT_SCALE_FACTOR; the test asserts a mean of 4-of-10 flags, which a raw comparison against 2 would miss.
  • Absent satisfaction is not a low one — tested.
  • Thresholds shown on the definition, no second copy — met, with the derivation inverted; see finding 3.
  • Both exclusions stated on the claim, neither approximated — two reconciliation lines, asserted in a test.
  • Delivered excluded, and M8's currency exclusion carried — isInFlight handles the first by construction; the second is M8's own sentence from the same helper (see finding 4 for where it sits).
  • The old figure and its fields deleted, no route renders it — see above.
  • Reaches the screen only through the claim surface — the section renders ClaimFigure and nothing else; isRenderableClaim asserted in the test, which is how the sibling metrics enforce it.
  • On the /vendor/metrics grid, euros in whole units — formatClaimMoney, which pins MONEY_FRACTION_DIGITS = 0 and never abbreviates.
  • Every read scoped through vendorClaimFilter — M10 issues no reads of its own; both dependencies are already scoped through it, and composing rather than querying is what makes that inherited rather than re-asserted.
  • Zero renders with the in-flight count behind it — inFlightCount on the record and in the empty label; tested.
  • Soft-deleted customer stays in the figure, name replaced — via the shared naming helper.

Notes for Verify

Four things worth a close look:

  1. customerId is new on two shipped types. MoneyEngagementInput and ActiveEngagement carried a customer name and no id, while the health cohort keys on customerId. A name join would have silently merged two customers who share one. Only one production call site constructs these (M8's service) and both test factories were updated.

  2. The dashboard reads M5 on the dictionary's default period, not the range selector. Only one of M5's four components is period-bound, and the range selector is the dashboard's control rather than the metric's definition — so reading it any other way would let the dashboard and /vendor/metrics band the same customer differently on the same day. Worth confirming that reading is the one you want.

  3. The threshold criterion is met with the derivation inverted. It asks for the thresholds to be read from the definition record. MetricSetting.value is a display string ("Over 30 days"), and parsing a number back out of one to drive a money figure is fragile in exactly the way this scope exists to prevent. So the constants are the single home and the record's settings are generated from them by the migration. There is one copy of each number, the flag labels on screen interpolate the same constants, and what /vendor/metrics and /admin/metrics display is that copy — but the arrow points the other way from the spec's wording. Changing a threshold is a code change plus a migration plus a deploy, which is the mechanism breakdown.md records now that BR-24's Admin write path is retired unbuilt.

  4. M8's currency exclusion sits in M10's reconciliation, not its caveats. Claim.caveats is documented as "at most two", and the slice framing and the overlap warning are the two that change how this number is read. The exclusion sentence is M8's own, from the same helper, so the wording matches exactly — it is one line further down.

Context budget: within band.

04_verify/output/verify.md

Verify: vendor-revenue-at-risk

  • ci: GREEN on 1d7ae21 — the commit carrying every fix below, settled via ci-status.sh after that push. The only commit after it is this file, a .icm/**-only diff on which the Quality workflow short-circuits to success; its own settled verdict is named in the handover message
  • previews smoked: web (https://web-git-claude-pipeline-vendor-revenue-risk-4jepg2-sustentus.vercel.app) · marketing (built, untouched by this diff) · demo, docs, help-centre, storybook skipped for this commit (no preview built — nothing was demonstrated on them, and nothing needed to be: the change touches apps/web and packages/services only)
  • production-readiness: run — 1 defect (fixed on branch), 1 docs gap (for Ship)
  • code-review: high (the spec's complexity: complex) — the CI Claude review also ran and passed with no comments posted; run again here because one automated pass with zero findings on a ~600-line change is thin corroboration for the stage that owns quality. 4 findings: 2 fixed on branch, 1 surfaced below, 1 accepted
  • security-review: run — no findings at confidence ≥ 8. Required because the diff touches route policies
  • playwright: TODO — manual DoD smoke performed instead

DoD smoke (on the preview — each line says who verified it)

Agent-run (no credentials, so unauthenticated surface + code-path tracing only):

  • The web preview builds and serves for this commit — / returns 200 (agent)
  • /vendor/risk no longer serves the overlapping-categories figure — it 307s to /sign-in?redirect_url=%2Fvendor%2Frisk, and the page directory and its ROUTE_POLICIES entry are both gone. route-policies.ts is an allow-list with deny-by-default in proxy.ts, so the path is now unreachable by every role — the removal tightens access rather than loosening it (agent, corroborated by the security review)
  • Each engagement counted once; drill-down lists every flag — traced in buildRevenueAtRisk/riskRecordSet, and asserted in vendor-risk.test.ts (agent)
  • Satisfaction flag on the displayed 1–5 scale — isLowSatisfaction divides by CSAT_SCALE_FACTOR first; asserted (agent)
  • Join by customer id, never by name — asserted with two customers sharing a display name (agent)
  • Every read scoped through vendorClaimFilter; no client-supplied value reaches a query (?period, ?range, ?metric are all allow-listed) — traced end to end by the security review (agent)
  • customerId is not projected into any RecordSet, so it is not serialised to the client (agent)

Operator-demonstrated — not yet done; these are yours, and the gate should not pass without them. Everything below needs a signed-in vendor on the preview:

  • auth: a Vendor signs in and reaches /vendor/dashboard (operator)
  • M10 renders on /vendor/dashboard as a labelled slice of ASR, and the drill-down opens to the flagged engagements with their flags (operator)
  • M10 renders on the /vendor/metrics grid beside the other nine, in euros, whole units (operator)
  • The specific regression check for the defect fixed below: open /vendor/dashboard and /vendor/metrics?period=last-quarter side by side and confirm M10's headline is the same figure on both (operator)
  • The per-flag totals show with counts, and the screen says adding them means nothing (operator)
  • payments: not touched — no payment path in the diff (n/a)
  • notifications: none expected — the diff adds no notifyX call and touches no notification path (n/a)

Findings & cleanup

Fixed on branch:

  1. M10's headline moved with the period selector while labelled a stock. The dashboard pinned its M5 read to the dictionary default; /vendor/metrics fed M5 read on the reader's selected period straight into M10. M5's atRisk is period-sensitive (the commercial-trend component is period-bound), so a vendor on ?period=last-quarter could see a different M10 total from the one on their dashboard the same day — two values for one concept (BR-4), under a label saying the figure does not move (BR-1). It also falsified the ticked criterion "the two can never disagree". Fixed: /vendor/metrics now bands M10 on the dictionary default too, reusing the existing read when the selection already is the default rather than querying twice; and the figure now states its banding window instead of leaving it to be inferred. Found by /production-readiness. This is the cross-surface risk flagged for Verify in the build notes — it was real.
  2. "Customer removed" shown for a live customer with no recorded name. My customerName(...) call passed the removed-customer fallback on the resolved branch too, so a live but unnamed customer read "Customer removed" in the M8/M10 drill-downs while M5 read "Unnamed customer" for the same record — the exact BR-4 divergence this scope exists to remove. Fixed by resolving first and choosing the fallback per branch, the shape M5 already uses.
  3. The zero-state could contradict its own exclusion line. inFlightCount is M8's reportable population, so a vendor whose every in-flight engagement is in another currency got "Nothing is in flight for this account right now" directly above a line saying those engagements exist but were excluded. Fixed: the field is documented as the reportable count, and the zero-state now distinguishes "nothing in flight" from "nothing measurable". Test added.

Surfaced — a judgement call, not fixed:

  1. Within /vendor/metrics, M5 and M10 can still read differently. M5's figure follows the reader's period (correct — its claim states the two periods it compares); M10's flags are now pinned. So with a non-default period selected, M5's at-risk drill-down can omit a customer whose engagement M10 flags "Customer at renewal risk". The figure discloses the window, but disclosure is not agreement. This is inherent to M10 being a stock built partly on a period-bound verdict — BR-5 says a metric declares whether it is a cohort or an activity figure and the two are never combined in one figure, and M10 combines them by construction. Resolving it properly means changing a definition, which is Define's call, not Verify's. Options if you want it closed: (a) accept, as now, with the window stated; (b) pin M5's figure to the default too — cheap, but it removes the period selector's meaning for M5; (c) redefine M10's renewal-risk flag on a period-independent basis (e.g. band only, no trend), which is a scope change. My recommendation is (a) for this run and a follow-up stub if it grates in use.

Accepted with reason:

  1. The second health read on /vendor/metrics is heavier than it needs to be. On a non-default period M10 triggers a full vendorHealthService.report — the all-time delivery read plus the collection reads — when only .cohort is consumed. A buildHealthCohort-only service path would roughly halve that page's heaviest read. Not done here: it is a new service API surface, it only bites on non-default periods, and correctness came first at this stage. Worth a chore if the page feels slow.

For Ship (not this stage's to fix):

  1. apps/docs/app/business/feature-role-matrix/vendors/page.mdx:46 still lists /vendor/risk as a live vendor route with its old description. docs-sync runs at Ship on this same PR and that row is the concrete edit it owes. A changelog entry is owed too — a vendor loses a route and the dashboard's revenue-risk section is rewritten.

Rollback note for Ship: revert-commit is safe. The migration is value-neutral today (it writes the same strings the seed already held) and its down restores the seeded literals verbatim.

Context budget: within band. Read beyond the Inputs table: route-policies.ts and proxy.ts (to judge whether the route deletion loosened anything), and vendor-health.ts's scoring internals (to confirm the period-sensitivity behind finding 1) — both load-bearing for findings above.

05_ship/output/changelog.md


title: Revenue at risk, with each engagement counted once date: 2026-08-20T08:30:00Z personas: [vendor, csm] slug: vendor-revenue-at-risk pr: https://github.com/sustentus/sustentus/pull/868

Revenue at risk, with each engagement counted once

Your dashboard now tells you what your at-risk work is actually worth. The figure that sat there before added four overlapping categories together, so an engagement that was both running late and had an unhappy customer behind it was counted twice, and the total could come out larger than the work it was drawn from. It counts each engagement once now, however many things are wrong with it.

What gets flagged

An engagement is flagged when any of three things is true:

  • its customer is at renewal risk — the same flag your customer health figure raises, read from there rather than worked out a second way
  • it has sat in one stage for more than 30 days
  • that customer's satisfaction averages 2 or below on the five-point scale they answer on

Each flag carries its own total and its own count, so you can see which problem is holding the money. What you cannot do is add those three totals together, and the screen says so plainly: an engagement carrying two flags appears in two of them. Only the headline figure counts anything once.

Open it and every flagged engagement is listed with its contracted value, its customer, and every flag it holds. Those values add up to exactly the headline figure.

A slice, not a second pot

Revenue at risk is stated as a share of your active service revenue, naming the total it came out of. It is part of that money rather than money sitting beside it, so nothing adds the two together or sets one against the other.

Work you have already delivered is not in it — that risk went with the delivery. An engagement priced outside your reporting currency is left out and its count stated beside the figure, the same way active service revenue treats it, rather than converted at a rate nobody agreed.

Two things it does not claim

Delay against a date you agreed with the customer is not measured. Work still in flight carries no such date, so days in stage stands in for it — and the definition says outright that this is the weaker test rather than presenting the two as the same thing.

Scope reductions are not measured at all. Nothing in the system records a job getting smaller, so rather than estimate one, the definition states it as an exclusion.

What has gone

The revenue leakage card has been removed from your dashboard, and the risk page it linked through to has gone with it — both showed the old added-up figure, and nothing else pointed at that page. The renewal-risk table you reached through the card is still there, in the same dashboard section as the new figure.

The two thresholds — over 30 days in stage, satisfaction of 2 or below — are shown on the metric's definition in the KPI dictionary. They change with a release, not by anyone adjusting a number in the product.

05_ship/output/investor-update.md

What at-risk work is really worth

Who it's for: Vendors and CSMs What shipped: Revenue at risk now counts each engagement once, as a labelled slice of active service revenue. Why it matters: Refine the Bridge: a figure vendors can quote, not four overlapping categories added together.

The old figure counted late-and-unhappy work twice. This one cannot.

Dig deeper: https://github.com/sustentus/sustentus/pull/868 · https://help.sustentus.com/changelog/2026-08-20-vendor-revenue-at-risk

05_ship/output/release.md

Ship: vendor-revenue-at-risk

  • pr: #868 · merge: authorised — Ready to merge ticked by Jamie (read, never ticked here); this commit rides the squash
  • CI: GREEN on 1d7ae21, the commit carrying all code and every Verify fix, settled via ci-status.sh after that push. Everything since is .icm/**, apps/docs/** and apps/help/**; the verdict on the head that actually merges is established by ci-status.sh after this push and before the merge, and nothing but GREEN authorises it
  • technical docs: technical/packages/services — the risk family added as the seventh metric family (shared/vendor-risk.ts: population taken from the money family rather than re-queried, join by customerId, the three overlapping flags, each engagement counted once, thresholds with one home); the metric-dictionary section now records that definition changes land as further migrations, and drops "admin-owned" from settings — the collection has no product write path and this run's migration is the first thing to change one
  • business docs: business/feature-role-matrix/vendors — the /vendor/risk row removed, the route is deleted; business/roles — a Revenue at risk bullet added to what a vendor sees, stating the slice framing, the three flags, the count-once rule, and both stated exclusions
  • release notes: both — changelog entry apps/help/app/changelog/2026-08-20-vendor-revenue-at-risk/ (live with this merge) and the ship note at 05_ship/output/investor-update.md
  • sent: none at the time of writing — the merge is the send. ship-note.yaml fires on it, finds this run's investor-update.md in the merge commit's tree and emails it to #product-update; a failure posts to #alerts
  • close-out: close-out.sh vendor-revenue-at-risk will archive .icm/runs/vendor-revenue-at-risk/ to apps/docs/archive/pipeline-runs/. The vendor-metrics epic does not archive with it — this was not its last stub: vendor-dashboard-reconciliation.md is still in flight, with the other eight already in _done. Written here as intent, since the merge that carries this file is what makes it true

Acceptance check (vs spec)

Every criterion below was ticked on the PR at Build and stood up at Verify. Where the evidence is a test or a traced code path rather than an operator on the preview, this says so.

  • Each engagement counted exactly once whatever its flags; drill-down lists every flag — asserted in vendor-risk.test.ts, traced through buildRevenueAtRisk/riskRecordSet
  • Drill-down values add up to the figure, and the reconciliation states that addition (BR-2) — asserted
  • Stated as a share of Active Service Revenue, naming the ASR total; nothing adds or compares the two (BR-11) — revenueAtRiskClaim caveats + vendor-risk-figures.ts
  • Per-flag totals shown with counts, and the screen says their sum means nothing — the truthy breakdown mode, with the overlap warning as the second caveat
  • Population derived from M8's own ASR result, not a second query, so the two cannot disagree (BR-4) — buildRevenueAtRisk takes ActiveServiceRevenue as input
  • Renewal-risk flag reads M5's atRisk verdict; the join is by customer id, never by name — asserted with two customers sharing a display name
  • Satisfaction flag compares on the displayed 1–5 scale, with a test asserting the stored 1–10 score is converted first — isLowSatisfaction(4) === true, isLowSatisfaction(5) === false
  • A flagged customer with no recorded satisfaction is not flagged on satisfaction (BR-17) — asserted
  • Thresholds shown on the definition with no second copy in the computation — met with the derivation inverted: the constants are the single home and the record's settings are generated from them by migration 1787875600000-m10-risk-threshold-settings. Recorded as a deviation from the criterion's literal wording in the build notes (finding 3) and annotated on the criterion itself, because admin-metric-definition-governance was retired unbuilt and BR-24's write path does not exist
  • Both unsupportable things stated as exclusions and visible on the claim — agreed-date delay (days in stage stands in, named as the weaker test) and scope reductions (not measured)
  • Delivered work excluded; M8's non-reporting-currency exclusion carried with its count, from the same shared helper — one line down in the reconciliation, since caveats holds at most two
  • RevenueLeakageCard, the topIssue banner and the topDrivers card deleted with the fields they read; /vendor/risk deleted too, its ROUTE_POLICIES entry with it. The policy file is an allow-list with deny-by-default in proxy.ts, so the removal tightens access — confirmed by the security review
  • M10 reaches the screen only through the claim surface; a claim with no record set or no reconciliation does not render — isRenderableClaim, asserted
  • On the /vendor/metrics grid beside the other nine, in euros, whole units (BR-19) — code path traced; the rendered grid is on the operator half of the smoke, not demonstrated here
  • Every read scoped through vendorClaimFilter; no client-supplied value reaches a query (BR-23) — traced end to end by the security review, which returned no finding at confidence ≥ 8
  • Zero flagged engagements renders zero with the in-flight count behind it; nothing illustrative or placeholder (BR-3) — and the zero-state now distinguishes "nothing in flight" from "nothing measurable", a defect Verify found and fixed
  • A soft-deleted customer's engagement stays in the figure while their name is replaced — and Verify fixed the inverse defect: a live but unnamed customer read "Customer removed"

Not demonstrated by the agent. The operator half of Verify's Definition-of-Done smoke needs a signed-in vendor on the preview, and this session has no credentials. Those lines are unticked in 04_verify/output/verify.md and stay unticked. The one worth running against production after the deploy is the regression check for Verify's first defect: open /vendor/dashboard and /vendor/metrics?period=last-quarter side by side and confirm M10's headline is the same figure on both — it is a stock, and it must not move with the period selector.

Carried forward, not blocking. Verify surfaced one judgement call (within /vendor/metrics, M5's figure still follows the reader's period while M10's flags are pinned, so the two drill-downs can differ on a non-default period — accepted with the window stated on the figure, option (a) of three) and one accepted perf item (the second vendorHealthService.report on a non-default period builds the full claim set when only .cohort is used). Both are written up in verify.md; either is a chore stub if it grates in use.