vendor-customer-health-and-churn-riskrun.md02_define/output/spec.mdScope: .icm/runs/vendor-metrics/01_scope/output/scope.md (M5, §3.6, §3.7, §3.9, BR numbering
carried through; §6 answers Q6 and Q13 are the settled rulings).
Stub: .icm/intake/vendor-metrics/vendor-customer-health-and-churn-risk.md (6 of 9).
Depends on: vendor-satisfaction-and-revenue-weighting (stub 5) — merged as #858, archived under
apps/docs/archive/pipeline-runs/. Through it, metric-dictionary-and-claim-surface (stub 1,
merged as #782), which owns the claim surface every figure here renders through.
A vendor's dashboard is the evidence for their subscription, and the one question it has never
answered is the one that decides the renewal: which customers am I about to lose? The platform
already asserts an answer. vendorService.getCustomerHealth powers /vendor/health,
/vendor/csat and the vendor dashboard with a "churn prediction" built from a heuristic signal set
— low CSAT, ageing over 30 days, SLA breach — against no written definition, no stated weights, no
visible components and no route to the records behind it. It is an opinion with a decimal point: a
vendor cannot act on it, cannot check it, and has no reason to believe it, which is worse than
showing nothing because it spends the trust the rest of the dashboard needs.
M5 replaces the assertion with arithmetic. The score exists to make the count of at-risk customers
checkable rather than asserted, which is the whole of Refine the bridge / Q2-2026 Objective 1
(Establish Product-Market Fit with Vendor Partners): a vendor renews on numbers they can defend in
front of their own board, and a health band they cannot reproduce by hand is not one of them. It is
also the input stub 8 (vendor-revenue-at-risk) values — M10 puts money against exactly the
customers this stub flags — so a soft definition here becomes a soft money figure there.
M5 becomes a live Claim on the claim surface at /vendor/metrics. The headline is a count of
customers at renewal risk; the
per-customer score is what stands behind it, and every component of every score is visible with the
records that produce it.
A new packages/services/src/shared/vendor-health.ts holds the scoring, banding and churn-risk
rules as pure functions over plain inputs, unit-tested with no database near them — the split stubs
1, 3, 4 and 5 established (shared/vendor-*.ts for arithmetic, db/services/metric-dictionary/
for the reads). A new db/services/metric-dictionary/vendor-health.ts gathers the facts and builds
the claim, registered as vendorHealthService in that folder's instance.ts.
Every customer of this vendor with at least one engagement, live or complete — an engagement
being a lead that reached an accepted proposal, the same membership test
shared/vendor-delivery.ts already applies (classifyEngagement). A customer with no engagement
at all is not yet this vendor's customer and does not appear (the M5 record's stated exclusion).
Every read is scoped through vendorClaimFilter({ tenantId, vendorId }) — stub 1's single scoping
point, which throws rather than returning an unfiltered query (BR-23).
A score out of 100. Each component is computed at full precision from the records, then rounded once to a whole number; the score is the sum of those four rounded numbers. This is what makes the four numbers a vendor sees add up to the total beside them (BR-18), and it is not a departure from BR-19: the component values are the displayed values, nothing is rounded before a component is computed, and the score is never rounded a second time.
| Component | Weight | Rule |
|---|---|---|
| Satisfaction | 40 | 40 × (mean of the customer's recorded responses ÷ the maximum score) |
| Engagement recency | 25 | Four buckets by age: ≤30 days 25; 31–60 17; 61–90 8; >90 0 |
| Delivery reliability | 20 | 20 × (their engagements delivered on or before the agreed date ÷ their measurable delivered engagements) |
| Commercial trend | 15 | Four buckets by change: flat or up 15; down <5% 10; down 5–15% 5; down >15% 0 |
Satisfaction counts every response the customer has ever recorded, not the period's — the M5
record's basis date is explicit that the score is "Not period-bound — the score is a snapshot of
the customer now". A recorded response is stub 5's definition unchanged: isCompleted: true, an
overallScore of 1–10, and a completedAt (BR-14 — the Customer Satisfaction scope governs). The
share of maximum is computed on the stored 1–10 value before any display scaling (BR-19).
Recency is measured from the most recent of a closed list of dated facts on that customer's engagements with this vendor — an activity-trail entry against one of their leads, a milestone acknowledged, a bill settled, a satisfaction response recorded, or the engagement itself sourced. The last of these is the floor that guarantees the component always exists for anyone in the population, and the drill-down names which record set the date, so "47 days" is a record a vendor can open rather than a number they must accept.
The component is four discrete buckets, not a curve: full marks at 30 days or fewer and none beyond 90 are the scope's own thresholds; the two interior buckets are the remaining thirds of the weight — 31–60 days scores 17, 61–90 days scores 8. A vendor can reproduce the number by reading one date off the drill-down and finding its row, which a linear interpolation does not allow.
Delivery reliability counts every engagement of theirs that reached Delivered, all-time, on
shared/vendor-delivery.ts's existing classification — the definition is stub 3's and is not
re-derived here (BR-4). Engagements with no agreed date recorded are not read as on time: they
are excluded from both sides of the share and counted in their own bucket, stated beside the
component (BR-17, and M6's own stated exclusion).
Commercial trend compares collected spend in the selected period against the equivalent
previous period — the immediately preceding window of the same length, added as
previousPeriod(resolved) in shared/period.ts so stubs 8 and 9 consume one implementation rather
than each writing their own. Collected is stub 4's definition unchanged: invoices with paid: true
and paidAt inside the window, summed on total (BR-10). Where the previous period collected
nothing and the current period collected something, the trend is growth and scores full marks.
Like recency it is four buckets, on the same thirds: flat or up scores 15, down less than 5% scores 10, down 5% to 15% inclusive scores 5, and down more than 15% scores 0. Only the outer two are the scope's — full marks for no decline, and the 15% line it already draws for "declining".
Three components are all-time snapshots; the commercial trend is period-bound by definition. That is a mixed basis, and BR-5 forbids combining bases silently, not combining them at all. The claim states it: the score is a snapshot as at the read's stamped moment, and the trend component names the two periods it compares. The M5 tile carries the period label for the trend alone, never for the score.
Green 70 and above, Amber 45 to 69, Red below 45.
Churn risk is Red, or Amber whose collected spend is down more than 15% on the equivalent previous period. The Amber test reads the decline percentage — the scope says more than 15%, so a customer down exactly 15% is not at risk. Under the buckets that boundary now falls the same way on both sides: −15% sits in the 5-point bucket and is unflagged, −15.1% scores 0 and is flagged, so the trend component reaching zero and the churn-risk test coincide exactly instead of disagreeing at the boundary. The trend figure that triggered the flag is shown on the row.
The headline is the count of customers at renewal risk, and it opens to exactly those
customers — each with their score, their band, and the component that put them there. "The
component that put them there" is the one with the largest shortfall against its own weight
(weight − contribution), ties broken by the larger weight and then by the order in the table
above, so the same inputs always name the same component.
A component with no input is not a component worth zero. A customer missing any component is not banded and carries no total score — never a partial score that could be read as a real one. Their available components are still shown with their evidence, they are counted in their own explicit bucket beside the headline, and they are never hidden (BR-17, BR-3).
A component is missing only when no record could produce it — not when the records produce a bad value. Zero collected this period against real spend last period is a −100% trend, not a missing one. Three states arise, each named on the row:
| State | Cause |
|---|---|
| Not yet rated | No engagement has reached Delivered — no satisfaction and no reliability to measure |
| Awaiting satisfaction | Delivered, but no satisfaction response has ever been recorded |
| No commercial trend | Nothing collected in either the current or the equivalent previous period |
Where more than one applies, every reason is listed — a customer is not told one thing is missing when two are. Unbanded customers are in the population and in the counts that describe it; they are not in the at-risk count, because they are neither Red nor Amber.
No score is ever stored. Every score is computed at read time from the records and the weights, so
there is no document to edit and no service method that writes one. The weights, the bands and the
two thresholds are constants in shared/vendor-health.ts, surfaced read-only as the M5 record's
settings — which the dictionary already carries, seeded by
1784600000000-seed-metric-definitions.ts.
This is stricter than the stub's eighth criterion asked for. That criterion assumed the Admin write
path that admin-metric-definition-governance would have built; that stub was retired unbuilt
(breakdown → Retired, BR-24 goes unbuilt), so there is no route in the product for anyone,
Admin included, to change a weight or a band — changing one is a code change and a deploy. And
because no score is stored and M5 publishes no history, there is nothing to restate: a weight
change applies to what is displayed from the deploy onward. Storing scored history is out of scope.
On the claim surface, M5 is one LiveFigure built by a new
apps/web/lib/vendor-health-figures.ts, following stub 5's adapter pattern exactly:
customerId beyond the rendered columns, so a click filters on the customer rather than on a
re-parsed name (stub 5's filterKey/cohortKey pattern — two customers may share a display
name).Reconciliation lines, as data on each RecordSet (BR-2, BR-18): the four component contributions
summing to the score on every scored row; the at-risk count equalling Reds plus Ambers-with-decline;
the population equalling banded plus unbanded; the Green/Amber/Red counts summing to the banded
count; and, for each component, the records listed summing to the contribution shown.
Nothing renders without passing isRenderableClaim. The as-at moment and the period come from the
page's single per-read stamp (BR-20); this stub sources no second one.
A soft-deleted customer renders as "Customer removed" via the shared customerName helper, with
their score and components intact — the work happened and the records are real, so they stay in
every aggregate while the name leaves the drill-down (scope §3.9).
The scope's worked example scores Northwind Logistics 38 — satisfaction 16 of 40, recency 12 of 25, reliability 10 of 20, trend 0 of 15 — Red, flagged churn risk. Under the buckets that total is 34, not 38, and the example is restated here rather than quietly preserved: satisfaction 16, recency 8 (61 days sits in the 61–90 bucket), reliability 10, trend 0 (down 18%). Its conclusion is unchanged — 34 is still Red, still flagged churn risk — which is the part the scope was making.
Recency of 12 is not reproducible under either shape. The scope's parenthetical "last activity 47 days ago" was already a slip against its own 30/90-day rule (the stub records it); the buckets resolve it differently again — 47 days now scores 17 and 61 days scores 8. Fixtures and tests assert 34, and nothing anywhere asserts 38, or that 47 days scores 12.
vendorClaimFilter, so one vendor's customers are unreachable
from another's by any route including the drill-downs (BR-23)./vendor/health, /vendor/csat and the vendor dashboard's churn prediction onto
this definition. All three keep vendorService.getCustomerHealth, whose heuristic signal set
and health rate are computed differently and will disagree with M5. That is knowingly two values
for one concept for the duration of this stub, exactly as stub 5 left the satisfaction figures,
and closing it is stub 9's (vendor-dashboard-reconciliation) whole job — it depends on this
stub for that reason.churnPrediction heuristic is not extended, ported or reused.admin-metric-definition-governance stub.admin-metric-definition-governance stub,
so it has no home to extend. That also drops the { tenantId: 1, csm: 1 } lead index and its
migration, which existed only to serve that route. Whether CSMs should reach health at all is
stub 9's question, not this one's.03_build/output/notes.mdfeat: … — M5 customer health and churn risk on the claim surfacepackages/services/src/shared/vendor-health.ts (new) — the arithmetic, with no
database near it: the four weighted components, the two bucket tables, the bands, the
churn-risk test, and the four record sets plus the claim. Each component is rounded once
and the score is the sum of those rounded numbers, so the four figures a vendor sees add
up to the total beside them (BR-18).packages/services/src/shared/vendor-health.test.ts (new) — written from the
acceptance criteria, not from the implementation. Covers both bucket tables at every
boundary, the Northwind example at 34, determinism, all three unbanded states, the
exactly-15% edge, and the no-agreed-date exclusion.packages/services/src/db/services/metric-dictionary/vendor-health.ts (new) — gathers
the facts and builds the claim. Every read scoped through vendorClaimFilter..../metric-dictionary/vendor-delivery.ts — one word: ACCEPTED_PROPOSAL_STATUS is
now exported, so the health family shares the engagement-membership test rather than
restating the magic string. No behaviour change..../metric-dictionary/instance.ts, db/services/index.ts, shared/index.ts —
register and export vendorHealthService / VendorHealthReport.apps/web/lib/vendor-health-figures.ts (new) + apps/web/app/(app)/vendor/metrics/page.tsx —
M5 renders through the existing claim surface. No component changes were needed: stub 5
built ClaimFigure for all ten families, and M5 plugs into it.previousPeriod() helper was added. The spec called for one in shared/period.ts;
ResolvedPeriod already carries previous: { from, to, label }, computed at local
midnight and correct across DST. Adding a second implementation would have been the
duplicate CONVENTIONS.md → "grep before writing a helper" exists to stop. The page
passes resolved.previous straight through.VendorDeliveryService.report() is called over an unbounded window (epoch → asAt)
because the score is a snapshot and reliability is all-time. That service's membership
read is not period-bounded either, so this reads the same records and only widens which
of them count as delivered. It is a second full pass over the delivery collections on
this page — the honest cost of not forking the definition. Worth a look if the page
gets slow.Engagement.id, which is the lead id. The
classified record carries a customer display name, and two customers can share one, so
grouping on the name would silently merge them. No change to stub 3's types was needed.formatPercent clamps to 0–100 (it also feeds
progress widths), so every decline would have rendered as "0%" — the figure that flags a
customer at risk reading as flat. The other signedPercent in the tree is private to a
server-only module and /shared must stay importable without it.healthClaim + atRiskRecordSet.scoredRecordSet, and each component's own line
with its derivation heads its records in the drill-down.isRenderableClaim still gates.vendorClaimFilter.completedAt still counts toward the satisfaction mean but
contributes no recency fact: it is a data defect, and dating it arbitrarily would move a
score. Stub 5 made the same call for the same reason.04_verify/output/verify.mdAgent-run (no credentials needed):
/ returns 200 (agent)/vendor/metrics refuses an unauthenticated visitor — 307 to
/sign-in?redirect_url=%2Fvendor%2Fmetrics, so the vendor-only route policy holds (agent)Operator-demonstrated — NOT YET DONE. Every acceptance criterion on this surface is behind a vendor sign-in, so the agent can demonstrate none of them. The list below is the ask, not a record; nothing here may be read as verified until an operator reports back and the results are written in, attributed.
/vendor/metrics (operator)-18.0%), not 0% and not -18%
(operator)Fixed on branch (this stage):
unbandedReasons, rendering an empty "Why it is not banded" cell. Contradicted the spec's "every
reason that applies is listed". Added noMeasurableDelivery + a test.softDeletePlugin only registers
pre(/^find/), so the last-settlement Invoice.aggregate was reading soft-deleted documents —
worth up to 25 points, enough to lift a dormant customer out of the at-risk count. The
Activity.aggregate two lines below already guarded; this one now does too.Current quarter preset false-flagged the whole cohort. A part-elapsed period was being
compared against a complete previous one, so early in each quarter every customer read as a
near-total decline — zeroing the trend component and flagging Amber customers at renewal risk on
nothing but the calendar. Fixed by comparing like-for-like: for an open period the previous
window is narrowed to the same elapsed span, which is what the scope's "the equivalent
previous period" actually asks for. Covered by three new tests on trendWindows, including the
clamp that stops the window running past the previous period's own end.
(My first attempt at this suppressed the score entirely; the readiness audit correctly caught
that "0 of N" reads as "nobody is at risk" — the opposite of "we cannot tell". Reverted in favour
of the like-for-like comparison, which blanks nothing.)String(amount) emitted a bare number with no
currency, unlike every other money cell in this family. Now formatted in the vendor's reporting
currency, which the page already resolves for M8.One red CI round, mine, fixed:
cb74aab) went RED on Quality Project: changing
scoreCustomer's second parameter from Date to a ScoringContext left two single-line call
sites in the test file still passing AS_AT, which the batch rewrite of the other eight missed.
Typecheck caught both; 9e6c531 fixes them and CI is green. Recorded rather than quietly
re-pushed — it is exactly the kind of thing the factory exists to catch, and it did.Accepted, not fixed:
VendorDeliveryService.report() while the page already renders that service for
M3/M6. It is latency, not a correctness or index problem — the readiness audit confirmed
{tenantId, vendor}, {leadId, createdAt} and {tenantId, paid, paidAt} all cover the reads.
Hoisting one delivery report onto the page and passing the classification down is the fix, and it
means refactoring stub 3's service to expose its loader — a change worth making deliberately
rather than mid-Verify. Recorded here and in the Build notes.For Jamie — one judgement call, and one wording correction:
/pipeline define <slug>; if you accept the
discrepancy as a known omission, this note is the record.vendorClaimFilter" overstates the mechanism. Four of the
seven reads use it; the other three (Proposal, Activity, User) carry an explicit tenantId
plus an $in over an id set already filtered by {tenantId, vendor}. The security property
holds on every path — the security review confirmed both boundaries are closed, drill-downs
included — but the sentence in the Build notes describes one mechanism where there are two.Not this stage's work: docs sync and the changelog entry are Ship's (step 1 of 05_ship); the
readiness audit flagged their absence, which is expected of a stage:build PR.
Rollback: clean. No migration, no schema change, no stored score — reverting the commit is a complete rollback.
05_ship/output/changelog.mdYour dashboard now answers the question a renewal turns on. A headline counts the customers at renewal risk, and every customer carries a health score out of 100 that you can take apart down to the individual records.
Four things, weighted, and all four are on the row beside the score:
The four contributions add up to the score shown. Recency and trend are scored in bands rather than on a sliding curve, so you can reproduce any score yourself: read the number off the drill-down, find the band it falls in, and the points are the points. A customer 40 days quiet scores 17 for recency, the same as one 55 days quiet, and the exact day count is on the row either way.
Every part opens to the records that produced it — the responses, the engagements, the invoices — and those records reconcile to the number they sit under.
A customer is flagged when their health is red, or amber with collected spend down more than 15% on the equivalent previous period. Down exactly 15% is not flagged. The trend figure that triggered the flag is on the row, carried to one decimal, so a customer at −15.4% and one at −14.6% no longer look identical.
Where the period you are looking at is still running, the comparison is matched to the same elapsed stretch of the previous period. Three weeks into a quarter you are compared against the first three weeks of the last one, not against all thirteen — so an open quarter does not read as a collapse.
A customer with no delivered work yet, no satisfaction response, or nothing collected in either period is not scored zero on the part that is missing. They carry no total and no band, they are listed on their own with every reason stated against them, and they are left out of the at-risk count rather than being counted as safe. The parts they do have are still shown.
Delivered work with no agreed date recorded is left out of the reliability share rather than read as on time, and the number left out is stated beside it.
No score is stored anywhere. Each one is worked out from your records at the moment you look, and there is no screen — not for you, not for an administrator — that can move a score, a weight or a band. If a number looks wrong, the records behind it are the thing to look at, and they are one click away.
05_ship/output/investor-update.mdWho it's for: Vendors What shipped: Every customer scored, the count at renewal risk, and the records behind each part. Why it matters: Refine the Bridge — Q2-2026 Objective 1: Establish Product-Market Fit with Vendor Partners. Retention is what that objective rests on.
Missing evidence leaves a customer unscored, never counted as safe.
Dig deeper: https://github.com/sustentus/sustentus/pull/867 · https://help.sustentus.com/changelog/2026-08-19-vendor-customer-health-and-churn-risk
05_ship/output/release.mdheld at the gate on its
first pass, when the box was still unticked, and rewrote it only after reading the tick.7ed65e7, settled via ci-status.sh after the push that brought in the docs and
the changelog — every blocking check and commit status completed without failure:
Quality Project, Audit database, Migrate preview database, Migrate production database,
Project run labels, Review diff against CONVENTIONS.md, and Vercel web, docs and
help-centre — the two doc sites built on this head precisely because this run changed them.
Vercel demo, marketing and storybook were skipped by their ignore steps — recorded as
skipped, never quoted as passes. This record's own commit re-runs the same set, and the merge is
taken only on that round settling green.technical/packages/services — the Metric families bullet gains the fifth
family: shared/vendor-health.ts (the four weighted components, the two bucket tables, the
once-only rounding that makes the row add up, and the missing-component rule that withholds a
score rather than scoring zero) and vendorHealthService, including that it reuses the delivery
family's classifyEngagement rather than re-deriving Delivered, that an in-progress period is
compared against a like-for-like slice of the previous one, and that no score is persisted.business/roles — a Customer health and churn risk line under what a vendor
sees: the score and the at-risk count, the four parts and their evidence, the flag rule and the
15% threshold, the missing-evidence behaviour, and that nothing is stored and no role can adjust
a score, a weight or a band. feature-role-matrix deliberately unchanged: this stub adds no
permission and no role capability — /vendor/metrics stays roles: ["vendor"], the CSM read
route was cut at Define, and the metric-definitions row stub 1 recorded still covers the read.
platform-overview unchanged: another figure family on an existing dashboard is not a headline
platform capability..github/workflows/ship-note.yaml fires on this
merge. Both Dig deeper links were filled before the merge (PR #867 and the live changelog
entry), never left as placeholders.apps/docs/archive/pipeline-runs/. The vendor-metrics epic is
not finished — this is stub 6 of 9, and three stubs remain in .icm/intake/vendor-metrics/
(vendor-dashboard-reconciliation, vendor-retention-by-speed-and-region,
vendor-revenue-at-risk) — so the intake folder stays where it is.Every line below was established by reading the diff and by the 35 unit tests CI runs on every push. None of them was demonstrated on a signed-in preview — see the section after this one.
atRiskRecordSet, whose
rows are the flagged cohort and nothing elsescoreCustomer rounds inside each component and sums the results, so
the displayed figures are the summandsRECENCY_BUCKETS, unit-asserted at all three boundariesTREND_BUCKETS.
The exactly-−5% and exactly-−15% boundaries are unit-asserted; the first cut of this had
−5% falling in the wrong bucket and was fixed before the first pushevidenceRecordSet carries a "Component total" row per component, added when a
self-check found basis computed and never renderedisRenderableClaim gates it and a Claim
cannot be constructed without its RecordSetvendorClaimFilter; the other three (Proposal, Activity, User) carry an explicit
tenantId plus an $in over an id set already filtered by {tenantId, vendor}. The spec's
wording describes one mechanism where there are twoInvoice.aggregate, which was reading archived documents because
softDeletePlugin registers only pre(/^find/)The agent has no preview credentials, so the signed-in half of the DoD smoke was never
exercised. 04_verify/output/verify.md leaves all nine of those lines unticked and says so in
its own words. These are unchecked checks, not passed ones:
/vendor/metrics-18.0%), not 0% and not -18%No walkthrough clip was recorded for the #build thread — the agent cannot record one, and the
DoD calls the clip visibility rather than a gate.
Everything reachable without signing in was verified: the unit suite CI runs on every push, the
preview build on every head, the vendor-only route policy (/vendor/metrics 307s an
unauthenticated visitor to /sign-in), the scoping traced read by read, and a security review that
found no HIGH or MEDIUM finding.
/pipeline define amends the spec if Jamie would
rather the two matched.VendorDeliveryService.report() while the page already renders that service for M3 and
M6. Latency, not correctness — the readiness audit confirmed {tenantId, vendor},
{leadId, createdAt} and {tenantId, paid, paidAt} all cover the reads. The fix is to hoist one
delivery report onto the page and pass the classification down, which means refactoring stub 3's
service to expose its loader — worth doing deliberately rather than mid-Verify. Accepted, not
fixed; recorded here, in the Build notes and in Verify./vendor/health, /vendor/csat and the
vendor dashboard still show the old vendorService.getCustomerHealth churn prediction, computed
from a different signal set with no vendor scoping and no missing-evidence rule. Stub 9
(vendor-dashboard-reconciliation) closes that, as this spec's Out of scope records. Until it
does, a vendor can reach two different answers to "is this customer at risk".overallScore is written on two scales, and this figure inherits it. /csat stores 1–5;
/customer/satisfaction/[leadId] stores 1–10. The satisfaction component divides by 10, so a
genuine 5/5 from the public form contributes as if it were 2.5/10 — 20 points of the 40 lost on a
perfect score. Pre-existing, flagged at stub 5's ship and unchanged here; the fix needs the live
distribution before either write path is normalised. This run does not make it worse — it gives
the wrong number more weight, which is one more reason to schedule the bug-lane run.Context budget: within the Inputs table.