vendor-revenue-at-riskrun.md02_define/output/spec.mdThe 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.
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.ts → ScoredCustomer |
| Stalled over 30 days in stage | daysInStage > 30 on the engagement |
vendor-money.ts → ActiveEngagement |
| Customer satisfaction 2 or below | The customer's mean satisfaction, on the displayed 1–5 scale, is ≤ 2 | vendor-satisfaction.ts → asDisplayScore |
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.
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.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.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./vendor/metrics grid as a live figure beside the other nine, in euros in
whole units, never abbreviated (BR-19).vendorClaimFilter, so one vendor's engagements and customers
are unreachable from another's by any route including the drill-down (BR-23).scope.md §5 rules alerting out for the whole scope.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.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.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:
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.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.mdb27d9be — M10, the flagged slice of the order bookb27d9be — Quality Project (format, lint, typecheck, tests), the CONVENTIONS
review, both migration jobs, and the web + marketing previewspackages/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.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.flagsFor
collects flags per engagement and the engagement enters the population once; tested.riskRecordSet.shareSentence on
the reconciliation and the first caveat; the section's own copy says the same.flagRecordSet, whose
reconciliation names the overlap total and says it is not a number that means anything.buildRevenueAtRisk takes the stock; there is no
second query anywhere in vendor-risk.ts.atRisk; join by customer id — riskFactsFrom, and a test
that two customers sharing a display name do not merge.isLowSatisfaction divides by
CSAT_SCALE_FACTOR; the test asserts a mean of 4-of-10 flags, which a raw comparison
against 2 would miss.isInFlight handles the first by
construction; the second is M8's own sentence from the same helper (see finding 4 for where
it sits).ClaimFigure and
nothing else; isRenderableClaim asserted in the test, which is how the sibling metrics
enforce it./vendor/metrics grid, euros in whole units — formatClaimMoney, which pins
MONEY_FRACTION_DIGITS = 0 and never abbreviates.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.inFlightCount on the record and in the
empty label; tested.Four things worth a close look:
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.
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.
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.
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.md1d7ae21 — 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 messageapps/web and
packages/services only)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 acceptedAgent-run (no credentials, so unauthenticated surface + code-path tracing only):
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)buildRevenueAtRisk/riskRecordSet, and asserted in vendor-risk.test.ts (agent)isLowSatisfaction divides by
CSAT_SCALE_FACTOR first; asserted (agent)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:
/vendor/dashboard (operator)/vendor/dashboard as a labelled slice of ASR, and the drill-down opens to
the flagged engagements with their flags (operator)/vendor/metrics grid beside the other nine, in euros, whole units
(operator)/vendor/dashboard and
/vendor/metrics?period=last-quarter side by side and confirm M10's headline is the same
figure on both (operator)notifyX call and touches no
notification path (n/a)Fixed on branch:
/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.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.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:
/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:
/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):
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.mdYour 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.
An engagement is flagged when any of three things is true:
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.
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.
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.
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.mdWho 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.md1d7ae21, 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 ittechnical/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 onebusiness/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 exclusionsapps/help/app/changelog/2026-08-20-vendor-revenue-at-risk/ (live with this merge) and the
ship note at 05_ship/output/investor-update.mdship-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 #alertsclose-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 trueEvery 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.
vendor-risk.test.ts, traced through buildRevenueAtRisk/riskRecordSetrevenueAtRiskClaim caveats + vendor-risk-figures.tstruthy breakdown mode, with the overlap warning as the second caveatbuildRevenueAtRisk takes ActiveServiceRevenue as inputatRisk verdict; the join is by customer id, never by name —
asserted with two customers sharing a display nameisLowSatisfaction(4) === true, isLowSatisfaction(5) === falsesettings 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 existcaveats holds at most twoRevenueLeakageCard, 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 reviewisRenderableClaim, asserted/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 herevendorClaimFilter; no client-supplied value reaches a query
(BR-23) — traced end to end by the security review, which returned no finding at confidence ≥ 8Not 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.