vendor-retention-by-speed-and-regionrun.md02_define/output/spec.mdRetention is the number that answers whether delivering faster makes customers come back, and it is the one number on the vendor dashboard that argues for the subscription itself. Today M7 has a definition record in the dictionary but no figure behind it: the metric grid shows it as not yet reported. A bucketed rate with no counts would be worse than nothing — a bucket of three customers is an anecdote and reads identically to a bucket of thirty — and customers with no recorded country would be quietly folded somewhere, hiding both the retention truth and the data-quality problem underneath it.
This is stub 7 of the vendor-metrics scope, which advances the Refine-the-bridge initiative and
Q2-2026 Objective 1 (Establish Product-Market Fit with Vendor Partners). It builds directly on
stub 3 (vendor-delivery-and-service-quality, PR #857), which defined Delivered and put the
delivery facts on the claim surface; M7's speed buckets are computed over exactly those delivered
engagements, so the two families cannot disagree about which engagements count.
M7 becomes a live claim on the vendor claim surface, alongside M1/M2, M3/M6, M8 and M4/M9.
The population is the customers of this vendor with at least one engagement delivered in the
previous period — ResolvedPeriod.previous, the equal-length period immediately preceding the
selected one (§3.4). Delivered means M3's definition, read from the existing delivery family.
The measure is the share of those customers who started at least one new engagement in the
current period. An engagement is a lead with an accepted proposal (M3's vocabulary). The date
it started is the lead's status-history transition into work_in_progress — the first durable
timestamp on the record once the customer has accepted and the work has become real. The proposal
document carries no acceptance timestamp of its own, and proposal.updatedAt moves whenever the
record is later edited, which BR-13 forbids as a basis date.
Speed buckets place each customer by their median delivery time across their engagements
delivered in the previous period: 14 days or fewer, 15 to 30, 31 to 60, over 60. Delivery time is
whole days from the date the lead was sourced (lead.createdAt, the same basis date M1 and M2's
cohort uses) to the date it reached Delivered. A median over an even number of engagements is the
mean of the two middle values, and the unrounded median places the customer — a median of 14.5
days falls in the 15-to-30 bucket, because rounding never happens before the bucketing (BR-19).
Region buckets group the country stamped on the engagement — lead.location.country,
denormalised onto the lead when it was raised — mapped to a region through the tenant's own
region-to-countries rows. Where a customer has several engagements delivered in the previous
period, their most recent one places them. Because the stamp is on the engagement and not on the
customer's profile, a customer who moves country keeps their past periods where they were:
history is not restated.
The unknown-region bucket is always shown and holds both unclassifiable cases — no country recorded on the engagement, and a country recorded that no region groups. It is never merged into a named region and never assigned the vendor's own (BR-17).
Every bucket carries its own customer count, however small, and nothing is suppressed or merged into an "other" bucket. Both breakdowns are cuts of one joint record set — one customer per row, carrying their median speed, their region and whether they returned — so the speed buckets and the region buckets sum to the same population and the same returner count by construction rather than by two aggregations agreeing (BR-18).
A rolling 12-month figure is shown alongside the headline, with its own population and returner counts: customers delivered in the 12 months before the trailing 12 months, returning in the trailing 12 months. Where the selected period is already the trailing 12 months, headline and rolling figure are the same claim and only one is shown.
The M7 definition record is updated by migration so the dictionary states these settled definitions — the delivery-time basis, the "started" basis and the unknown-region rule — rather than the looser seed text. Definitions change by migration and deploy; there is no write path (the Admin governance stub was retired).
work_in_progress
inside the current period.vendorClaimFilter (BR-23).vendor-dashboard-reconciliation.work_in_progress transition because
no proposal-acceptance timestamp is stored. If one is later added to the proposal record, this
basis date should move to it and the definition record updated with it — the measure is
unchanged either way.03_build/output/notes.mdfeat: vendor-retention-by-speed-and-region — M7 on the claim surfacepackages/services/src/shared/vendor-retention.ts (new): the arithmetic, with no DB
anywhere near it. Folds the previous period's delivered engagements into one row per
customer — median delivery time, region, returned — and then cuts that single list two ways.
Both breakdowns counting off one list is what makes the two reconciliations hold by
construction rather than by two aggregations agreeing (BR-18); two independent queries would
agree on the day they were written and drift by the first edge case.packages/services/src/shared/vendor-retention.test.ts (new): 24 unit tests written from the
acceptance criteria — bucket boundaries, the unrounded median, both unclassifiable region
cases, the two reconciliations, the empty-previous-period case, and the drill-down keys.packages/services/src/db/services/metric-dictionary/vendor-retention.ts (new): the reads.
Delivered comes from the delivery family rather than a second query (below); this file adds
only what the delivery facts do not carry — the lead's sourced date, its customer, and the
country stamped on it — plus the work_in_progress transitions and the tenant's region map.packages/services/src/db/services/metric-dictionary/vendor-delivery.ts: extracted the
fact-gathering half of report() into a public engagementInputs(). Behaviour-preserving
— report() now calls it and windows the result exactly as before. Done rather than copying
~150 lines of query-gathering into the retention service, which would have put two definitions
of "Delivered" in the codebase inside a month.packages/services/src/db/migrations/1787875400000-retention-metric-definition.ts (new):
brings M7's definition record onto the settled definitions — what delivery time is measured
from, what "started" means, and that the unknown-region bucket holds both unclassifiable
cases. population, period, opensTo and linksTo are deliberately untouched.apps/web/lib/vendor-retention-figures.ts (new) + apps/web/app/(app)/vendor/metrics/page.tsx:
M7 rendered through the existing claim surface, with both breakdowns narrowing the same cohort.shared/index.ts, db/services/index.ts, metric-dictionary/instance.ts.SPEED_BUCKETS is emitted whether or not anyone is in it, and no "other" bucket exists to
merge into.regionFor(null, …), and an empty-string
country is normalised to null at the read so it cannot become a region named "".regionsByCountry.get()
misses and falls through to the same constant.lead.location.country, stamped when the lead was raised; where a customer had several
engagements delivered in the period, the most recent one places them.median() returns the mean of the two middle
values for an even count, unrounded, and speedBucketFor buckets on that. Tested at 14 vs
14.5 days, and at every bucket boundary.work_in_progress in the current period — read from
status history, first transition per lead, and a lead already in the population cannot be
the evidence its own customer came back.null
when the selected preset is already last-12-months.matchValue over speedKey/regionKey, which the cohort rows carry as classification
rather than as a re-parsed label.rate is null, the headline renders —,
and the caveat says there is no population yet rather than showing a 0%.emptyRecordsLabel.Claim cannot be constructed without a
RecordSet; isRenderableClaim is asserted in the tests.asAt once and
this family adds no second clock of its own.vendorClaimFilter — both lead reads go through it, and the
status-history read takes its ids from the already-scoped lead read rather than
re-asserting scope against a collection that carries no vendor.Migrate preview database job applies migrations to the preview
database on every push, and its log on 4b32a79 shows
up: 1787875400000-retention-metric-definition with no "no M7 record to update" warning. So the
definition record was already live on the preview; only production waits for the merge. Still
worth reading the migration's up/down as a pair — they are inverse over the four fields they
touch and leave linksTo alone deliberately.vendor-delivery.ts was refactored, not just added to. report() is unchanged in
behaviour but no longer contains the gather. The existing delivery tests are the regression
check on that, and they were not modified.vendor-customer-health-and-churn-risk) landed on main after this was written and
constructs its own VendorDeliveryService().report(...), so /vendor/metrics now runs the
delivery gather three times. Each is bounded by one vendor's leads and well inside the
15-minute freshness ceiling, but engagementInputs() is the seam to hoist, and doing so is now
a change across M3, M5 and M7 together — see 04_verify/output/verify.md.04_verify/output/verify.md86c9364 — the head handed to Ship, settled via ci-status.sh after the last
push. Also GREEN on 5c48454, the code head; 86c9364 adds only this record and the Build-note
corrections.ENABLE_CLAUDE_REVIEW unset; its check run reports skipped, which ci-status.sh counts as
non-failing), so the review was run here. 6 findings, all 6 fixed on branch.Six commits, and one of them is a merge: origin/main moved six commits ahead while this run was
open, and M5 (vendor-customer-health-and-churn-risk, PR #867) landed touching all four barrel
and page files this branch touches. The PR had gone mergeable_state: dirty. origin/main is
merged in and the four conflicts resolved keeping both families, health before retention, in the
order the intake breakdown numbers them.
The agent cannot sign in. /vendor/metrics is requireRole("vendor") behind Clerk, and there
is no unauthenticated route in this diff, so every acceptance criterion below is the operator's
to demonstrate. What the agent could establish is recorded as traced — the code path read against
the criterion — and is not a substitute for seeing it.
work_in_progress in the period — traced; the
population for this side was wrong and is fixed this stage · operatorlast-90-days (rolling shown) and last-12-months (not shown)Claim cannot be built without a
RecordSet; isRenderableClaim asserted) · operatorvendorClaimFilter — traced, and re-checked by the security pass ·
operator: confirm a second vendor sees only their own customersMigrate preview database job on 4b32a79 logged up: 1787875400000-retention-metric-definition and
M7 now states its delivery-time and started bases, with no "no M7 record to update"
warning, so the record was found and updated on the preview database (agent)/vendor/metrics reachable — operatornotifyX call and no email (agent, traced)Fixed on branch (commit 5c48454):
returnedAt was fed all of the
vendor's lead ids, so a lead reaching work_in_progress without an accepted proposal counted
as a customer coming back for work M3 would not call an engagement — contradicting the spec's
own vocabulary. Narrowed to the accepted-proposal set.engagementInputs flatMaps over proposals, so
a lead carrying two accepted ones would appear twice, double-weighting that job in its
customer's median. Deduped by lead, earliest delivery winning, so the result does not depend on
query order.current-quarter preset a whole previous quarter was measured against a few elapsed days, with
nothing said. Now stated in the caveat — ahead of the rolling figure, which keeps its own
breakdown — and in the reconciliation.isActive. Deactivating a populated region would have moved
its customers into the unknown bucket retroactively, restating a completed period (BR-13).
Filter dropped; the admin region list reads them all for the same reason.cohortClaim set on both.entityId and
status together, so the planner filtered one of them in memory on every render —
over the tenant's whole status history. Added {tenantId, entityType, status, entityId}
(ESR-ordered, not a prefix of either existing index) as migration
1787875500000-statushistories-entity-status-index.ts, with the Schema.index(...)
declaration in lockstep. Narrowing the query by changedAt instead was rejected: it would
break the first-transition-only invariant and restate completed periods.RETURNED_STATUS hardcoded "work_in_progress"
while shared/vendor-funnel.ts already exports ACTIVATION_STATUS for the same transition.
Imported instead — statuses live in workflows.json, and main set the precedent this week by
exporting ACCEPTED_PROPOSAL_STATUS for the health family.Recorded, not fixed — each is bigger than this run:
/vendor/metrics. M3/M6 call
engagementInputs(), M7 calls it again, and M5 (landed on main) constructs its own
VendorDeliveryService().report(...). Each is bounded by one vendor's leads, so this is well
inside the 15-minute freshness ceiling, but it is now three full multi-collection gathers.
engagementInputs() is the seam: hoisting it to one call in page.tsx and passing the inputs
down fixes it for all three families at once. That is a change to M3, M5 and M7 together and
belongs in intake, not in this PR.Lead.find(scope) has no ceiling and feeds every lead id into the
$in above. Grows monotonically with vendor history. The same pattern is in the delivery and
health families — a ceiling or an archival story is a family-wide decision.vendor-health.test.ts shows the pattern — extract the pure
window selection and test it. Worth doing; not worth holding this PR for.Corrections to the Build notes, which were written before the evidence existed:
notes.md said "the migration has not been run". It had — the Migrate preview database
job applies migrations to the preview database on every push, and its log shows M7 updated.
Production still gets it on merge.notes.md said the gather runs "twice"; M5 landed since, making it three.Both are corrected in notes.md on this branch rather than left to be rediscovered.
apps/docs and no apps/help.
M7 going live on the vendor claim surface is user-visible, and the three features that merged
just before it each shipped a changelog page. docs-sync and changelog-entry are Ship's step.page.tsx — and both migrations have working, symmetric downs. A revert commit suffices.05_ship/output/changelog.mdYour dashboard now answers the question the subscription argues for. Of the customers who had work delivered in the previous period, it shows the share who started something new in this one — cut by how fast their work was delivered, and by where they are.
A rate on its own hides its own sample. A bucket of three customers is an anecdote and reads identically to a bucket of thirty, so every bucket shows how many customers are in it and how many of them came back. Nothing is suppressed for being small, and there is no "other" bucket to be merged into.
Both cuts count off the same list of customers, so the speed buckets and the region buckets add up to the same population and the same number of returners. If they ever disagreed, one of them would be wrong — here they cannot.
Each customer is placed by their median delivery time across the work delivered to them in the previous period — not their average, so one unusually long job does not drag a customer into a slower bucket than the rest of their work deserves.
Delivery time runs from the day the lead was sourced to the day the work reached delivered, counted as whole calendar days in your own time zone. The four buckets are 14 days or fewer, 15 to 30, 31 to 60, and over 60. The median is used exactly as calculated: a customer at 14 and a half days sits in the 15-to-30 bucket, because nothing is rounded before the bucketing.
Customers are grouped by the country recorded on the engagement itself, not the one on their profile today. A customer who moves country stays counted where they were at the time, so a period you have already read never changes underneath you. Where a customer had several engagements delivered in the period, their most recent one places them.
Countries map to regions through your own region list. Where no country was recorded, or where a country belongs to no region you have set up, the customer appears in an unknown-region bucket that is always shown — never folded into a named region, never assigned to yours, and never dropped. It is both a retention figure and a data-quality one: a large unknown bucket is telling you something.
A trailing twelve-month figure sits beside the headline with its own customers and its own returners, so a quiet quarter has a longer view next to it. When the period you are looking at is already the last twelve months, it is not shown twice.
Every bucket opens to the customers in it — the ones who came back and the ones who did not — adding up to exactly the counts on the row. Where the previous period had no customers at all, no rate and no change is shown: a change from nothing is not a percentage, and the surface says what it is waiting for rather than showing a zero that looks like a result.
Where the period you are looking at is still running, it says so, and says that it is being compared against a period that has finished.
05_ship/output/investor-update.mdWho it's for: Vendors What shipped: Retention split by delivery speed and by region, every bucket carrying its count. Why it matters: Refine the Bridge — Q2-2026 Objective 1: Establish Product-Market Fit with Vendor Partners. Retention argues for the subscription.
Both cuts count off one customer list, so they reconcile by construction.
Dig deeper: https://github.com/sustentus/sustentus/pull/866 · https://help.sustentus.com/changelog/2026-08-19-vendor-retention-by-speed-and-region
05_ship/output/release.md66853a6, settled via ci-status.sh after the push that brought in the docs, the
changelog and the ship note. Genuine passes: Quality Project (format, lint, typecheck and the
27 unit tests), Audit database, Migrate preview database, Project run labels, and the
Vercel web, docs and help-centre previews — the two doc sites built on this head precisely
because this run changed them. Two check runs concluded skipped, and neither is quoted as a
pass: Migrate production database (it runs on main, not on a branch) and
Review diff against CONVENTIONS.md (ENABLE_CLAUDE_REVIEW is unset, so the CI Claude review
did not run at all — the review that stands is the high pass Verify ran here, 6 findings, all
6 fixed on branch). Vercel demo, marketing and storybook were skipped by their ignore
steps. The commits carrying this record touch only .icm/runs/**, so their rounds re-run the
workflow checks and the web preview while the docs and help-centre previews are ignored for
that diff. ci-status.sh was run to a settled GREEN on each pushed head, and the merge is
taken only on the final head settling green — which head that is, the PR's own merge commit
records, since this file cannot name a SHA it precedes.technical/packages/services — the Metric families bullet gains the sixth
family: shared/vendor-retention.ts (one customer list cut two ways, which is what makes the two
breakdowns reconcile by construction rather than by two aggregations agreeing; calendar-day
delivery time with both ends reduced to local midnight in the vendor's timezone; the unrounded
median; every speed bucket emitted whether or not anyone is in it; the always-shown, always-last
unknown-region bucket holding both unclassifiable cases) and vendorRetentionService — that it
takes Delivered from the delivery family's own engagementInputs() rather than re-deriving it,
dedupes to one row per lead, reads the first work_in_progress transition per lead over the
accepted-proposal set only, and reads region rows without an isActive filter so deactivating a
populated region cannot restate a completed period.business/roles — a Retention by delivery speed and region line under what a
vendor sees: the population and the measure, that every bucket carries its count and both cuts
add up to the same population and returner count, the median-days basis, counted-where-they-were,
the unknown-region bucket, the rolling twelve months, and the drill-down.
feature-role-matrix deliberately unchanged: this stub adds no permission and no role
capability — /vendor/metrics stays requireRole("vendor"), and the metric-definitions row
stub 1 recorded still covers the read. platform-overview unchanged: a further figure family on
an existing dashboard is not a headline platform capability. The spec names Vendor and SDM as
personas, but no SDM route was added and no criterion is an SDM's — the changelog entry is
therefore personas: [vendor], which is what "actually affected" means..github/workflows/ship-note.yaml fires on this
merge. Both Dig deeper links were filled before the merge (PR #866 and the live changelog
entry), never left as placeholders. Body is 58 words against the 60-word cap.apps/docs/archive/pipeline-runs/. The vendor-metrics epic is
not finished — this is stub 7 of 9, and two stubs remain in .icm/intake/vendor-metrics/
(vendor-dashboard-reconciliation, vendor-revenue-at-risk) — so the intake folder stays where
it is.Every line below was established by reading the diff and by the 27 unit tests CI runs on every push. None of them was demonstrated on a signed-in preview — see the section after this one.
SPEED_BUCKETS entry is
emitted whether or not anyone is in it, and there is no "other" bucket to merge intoregionFor(null, …),
and an empty-string country is normalised to null at the read so it cannot become a region
named ""median() returns the mean of the two middle values
for an even count, unrounded, and speedBucketFor buckets on that. Unit-asserted at 14 vs
14.5 and at every bucket boundarywork_in_progress in the period — first transition per
lead from status history, and a lead already in the population cannot be the evidence its own
customer came back. The population for this side was wrong at Build and was fixed at
Verify: it read every lead rather than every accepted-proposal engagementnull when
the selected preset is already last-12-monthsmatchValue
over speedKey/regionKey, which the cohort rows carry as classification rather than as a
re-parsed label. Each carries an explicit cohortClaim, added at Verify: the default caption
is false against a row holding both a Returned and a Customers countrate is null, the headline renders —,
and the caveat says there is no population yet rather than showing a 0%emptyRecordsLabel, which names what it is waiting forClaim cannot be constructed without
its RecordSet; isRenderableClaim is asserted in the testsasAt once and this
family adds no second clock of its ownvendorClaimFilter — both lead reads go through it, and the
status-history read takes its ids from the already-scoped lead read rather than re-asserting
scope against a collection that carries no vendor. Re-checked by the security pass, which
found no HIGH or MEDIUM finding1787875400000-retention-metric-definition, and this one was demonstrated: the
Migrate preview database job on 4b32a79 logged it applied, with no "no M7 record to
update" warning, so the record was found and updated on the preview database/vendor/metrics is requireRole("vendor") behind Clerk and this diff adds no unauthenticated
route, so the agent could not sign in and the rendered half of the DoD smoke was never
exercised. 04_verify/output/verify.md leaves fifteen of the sixteen lines unticked and says so
in its own words. These are unchecked checks, not passed ones:
/vendor/metricslast-90-days and not shown on last-12-monthscurrent-quarter and reads as intended — this is the fix for
one of the six Verify findings and the most worthwhile thing to look atEverything reachable without signing in was verified: the 27 unit tests CI runs on every push, the preview build on every head, the scoping traced read by read, and a security review with no HIGH or MEDIUM finding.
/vendor/metrics. M3/M6 call
engagementInputs(), M7 calls it again, and M5 constructs its own VendorDeliveryService() .report(...). Latency, not correctness — each is bounded by one vendor's leads and well inside
the 15-minute freshness ceiling. engagementInputs() is the seam: hoisting it to one call in
page.tsx fixes all three families at once, and that is a change to M3, M5 and M7 together, so
it belongs in intake rather than in this PR.Lead.find(scope) has no ceiling and feeds every lead id into an $in.
Grows monotonically with vendor history. The same pattern is in the delivery and health families,
so a ceiling or an archival story is a family-wide decision, not this run's.vendor-health.test.ts shows the pattern — extract the pure
window selection and test it.proposal.updatedAt
is disqualified by BR-13. The work_in_progress transition is the first durable timestamp once
the work is real. If a genuine acceptance timestamp is ever added, this basis should move to it..icm/runs/. vendor-delivery-and-service-quality (stub 3,
PR #857) merged and its changelog entry is live, but close-out.sh was never run for it, so its
folder still sits in .icm/runs/ and is absent from apps/docs/archive/pipeline-runs/. Not this
run's to close — recorded so it is not rediscovered a third time.page.tsx — and both migrations have working, symmetric downs. A revert commit suffices.Context budget: within the Inputs table.