Skip to Content

← All archived runs

Run: vendor-retention-by-speed-and-region

run.md

Run: vendor-retention-by-speed-and-region

  • branch: claude/vendor-retention-pipeline-47fs22
  • pr: #866

02_define/output/spec.md

Spec: Retention by delivery speed and region, in buckets that sum

  • slug: vendor-retention-by-speed-and-region
  • personas: Vendor, SDM
  • touches: packages/services/src/shared/vendor-retention.ts (new), packages/services/src/db/services/metric-dictionary/vendor-retention.ts (new), packages/services/src/db/services/metric-dictionary/{instance,customer-name}.ts, packages/services/src/db/migrations (M7 definition record), apps/web/lib/vendor-retention-figures.ts (new), apps/web/app/(app)/vendor/metrics/page.tsx
  • complexity: complex

Problem

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

Proposed change

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

Acceptance criteria

  • Speed buckets sum to the whole population, and so do region buckets — same population, same returner count, both reconciling on screen (BR-18).
  • Every bucket shows the number of customers in it, however small, and nothing is suppressed or merged into an "other" bucket.
  • A customer whose engagement carries no recorded country appears in the unknown-region bucket, never silently dropped and never assigned to the vendor's own region (BR-17).
  • A customer whose engagement carries a country that no region groups appears in that same unknown-region bucket, rather than being dropped or given a region of its own.
  • A customer who changes country is counted where they were at the time of the engagement — the country stamped on the lead, not the one on their profile today — so a completed period never moves (BR-13).
  • Speed buckets use the median delivery time across the customer's engagements delivered in the previous period, not the mean, and the median is taken before any rounding (BR-19).
  • Delivery time runs from the date the lead was sourced to the date it reached Delivered, and the surface states that basis.
  • A customer counts as returned when a new engagement of theirs reached work_in_progress inside the current period.
  • The rolling 12-month figure is shown with its own population and returner counts; when the selected period is already the trailing 12 months it is not shown twice.
  • Opening any bucket lists its customers, retained and not retained, adding to the bucket counts exactly (BR-2).
  • Where the previous period had no customers, no rate and no change is shown — a change from nothing is not a percentage (§3.4).
  • A vendor with no delivered engagements sees nothing rather than a zero that looks like a result, and the surface says which records it is waiting for.
  • The figure renders only through the claim surface, with its record set and reconciliation lines; it cannot reach the screen without them.
  • The read carries the surface's single as-at moment and its freshness statement against the 15-minute ceiling, sourced once per read (BR-20).
  • Every route to the figure, including each bucket drill-down, returns only the signed-in vendor's own customers and engagements, through vendorClaimFilter (BR-23).
  • The M7 definition record in the dictionary states the settled delivery-time basis, the "started" basis and the unknown-region rule, and the vendor grid and Admin registry read the same record.

Out of scope

  • Benchmarking a vendor's retention against other vendors or an industry average.
  • Fixing the missing billing countries the unknown-region bucket exposes — surfacing them is the job here.
  • Forecasting retention, or projecting a part-period to a full one.
  • Revenue-retention measures — NRR, GRR, renewal rate, expansion and churn revenue. The whole scope excludes them; M7 counts customers, not revenue.
  • Changing the period selector's three presets, or adding a quarter-against-previous-quarter preset of M7's own. M7 reads the selected period and its equal-length predecessor, which for the current-quarter preset is the quarter-against-previous-quarter comparison the scope names.
  • Any Admin write path over the definition record — retired with the governance stub (BR-24 goes unbuilt; see the scope breakdown's Retired section).
  • Reconciling the existing vendor dashboard's retention figures against these definitions — that is stub 9, vendor-dashboard-reconciliation.
  • Region and country management itself; M7 reads the tenant's existing region rows.

Open questions

  • Non-blocking: the "started a new engagement" basis is the 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.md

Build notes: vendor-retention-by-speed-and-region

  • commits: feat: vendor-retention-by-speed-and-region — M7 on the claim surface
  • ci: GREEN on the pushed head (see below)

What changed

  • packages/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-preservingreport() 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.
  • Barrels: shared/index.ts, db/services/index.ts, metric-dictionary/instance.ts.

Acceptance criteria status

  • Speed buckets sum to the whole population, and so do region buckets — one customer list, cut twice; both record sets carry the reconciliation line stating it. Two tests assert the sums independently.
  • Every bucket shows its count, nothing suppressed or merged — every bucket in SPEED_BUCKETS is emitted whether or not anyone is in it, and no "other" bucket exists to merge into.
  • No recorded country → the unknown-region bucket — regionFor(null, …), and an empty-string country is normalised to null at the read so it cannot become a region named "".
  • A recorded country no region groups → the same unknown bucket — regionsByCountry.get() misses and falls through to the same constant.
  • Counted where they were at the time of the engagement — the country comes from 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, not mean, taken before rounding — 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.
  • Delivery time runs sourced → Delivered, and the surface states it — the speed record set's third reconciliation line says so in words.
  • Returned = a new engagement reaching 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.
  • Rolling 12-month figure with its own population and returners, not shown twice — a second report over the trailing twelve months against the twelve before; the page passes null when the selected preset is already last-12-months.
  • Opening any bucket lists its customers, adding up exactly — both breakdowns are matchValue over speedKey/regionKey, which the cohort rows carry as classification rather than as a re-parsed label.
  • Empty previous period → no rate and no change — rate is null, the headline renders , and the caveat says there is no population yet rather than showing a 0%.
  • A vendor with no delivered engagements sees nothing rather than a zero, and is told what it is waiting for — the same null path plus emptyRecordsLabel.
  • Renders only through the claim surface — Claim cannot be constructed without a RecordSet; isRenderableClaim is asserted in the tests.
  • One as-at moment per read against the 15-minute ceiling — the page stamps asAt once and this family adds no second clock of its own.
  • Every route scoped through 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.
  • The M7 definition record states the settled bases — the migration above; the vendor grid and the Admin registry both read that one record.

Notes for Verify

  • The migration has not been run. Corrected at Verify — it had. The agent sandbox cannot reach MongoDB, but the 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.
  • Two gathers per page load. Corrected at Verify — it is three. M5 (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.
  • The rolling figure is a listing, not a filterable breakdown: its population is a different set of customers over a different window, so offering a click that narrowed the headline's cohort would open records that do not belong to it.

04_verify/output/verify.md

Verify: vendor-retention-by-speed-and-region

  • ci: GREEN on 86c9364 — 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.
  • previews smoked: web (https://web-git-claude-vendor-retention-pipeline-47fs22-sustentus.vercel.app) · marketing built but is unrelated to this diff · demo, docs, help-centre and storybook were skipped for this diff and demonstrate nothing
  • production-readiness: run — 1 blocker (merge conflict, resolved), 5 warnings; 2 fixed on branch, 3 recorded below
  • code-review: high (spec complexity: complex) — the CI Claude review is not enabled (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.
  • security-review: run — no HIGH or MEDIUM findings
  • playwright: TODO — manual DoD smoke performed instead

What the branch is now

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.

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

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.

  • Speed buckets sum to the whole population, and so do region buckets — traced (one customer list, cut twice; asserted by two independent unit tests) · operator to demonstrate
  • Every bucket shows its count, nothing suppressed or merged — traced + unit-tested · operator
  • No recorded country → unknown-region bucket — traced + unit-tested · operator
  • A recorded country no region groups → the same bucket — traced + unit-tested · operator
  • Counted where they were at the time of the engagement — traced + unit-tested · operator
  • Median not mean, taken before rounding — traced + unit-tested at 14 vs 14.5 · operator
  • Delivery time runs sourced → Delivered, and the surface states it — traced; the day count was wrong and is fixed this stage (see findings) · operator
  • Returned = a new engagement reaching work_in_progress in the period — traced; the population for this side was wrong and is fixed this stage · operator
  • Rolling 12-month figure with its own population and returners, not shown twice — traced · operator: check both on last-90-days (rolling shown) and last-12-months (not shown)
  • Opening any bucket lists its customers, adding up exactly — traced · operator
  • Empty previous period → no rate and no change — traced + unit-tested · operator
  • A vendor with no delivered engagements sees nothing rather than a zero — traced · operator
  • Renders only through the claim surface — traced (Claim cannot be built without a RecordSet; isRenderableClaim asserted) · operator
  • One as-at moment per read against the 15-minute ceiling — traced · operator
  • Every route scoped through vendorClaimFilter — traced, and re-checked by the security pass · operator: confirm a second vendor sees only their own customers
  • The M7 definition record states the settled bases — demonstrated: the Migrate 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)
  • auth: vendor sign-in + /vendor/metrics reachable — operator
  • payments: not touched — no payment path in the diff (agent)
  • notifications: none expected — the diff adds no notifyX call and no email (agent, traced)

Findings & cleanup

Fixed on branch (commit 5c48454):

  1. Delivery time counted elapsed 24-hour spans, not calendar days. A job sourced at 18:00 and delivered at 09:00 fifteen days later computed as 14 and landed in the "14 days or fewer" bucket — contradicting the basis printed on the surface, in the claim and in the migration. Both ends now reduce to local midnight in the vendor's business timezone, which also puts the day boundary where §3.4 says it belongs. Two new tests.
  2. The returner side read every lead, not every engagement. 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.
  3. One row per accepted proposal, not per lead. 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.
  4. A part-finished period was compared against a complete one in silence (§3.4). On the 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.
  5. Region rows were filtered on 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.
  6. Bucket drill-downs carried the wrong caption. The default "exactly the count shown" is false against a row holding both a Returned and a Customers count. cohortClaim set on both.
  7. The status-history read had no index. Neither existing index carries 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.
  8. A duplicated workflow status literal. 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:

  • The delivery gather now runs three times per render of /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.
  • Two unbounded reads. 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.
  • No test at the DB-service layer. The 27 unit tests cover the arithmetic; the windowing and the "a population lead cannot be the evidence its own customer came back" invariant live in the service and are untested. Main's 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.

Notes for Ship

  • Docs and changelog are owed and absent — the diff touches no 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.
  • Rollback is clean. Everything is additive — new files, barrel entries, one block in page.tsx — and both migrations have working, symmetric downs. A revert commit suffices.

05_ship/output/changelog.md


title: Whether delivering faster brings customers back date: 2026-08-19T20:00:00Z personas: [vendor] slug: vendor-retention-by-speed-and-region pr: https://github.com/sustentus/sustentus/pull/866

Whether delivering faster brings customers back

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

Every bucket carries its count

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.

How delivery speed is measured

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.

How regions are worked out

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 rolling twelve months alongside

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.

Opening any of it

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

Whether delivering faster brings customers back

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

Ship: vendor-retention-by-speed-and-region

  • pr: #866 · merge: authorised — Ready to merge ticked; 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's first pass on this run stopped at the gate with the box unticked and said so; this line was written only after reading the tick.
  • CI: GREEN on 66853a6, 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 docs: 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 docs: 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.
  • 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 #866 and the live changelog entry), never left as placeholders. Body is 58 words against the 60-word cap.
  • close-out: archive this run to 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.

Acceptance check (vs spec)

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 sum to the whole population, and so do region buckets — structural: one customer list, cut twice, so the two cannot disagree; both sums are separately unit-asserted and both record sets carry the reconciliation line stating it
  • Every bucket shows its count, nothing suppressed or merged — every SPEED_BUCKETS entry is emitted whether or not anyone is in it, and there is no "other" bucket to merge into
  • A customer with no recorded country appears in the unknown-region bucket — regionFor(null, …), and an empty-string country is normalised to null at the read so it cannot become a region named ""
  • A customer with a country that no region groups appears in that same bucket — the lookup misses and falls through to the same constant; the bucket is never merged into a named region and never assigned the vendor's own
  • A customer who changes country is counted where they were — the country is the one stamped on the lead, and where several of their engagements were delivered in the period the most recent one places them
  • Median, not mean, taken before rounding — 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 boundary
  • Delivery time runs sourced → Delivered, and the surface states it — the speed record set's reconciliation line says so in words. The day count was wrong when Build handed it over and was fixed at Verify: it counted elapsed 24-hour spans, so a 15-calendar-day job bucketed as 14, contradicting the basis printed beside it. Both ends now reduce to local midnight in the vendor's business timezone; two tests added
  • Returned = a new engagement reaching work_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 engagement
  • Rolling 12-month figure with its own population and returners, not shown twice — a second report over the trailing twelve months against the twelve before; the page passes null when the selected preset is already last-12-months
  • Opening any bucket lists its customers, adding up exactly — both breakdowns are matchValue 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 count
  • Empty previous period → no rate and no change — rate is null, the headline renders , and the caveat says there is no population yet rather than showing a 0%
  • A vendor with no delivered engagements sees nothing rather than a zero — the same null path plus emptyRecordsLabel, which names what it is waiting for
  • Renders only through the claim surface — structural: a Claim cannot be constructed without its RecordSet; isRenderableClaim is asserted in the tests
  • One as-at moment per read against the 15-minute ceiling — the page stamps asAt once and this family adds no second clock of its own
  • Every route scoped through 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. Re-checked by the security pass, which found no HIGH or MEDIUM finding
  • The M7 definition record states the settled bases — migration 1787875400000-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

Still the operator's, carried from Verify unperformed

/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:

  • a vendor signs in and reaches /vendor/metrics
  • the speed buckets and the region buckets each visibly add up to the same population and the same returner count as rendered
  • every bucket shows a count, including the empty ones
  • the unknown-region bucket is present and last, and holds both unclassifiable cases
  • opening a bucket lists exactly its customers, returned and not, adding to the row
  • the rolling twelve months is shown on last-90-days and not shown on last-12-months
  • a second vendor sees only their own customers
  • the in-progress caveat appears on current-quarter and reads as intended — this is the fix for one of the six Verify findings and the most worthwhile thing to look at
  • the delivery-time basis printed on the surface matches the day count now being produced

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

Carried forward

  • The delivery gather now runs three times per render of /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.
  • Two unbounded reads. 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.
  • No test at the DB-service layer. The 27 unit tests cover the arithmetic; the windowing and the "a population lead cannot be the evidence its own customer came back" invariant live in the service and are untested. Main's vendor-health.test.ts shows the pattern — extract the pure window selection and test it.
  • The "started" basis is a stand-in, and the migration says so. The spec's chosen basis was the date the proposal was accepted; no such timestamp exists on the record, and 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.
  • A sibling run is still open in .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.
  • Rollback is clean. Everything is additive — new files, barrel entries, one block in page.tsx — and both migrations have working, symmetric downs. A revert commit suffices.

Context budget: within the Inputs table.