Skip to Content

← All archived runs

Run: vendor-customer-health-and-churn-risk

run.md

Run: vendor-customer-health-and-churn-risk

  • branch: claude/vendor-customer-health-churn-pipeline-97op22
  • pr: #867

02_define/output/spec.md

Spec: Customer health and churn risk, with every component visible

  • slug: vendor-customer-health-and-churn-risk
  • personas: Vendor
  • touches: packages/services/src/shared/vendor-health.ts, packages/services/src/shared/period.ts, packages/services/src/db/services/metric-dictionary, apps/web/app/(app)/vendor/metrics, apps/web/components/vendor/metrics, apps/web/lib
  • complexity: complex

Scope: .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.

Problem

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.

Proposed change

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.

1. Where the arithmetic lives

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.

2. The population

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).

3. The score — four components, stated weights, stated buckets

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".

4. The mixed basis, declared

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.

5. Bands, and what "at renewal risk" means

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.

6. Not banded — insufficient data (Jamie's ruling, 2026-08-17)

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.

7. Nobody can adjust a score (BR-25)

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.

8. What renders, and what it opens to

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:

  • The claim — headline: the count at renewal risk. Records: one row per at-risk customer — name, score, band, the component that put them there, and the collected-spend change where that is what flagged them.
  • Breakdown "Every customer, scored" — the full population, one row each: name, score or unbanded state, band, and the four component contributions as their own columns. Rows carry 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).
  • Breakdown "Not banded — insufficient data" — the unbanded customers with every reason that applies and the components they do have.
  • Cohort — every record behind every component, one row each: customer, component, record type, date, and the value it contributes. Selecting a customer in either breakdown opens exactly their records, which is how each component opens to the records behind it.

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).

9. The worked example, and the slip in it

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.

Acceptance criteria

  • The headline is the count of customers at renewal risk, and opening it lists exactly those customers, each with their score, their band, and the component that put them there.
  • The four components, their weights and their evidence are visible on every scored customer, and the four contributions shown add up to the score shown beside them, on every row (BR-18).
  • Each component's contribution is rounded once and the score is the sum of those rounded contributions; nothing is rounded before a component is computed (BR-19).
  • Recency scores 25 at 30 days or fewer, 17 at 31–60, 8 at 61–90 and 0 beyond 90, as four discrete buckets; no test or fixture asserts the scope's 47-days-scores-12 slip.
  • Commercial trend scores 15 when flat or up, 10 down to 5%, 5 down to 15% inclusive, and 0 beyond that, as four discrete buckets.
  • The scope's Northwind example is asserted at 34 — 16 + 8 + 10 + 0 — Red and flagged; nothing asserts the scope's stated total of 38.
  • The same inputs always produce the same score, no score is stored, and no role — Admin included — has any route in the product to adjust a score, a weight or a band (BR-25).
  • A customer with no engagement that reached Delivered shows as "not yet rated" with its available components, carries no total score, is not banded, and is not hidden.
  • A customer with a delivered engagement and no recorded satisfaction response shows as "awaiting satisfaction" — not scored zero on satisfaction and not banded (BR-17).
  • A customer with nothing collected in either period shows as "no commercial trend" and is not banded; a customer with real spend last period and nothing this period is scored as a −100% decline, not as missing.
  • A customer missing more than one component lists every reason, and unbanded customers are counted in their own bucket beside the headline and excluded from the at-risk count.
  • Churn risk is Red, or Amber with collected spend down more than 15% on the equivalent previous period — a customer down exactly 15% is not flagged and scores 5, not 0 — and the trend figure that triggered the flag is visible on the row.
  • Every component opens to the records behind it, and those records reconcile to the contribution shown (BR-2).
  • Delivered engagements with no agreed date recorded are excluded from the reliability share rather than read as on time, and their count is stated beside the component (BR-17).
  • The claim states that the score is a snapshot and that only the commercial trend is period-bound, naming the two periods it compares (BR-1, BR-5).
  • M5 reaches the screen only through the claim surface, and a claim with no record set or no reconciliation lines does not render.
  • Every read is scoped through vendorClaimFilter, so one vendor's customers are unreachable from another's by any route including the drill-downs (BR-23).
  • A soft-deleted customer's score and components stay in every aggregate while their name is replaced in the drill-down.

Out of scope

  • Repointing /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.
  • Alerting a vendor when a score crosses a band. Churn risk is shown on the dashboard, never pushed — excluded scope-wide.
  • Predicting churn, or any model-derived score. This is stated arithmetic over stated inputs. The existing churnPrediction heuristic is not extended, ported or reused.
  • Letting a vendor configure weights or bands. They are the business's, identical for everyone. And per §7 above there is no in-product route for anyone to change them — the Admin write path BR-24 described belongs to the retired admin-metric-definition-governance stub.
  • Storing a history of scores, or restating one. No score is persisted, so M5 publishes no history and there is no earlier period to restate. A trend of health over time is a different metric and is not one of the ten.
  • M10 — Revenue at Risk. Putting money against the customers this stub flags is stub 8, which depends on this one. No money figure is computed here beyond the collected-spend inputs the trend component needs.
  • Changing what the satisfaction survey asks, how it is issued, or who it is attributed to — the Customer Satisfaction scope (BR-14).
  • Any CSM or SDM route onto M5. Reverted at Define revision on Jamie's ruling: M5 is vendor-only this run. Scope §3.7 grants CSM funnel, activation and delivery figures and SDM delivery reliability and service quality — health is in neither list — and the shared CSM/SDM route onto the metric surface belonged to the retired 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.
  • Credits and refunds against Collected. The invoice model has no credit-note concept, so §3.5's "a credit reduces Collected on the date it is issued" is not representable today; the trend component reads Collected as stub 4 defined it.
  • Benchmarking, forecasting, exporting and restating history — excluded scope-wide.

Open questions

  • All-time satisfaction has no recency decay. A customer who scored 2 two years ago and 5 last month averages 3.5, and the score cannot tell that apart from two 3.5s. The M5 record's basis date is explicit that the score is not period-bound, so all-time is the definition, and weighting recent responses more heavily would be a new rule the scope never gave. Non-blocking; worth a line in the Build notes so a later reader does not read it as an oversight.
  • The recency floor makes the component generous for a dormant customer. An engagement sourced three months ago with nothing since scores near zero, which is right — but the floor means the component can never be missing, so a customer is never unbanded for recency alone. That is deliberate: it keeps the unbanded bucket to the three states that genuinely have no record. Non-blocking.
  • The interior bucket values are this spec's, not the scope's, and are not published. 17 and 8 for recency, 10 and 5 for trend, are the thirds of each weight; only the outer buckets come from the scope's stated thresholds. The M5 definition's existing settings stay accurate under buckets — full marks within 30 days, none beyond 90, declining is down more than 15% — so nothing there becomes wrong and no migration is needed. But a vendor sees each component's contribution without seeing the table that produced it. Publishing the four rows would be a definition change and therefore a migration, which is not in this run. Non-blocking.

03_build/output/notes.md

Build notes: vendor-customer-health-and-churn-risk

  • commits: feat: … — M5 customer health and churn risk on the claim surface
  • ci: GREEN on fb24da7 — Quality Project (format, lint, typecheck, tests), Vercel web + marketing previews, conventions review, all first pass

What changed

  • packages/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.

Decisions Build made, and why

  • No 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.
  • Reliability reuses stub 3's classification rather than re-deriving Delivered (BR-4): 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.
  • Engagements join to customers by 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.
  • A local signed-percent formatter. 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.
  • Both period windows are tested explicitly when bucketing collections, rather than splitting on a single boundary, so a record in any gap between them is dropped rather than counted as previous-period spend and quietly moving the trend.

Acceptance criteria status

  • Headline is the count at renewal risk, opening to exactly those customers with score, band and the component that put them there — healthClaim + atRiskRecordSet.
  • Four components, weights and evidence visible on every scored customer, and the contributions add up to the score — scoredRecordSet, and each component's own line with its derivation heads its records in the drill-down.
  • Each contribution rounded once, score is their sum, nothing rounded before a component is computed.
  • Recency 25 / 17 / 8 / 0 as four discrete buckets; nothing asserts the 47-days-scores-12 slip.
  • Same inputs, same score; no score is stored anywhere and no write path exists (BR-25).
  • No delivered engagement → "not yet rated", no total, not banded, not hidden.
  • Delivered with no response → "awaiting satisfaction", not scored zero, not banded.
  • Nothing collected either period → "no commercial trend", unbanded; real prior spend and none now → a −100% decline, not missing.
  • Every applicable reason listed; unbanded counted in their own bucket and excluded from the at-risk count.
  • Churn risk is Red, or Amber down more than 15%; exactly 15% scores 5 and is not flagged; the trend figure is on the row.
  • Every component opens to its records, which reconcile to the contribution shown.
  • Delivered engagements with no agreed date excluded from the reliability share and their count stated beside the component.
  • The claim states the score is a snapshot and only the trend is period-bound, naming both periods.
  • M5 reaches the screen only through the claim surface; isRenderableClaim still gates.
  • Every read scoped through vendorClaimFilter.
  • A soft-deleted customer keeps score and components; only the name leaves.

Notes for Verify

  • The second delivery pass is the thing to look at. It is correctness-preserving and deliberate, but it is the one place this run adds real query cost to an existing page.
  • Recency's closed list of dated facts — activity trail, milestone accepted, bill settled, response recorded, engagement sourced — is the spec's, and the last of them is the floor that means recency is never missing. So no customer is ever unbanded for recency alone; the unbanded bucket is exactly the three stated states.
  • A completed CSAT with no 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.
  • The scope's worked total of 38 is not asserted anywhere; the spec restates it at 34 under the buckets, and the tests assert 34.

04_verify/output/verify.md

Verify: vendor-customer-health-and-churn-risk

  • ci: GREEN on 9e6c531 — the last change to shipping code, settled via ci-status.sh. This file is the only commit after it; the verdict was re-confirmed on the final head.
  • previews smoked: web (https://web-git-claude-vendor-customer-health-churn-pi-b8a408-sustentus.vercel.app) · demo, docs, help-centre, marketing, storybook skipped for this diff (no preview built for this commit — correctly, the diff touches only apps/web and packages/services)
  • production-readiness: run — 4 blockers, 3 advisories. Three blockers fixed on branch; one (docs/changelog) is Ship's work, not Verify's. Detail below.
  • code-review: high (spec complexity: complex) — 6 findings; 5 fixed on branch, 1 accepted.
  • security-review: run — no HIGH or MEDIUM findings. Read-only, server-side, tenant- and vendor-scoped on every path; no new route, env var, dependency or rendering path.
  • playwright: TODO — manual DoD smoke performed instead

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

Agent-run (no credentials needed):

  • The preview builds and serves — / 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)
  • Every acceptance criterion traced to its code path in the diff, and the pure arithmetic behind each is pinned by 35 unit tests that CI runs (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.

  • auth: a vendor signs in and reaches /vendor/metrics (operator)
  • M5 renders a headline of the form "N of M" — N customers at renewal risk (operator)
  • Opening the headline lists exactly those customers, each with score, band and the component that put them there (operator)
  • "Every customer, and the four components behind its score" — the four contributions on each scored row add up to the score beside them (operator; the arithmetic is unit-pinned, the rendering of it is not)
  • Clicking a customer opens that customer's records, and each component's own line states how it was worked out (operator)
  • "Customers with no band, and why" lists a reason against every row — no empty cells (operator)
  • A trend figure renders with a sign and one decimal (e.g. -18.0%), not 0% and not -18% (operator)
  • Trend evidence rows show money with a currency symbol, not a bare number (operator)
  • Switching the period selector to Current quarter does not collapse the cohort: customers stay banded, and the caveat says the comparison was matched to the same elapsed span (operator — this is the fix for the most serious finding below)
  • payments: not touched — this run reports money, it never moves it (agent, from the diff)
  • notifications: none expected — no notification path in the diff (agent, from the diff)

Findings & cleanup

Fixed on branch (this stage):

  • Unbanded with no reason given. A customer whose delivered engagements all lacked an agreed date got a null reliability component — so no score, no band — but no entry in unbandedReasons, rendering an empty "Why it is not banded" cell. Contradicted the spec's "every reason that applies is listed". Added noMeasurableDelivery + a test.
  • Archived invoices fed the recency component. 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.
  • The 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.)
  • The threshold figure was unreadable at whole-percent precision. A customer flagged at −15.4% and one correctly unflagged at −14.6% both rendered "−15%", directly beside the reconciliation line saying exactly 15% is not flagged. The trend change now carries one decimal.
  • Unformatted money in the trend evidence rows. 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:

  • The first push of these fixes (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:

  • Two full passes over the delivery collections per render. The health service runs its own unbounded 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:

  • The spec says three unbanded states; the code now has four. Spec §6 names not yet rated, awaiting satisfaction and no commercial trend. The fourth — no delivery reliability, when every delivered engagement is missing its agreed date — follows directly from the spec's own stated principle ("a component is missing only when no record could produce it") and from the criterion requiring every applicable reason to be listed, so I read it as a spec omission that Build surfaced rather than a scope change. The code is right; §6's table is now incomplete. If you want the spec amended to match, that is /pipeline define <slug>; if you accept the discrepancy as a known omission, this note is the record.
  • "Every read is scoped through 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.md


title: Which of your customers are you about to lose date: 2026-08-19T18:30:00Z personas: [vendor] slug: vendor-customer-health-and-churn-risk pr: https://github.com/sustentus/sustentus/pull/867

Which of your customers are you about to lose

Your 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.

What the score is made of

Four things, weighted, and all four are on the row beside the score:

  • Satisfaction — the scores that customer has recorded, out of 40
  • Engagement recency — how long since they last engaged, out of 25
  • Delivery reliability — how much of their delivered work landed on or before the date you agreed, out of 20
  • Commercial trend — which way their spend is moving, out of 15

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.

What "at renewal risk" means

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.

Missing evidence is not a bad score

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.

Nothing here can be adjusted

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.md

The customers you are about to lose, named

Who 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.md

Ship: vendor-customer-health-and-churn-risk

  • pr: #867 · merge: authorised — Ready to merge ticked by Jamie; this record rides the squash, so it carries no post-merge SHA. The PR's own merged state is the record of the merge. Ship wrote this line as held at the gate on its first pass, when the box was still unticked, and rewrote it only after reading the tick.
  • CI: GREEN on 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 docs: 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 docs: 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.
  • release notes: both
  • sent: ship note queued for #product-update — .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.
  • close-out: archive this run to 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.

Acceptance check (vs spec)

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.

  • The headline is the count of customers at renewal risk, and opening it lists exactly those customers with score, band and the component that put them there — atRiskRecordSet, whose rows are the flagged cohort and nothing else
  • The four components, their weights and their evidence are visible on every scored customer and add up to the score beside them (BR-18) — unit-asserted on every fixture, including the unbanded ones that carry a partial component set
  • Each contribution is rounded once and the score is the sum of those rounded contributions (BR-19) — structural: scoreCustomer rounds inside each component and sums the results, so the displayed figures are the summands
  • Recency scores 25 / 17 / 8 / 0 across the four day buckets; nothing asserts the scope's 47-days-scores-12 slip — RECENCY_BUCKETS, unit-asserted at all three boundaries
  • Commercial trend scores 15 / 10 / 5 / 0 across the four change buckets — TREND_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 push
  • The Northwind example is asserted at 34 — 16 + 8 + 10 + 0 — Red and flagged; nothing asserts the scope's stated 38
  • The same inputs always produce the same score, no score is stored, and no role has any route to adjust a score, a weight or a band (BR-25) — the arithmetic module imports no database and the service writes nothing; there is no collection and no route to adjust
  • A customer with no delivered engagement shows as "not yet rated", keeps its available components, carries no total, is not banded and is not hidden
  • A customer with a delivered engagement and no recorded response shows as "awaiting satisfaction", not scored zero (BR-17)
  • A customer with nothing collected in either period shows as "no commercial trend"; one with real spend last period and nothing this period is scored as a −100% decline, not as missing
  • A customer missing more than one component lists every reason, and unbanded customers are counted in their own bucket and excluded from the at-risk count — and now list a fourth reason the spec does not name; see the judgement call below
  • Churn risk is Red, or Amber with collected spend down more than 15%; exactly 15% is not flagged and scores 5 — unit-asserted, and the flag reads the decline rather than the trend score so the two can never drift apart. The trend figure is on the row, to one decimal, which Verify fixed: at whole percents −15.4% and −14.6% both rendered "−15%" beside a line saying exactly 15% is not flagged
  • Every component opens to the records behind it and they reconcile to the contribution shown (BR-2) — evidenceRecordSet carries a "Component total" row per component, added when a self-check found basis computed and never rendered
  • Delivered engagements with no agreed date are excluded from the reliability share rather than read as on time, and their count is stated beside the component (BR-17)
  • The claim states that the score is a snapshot and that only the commercial trend is period-bound, naming the two periods it compares (BR-1, BR-5)
  • M5 reaches the screen only through the claim surface, and a claim with no record set or no reconciliation lines does not render — structural: isRenderableClaim gates it and a Claim cannot be constructed without its RecordSet
  • One vendor's customers are unreachable from another's by any route including the drill-downs (BR-23) — the security property holds on every path and the security review confirmed both boundaries closed, but the mechanism is two, not one. Four of the seven reads go through vendorClaimFilter; 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 two
  • A soft-deleted customer's score and components stay in every aggregate while their name is replaced in the drill-down — and the soft-delete guard was added at Verify to the last-settlement Invoice.aggregate, which was reading archived documents because softDeletePlugin registers only pre(/^find/)

Still the operator's, carried from Verify unperformed

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:

  • a vendor signs in and reaches /vendor/metrics
  • the headline renders as "N of M" and opens to exactly the flagged customers
  • the four contributions on a scored row add up to the score beside them as rendered (the arithmetic is unit-pinned; the rendering of it is not)
  • a customer opens to its own records, with each component's line stating how it was worked out
  • "Customers with no band, and why" lists a reason against every row, with no empty cells
  • a trend figure renders signed and to one decimal (-18.0%), not 0% and not -18%
  • trend evidence rows show money with a currency symbol
  • switching the period selector to Current quarter does not collapse the cohort, and the caveat says the comparison was matched to the same elapsed span — this is the fix for the most serious finding of the run and the single most worthwhile thing to look at

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.

Carried forward

  • The spec names three unbanded states; the code has four. Spec §6 lists not yet rated, awaiting satisfaction and no commercial trend. The fourth — no delivery reliability, when every delivered engagement is missing its agreed date — follows from the spec's own principle that a component is missing only when no record could produce it, and from the criterion requiring every applicable reason to be listed. Verify read it as a spec omission Build surfaced rather than a scope change: the code is right and §6's table is incomplete. Left as a known omission with this note as the record; /pipeline define amends the spec if Jamie would rather the two matched.
  • Two full passes over the delivery collections per render. The health service runs its own unbounded 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.
  • Two values for one concept are still knowingly live. /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.
  • The demo tenant will show thin health data until it is re-seeded with activity and collections spread across the storyline; several customers will sit in the unbanded bucket, which is correct behaviour on the data rather than a defect.
  • Rollback is clean. No migration, no schema change, no stored score, no new environment variable — reverting the commit is a complete rollback.

Context budget: within the Inputs table.