vendor-satisfaction-and-revenue-weightingrun.md02_define/output/spec.mdScope: .icm/runs/vendor-metrics/01_scope/output/scope.md (M4, M9, BR/AC numbering carried
through; §6 answers Q1–Q14 are the settled rulings).
Stub: .icm/intake/vendor-metrics/vendor-satisfaction-and-revenue-weighting.md (5 of 10).
Depends on: metric-dictionary-and-claim-surface (stub 1) — merged as #782, archived under
apps/docs/archive/pipeline-runs/.
A vendor's dashboard is the evidence for their subscription, and satisfaction is the number they quote back at a renewal conversation. A plain average treats a €900 engagement and a €90,000 engagement identically, so it can read healthy while the customers who actually pay are the unhappy ones — which is precisely the misread that costs a renewal. Weighting by revenue answers the commercial question instead, but a weighted average with no coverage figure is worse than none: 4.1 covering 76% of spend is a useful number, and the same 4.1 covering 20% is noise a vendor cannot tell apart from it.
Today the platform has neither. /vendor/csat and /vendor/customers already show an "average
CSAT", a "revenue-weighted CSAT" and a top-customers table, all computed in
vendorService.getCustomerHealth / getTopCustomers against no written definition: no
three-response floor, no coverage share, no vendor scoping, and CSAT counted by the date the survey
record was created rather than the date the customer answered — so a late response silently
restates a closed period (BR-13). Stub 1 landed the dictionary and the claim surface but wired no
figure to it. This stub is the first to put a real figure on that surface, and it is what
Refine the bridge / Q2-2026 Objective 1 (Establish Product-Market Fit with Vendor Partners)
depends on: a satisfaction number a vendor can defend, and a commercial one they can act on.
M4 and M9 become real Claims on the claim surface at /vendor/metrics — the first two figures to
render through it — each opening to the records that produce it.
M4's basis date is the date the response was recorded, never the delivery date (scope §6 Q8,
BR-13). The csat model has no such field: createdAt is when the survey record was raised, and
updatedAt moves on any later write, so counting on either would let a completed period change.
Add completedAt: Date | null to packages/services/src/db/models/csat.ts, set once when a
response is completed and never rewritten, with a backfill migration setting it to updatedAt for
existing isCompleted: true records. Index { tenantId, vendor, completedAt } to match the read.
Every satisfaction read in this stub counts on completedAt; a response with isCompleted: true
and no completedAt after the backfill is a data defect, not a zero, and is reported as an
unclassified record rather than silently dropped (BR-17).
A recorded response is a csat document with isCompleted: true, a completedAt inside the
period, and an overallScore of 1–10. overallScore is the satisfaction score — the seven other
score fields on the model are not part of M4 and are not read. Scores are stored 1–10 and displayed
on the 5-point scale via the existing CSAT_SCALE_FACTOR; the division happens at display, never
before aggregation (BR-19).
The mean of recorded scores in the period, to one decimal (AVERAGE_DECIMALS), shown only once at
least three responses exist — the Customer Satisfaction scope's floor, which this scope consumes
rather than sets (BR-14, scope §3.6). This stub adds nothing to the definition.
Below three responses the claim still renders, because the surface has no route to a figure without a record set — but it renders as a statement of what it is waiting for, not as a zero and not as a placeholder (BR-3): headline "Not published — 2 of 3 responses", with the record set listing the responses recorded so far and the surveys issued and still unanswered, so a vendor can see exactly which records would unlock it.
Beside the average, always: the response count and the response rate.
The two carry different bases, and the screen says so. The average reports activity in the
period (responses recorded, by completedAt). The response rate follows a cohort of surveys
issued in the period (createdAt) to whether they have since been answered — which is the
worked example's reading ("fifteen surveys were issued for the 15 delivered engagements; 11 were
answered"). Mixing bases silently is what BR-5 forbids; stating them is not. Surveys issued close
to the period end may still be open, and the count of those is shown beside the rate, exactly as M2
shows the count still inside its activation window.
Collected is invoices with paid: true and paidAt inside the period, summed on total,
grouped by customer — the vendor's own, via vendorClaimFilter. Highest first, top 10
(the Admin-owned setting on the M9 record), ties broken by the customer's most recent collection in
the period.
Where the top 10 does not cover every paying customer, the table states the share of collected spend it covers, and the reconciliation names how many paying customers there are in total.
A customer whose record is soft-deleted renders as "Customer removed" with their spend and score intact — the money was real and the work happened, so the contribution stays in every aggregate while the name leaves the drill-down (scope §3.9).
Each responding customer's satisfaction (the mean of that customer's responses recorded in the period — a customer with two engagements has two responses) multiplied by their collected spend, summed, divided by the summed spend of those same customers.
Customers who never responded are excluded from the weighted figure — not treated as neutral, not treated as average — and their spend still appears in the table (scope §3.9).
The weighted figure is never shown without the share of collected spend it covers (BR-16): responding customers' collected spend ÷ all collected spend in the period. Coverage is not a caveat that can be dropped in a redesign; a claim that carries no coverage figure does not render.
The plain average and the weighted figure are shown together, with the difference between them stated in words — that gap is the whole point of the metric, and hiding it wastes the number.
M4 → the individual responses. One row per response: engagement reference, named customer,
delivered date, recorded date, individual score, and the customer's improvement suggestion where
they left one. This ratifies the approved prototype's drill-down and extends it by the comment, per
Jamie's interrogation ruling of 2026-08-17: the BR-14 conflict between the scope body ("aggregates
only", M4/M9/§3.7) and Paul's §6 Q6 answer ("YES — drilldown and sort") resolves in favour of
Paul's answer. It is the later thought, the prototype already followed it, and /vendor/customers
already shows a per-customer CSAT today. expertAppreciation is not shown — it is feedback about
the expert, not about what the vendor sourced.
Reconciliation lines: the scores summed, the sum divided by the count, and the surveys-issued / answered / still-open split behind the response rate.
M9 → the top customers, and the collections behind them. One row per customer: name, collected spend, that customer's satisfaction (or "no response"), and their share of collected spend; each customer's individual collections are reachable beneath it, so the total is reproducible by hand.
Reconciliation lines: the customers' spend summing to collected-in-period; the top 10's share of that; the responding customers' spend over all collected spend equalling the stated coverage; and the explicit statement that non-responders are excluded from the weighted figure and present in the table.
Stub 1 landed the claim contract (Claim, RecordSet, isRenderableClaim,
distributeRoundedPercentages) and the dictionary grid, but no component renders a figure yet:
MetricGrid shows a definition's name and summary only. This stub adds the claim rendering to the
existing components in apps/web/components/vendor/metrics/ — the figure on the card, the one or
two caveats at level 2, and the "Show the records" table with its reconciliation lines at level 3 —
built for all ten families, not for these two, since stubs 2, 3 and 4 are running in parallel
and the first of them to land owns this. A metric with no claim keeps rendering exactly as it does
today.
Nothing renders without passing isRenderableClaim. The as-at moment and period already come from
the page's single per-read stamp (BR-20); this stub does not source a second one.
Every read goes through vendorClaimFilter({ tenantId, vendorId }) — stub 1's single scoping point,
which throws rather than returning an unfiltered query (BR-23). vendorId is the signed-in vendor's
application user id, resolved with the established pattern (auth() →
userService.findByClerkUserId(clerkUserId, tenantId)), matching how invoiceService already
filters invoices for a vendor. Both invoice.vendor and csat.vendor carry that attribution.
collectedByCustomer — the vendor-scoped, period-scoped collections read — lands in
packages/services/src/db/services/vendor/ as a named export stub 4 consumes rather than
re-derives. Stubs 4 and 5 are parallel and both need "Collected"; two independent
implementations would be BR-4 breached on arrival. Whichever lands second uses the one that landed
first.
The scope's worked example states a revenue-weighted satisfaction of 4.1 covering 76%. That figure cannot be produced from its own stated inputs — with the largest customer (€68,400 collected) scoring 2 out of a €205,400 responding pool, the arithmetic maximum is 4.0 — and the prototype's dataset reconciles to 3.7. The definition is sound; only that illustrative number is wrong. Nothing in this build, its tests or its fixtures asserts 4.1.
completedAt falls in, never the period the
engagement was delivered in; a response completed after a period closes does not change that
period's published average (BR-13).vendorClaimFilter, so one vendor reaches no other vendor's
customers, responses or collections by any route including the drill-downs (BR-23)./vendor/csat and /vendor/customers onto these definitions. Both keep their
current vendorService.getCustomerHealth / getTopCustomers figures, which are computed
differently and will disagree with the defined ones. That is knowingly two values for one concept
for the duration of this stub, and it is stub 10's (vendor-dashboard-reconciliation) job to
close it — it depends on this stub for exactly that reason, and stub 1 set the same precedent by
shipping a popover that shows both figures so the gap is visible rather than theoretical.vendor-money-states-and-active-service-revenue).completedAt backfilled from updatedAt is approximate for historical responses — any write
to a completed CSAT since it was answered will have moved updatedAt. It is the best evidence the
records hold, it is right going forward, and the scope does not restate history on new definitions
(§5). Non-blocking; worth one line in the Build notes so a later reader does not read early
history as exact.03_build/output/notes.md8fe31bd merge origin/main (stub 2's claim renderer), d92e9de M4 and M9 on the
claim surfacepackages/services/src/db/models/csat.ts — adds completedAt, the date a response was
recorded. Deliberately no default: null: the completion write sets it with $min, which sets
a missing field but keeps an existing smaller value, so the first answer stands and no later
write can restate a closed period. A stored null sorts below every date and would defeat that.
Adds the { tenantId, vendor, completedAt } index the new reads seek on.packages/services/src/db/migrations/1787875200003-add-csats-completed-at.ts — backfills
completedAt from updatedAt for already-answered responses and creates the index. Symmetric
down.packages/services/src/db/services/csat/index.ts — the completion write sets completedAt via
$min, kept out of the $set patch so $-prefixed free text cannot be read as a field path.packages/services/src/db/services/demo-data/index.ts — seeded responses carry completedAt.
Without it the demo tenant's satisfaction figures would read as no responses at all.packages/services/src/shared/vendor-satisfaction.ts (new) — the cohort, the three-response
floor, the weighting and its mandatory coverage, and every record set. Client-safe.packages/services/src/shared/vendor-satisfaction.test.ts (new) — 26 unit tests written from
the acceptance criteria, not from the implementation.packages/services/src/db/services/metric-dictionary/vendor-satisfaction.ts (new) — the
vendor-scoped read: responses by completedAt, issued surveys by createdAt, collections by
paidAt, all through vendorClaimFilter.packages/services/src/db/services/metric-dictionary/customer-name.ts (new) — one home for how
a customer is named in a drill-down, now that two metric families render names. vendor-funnel.ts
had a module-local copy; it now imports this one and passes no fallback, so its behaviour is
unchanged.apps/web/components/vendor/metrics/claim-figure.tsx — mode/filterKey become optional so a
plain-listing breakdown renders without a click affordance that opens nothing, plus a
cohortClaim for a breakdown row that shows a total rather than a count. M9's per-customer
drill-down uses matchValue, the mode stub 3 landed for exactly this (#857) — this branch
originally added an identical byKey and dropped it in the merge rather than fork the
vocabulary.apps/web/lib/vendor-satisfaction-figures.ts (new) + apps/web/app/(app)/vendor/metrics/page.tsx
— M4 and M9 wired onto the grid. The two families' reads are independent, so one failing leaves
the other's figures on screen.CSAT_SCALE_FACTOR moved from db/services/shared/metrics.ts to the shared module — the
satisfaction logic must be importable from the client, and a second copy of the constant is
exactly the drift this codebase forbids. Its one other consumer (db/services/vendor/index.ts)
now imports it from there.satisfactionClaim; the mean is taken on the stored 1–10 scale and divided at
display only.Not published — n of 3 responses, never a zero, and the record set lists the
responses so far with the still-open surveys beside them.completedAt falls in — the read windows on
completedAt, which the model now carries and the completion write can only set once.revenueWeightedClaim, not left to a surface to remember.weightingReconciliation.Claim with
a RecordSet and reconciliation lines.vendorClaimFilter.Customer removed.admin-metric-definition-governance) was retired in #860, so it now has no owner. Flagging
rather than fixing.completedAt comes from updatedAt
for anything answered before this migration, so any later write to a completed CSAT has moved it.
It is exact from here forward, and the scope does not restate history on new definitions.completedAt from
the backfill (their updatedAt), which will cluster them at seed time rather than across the
storyline's dates — so on a preview the response dates may not spread the way the storyline
intends until the demo data is regenerated./vendor/csat and /vendor/customers still show
the old getCustomerHealth / getTopCustomers figures, which are computed differently (no
vendor scoping, no floor, no coverage, CSAT counted by createdAt). That is stub 10's to close,
as the spec's Out of scope records.Quality workflow's Run tests step is the first execution of the new unit tests — they
were written but not run locally, per the offload.04_verify/output/verify.mdcomplexity: complex); the CI Claude review is disabled
(ENABLE_CLAUDE_REVIEW unset — its check run skipped), so it was run here. 7 findings, all
verified real, all fixed on branch.Preview: https://web-git-claude-vendor-satisfaction-revenue-wei-14f83e-sustentus.vercel.app
Agent-run — everything reachable without signing in, plus the code path behind each criterion traced in the diff:
web READY on the head commit (agent)Migrate preview database green: both migrations run cleanly against a real database, which
is the only evidence that matters for the backfill and the index (agent)Quality green (agent)completedAt; the field can only be set once ($min) — traced (agent)revenueWeightedClaim, not left to a surface — unit-asserted (agent)vendorClaimFilter, which throws rather than returning an unscoped
query; vendorId comes from the Clerk session, never from a query parameter — traced (agent)/vendor/metrics carries its route-policy entry (roles: ["vendor"]) and the page also calls
requireRole("vendor") — traced (agent)Operator-demonstrated (outstanding — these need a signed-in vendor on the preview):
/vendor/metrics (operator)The signed-in half is unverified. The agent has no preview credentials, and the Definition of Done says every criterion is demonstrated, not self-certified — so those lines stay unticked rather than being claimed.
Fixed on branch
c3d40a9) — found by tracing the readiness
pass's scoping claim back to the data rather than taking it. vendorClaimFilter scopes on
vendor, and the pending survey the lead flow raises never set it, so every satisfaction
figure would have read empty on production data while passing every test. csatService.create
now defaults the attribution from the lead; migration 1787875300000 backfills existing rows.c3d40a9) — M4 withheld
its average while M9, on the same screen, published one computed from the same responses and
printed the withheld number in its caveat. The floor governs satisfaction and weighting by spend
does not exempt it (BR-14). M9 now withholds, and the weighting arithmetic withholds with it.7733916) — the same defect on the
other tile, found by the code review. The rows stay; the arithmetic goes.csatService.create never stamped completedAt (7733916) — a response submitted in one
shot (created already complete, with a score) never passed through the update path that stamps
it, so it would have been invisible to every figure. Only the update path was covered.6f7db15) — the security pass's one
real finding. A customer who exercises a data-deletion request lost their name in the drill-down
but kept their words under a "Customer removed" label, which anonymises nothing: it still shows a
named complaint to the vendor complained about. The score stays in the aggregate; the comment
goes.c3d40a9), which the average
excludes — the issued/answered/open split now ties out against the rows.7733916), once as its records and once as its
breakdown. Its records are now the collections.down would have destroyed data (7733916) — it unset vendor wherever it
equalled the lead's, which is now true of every response create writes, so a rollback would
have stripped attribution the migration never wrote. It is a documented no-op instead.Escalated — needs an owner decision before this figure is trusted
overallScore is written on two different scales. /csat (the public form) validates and
stores 1–5; /customer/satisfaction/[leadId] stores 1–10. Both write the same field, and every
satisfaction figure — this one and the pre-existing getCustomerHealth — divides by 2. A genuine
5/5 from the public form therefore publishes as 2.5, and the drill-down reconciles perfectly
to the wrong number.
This is pre-existing and out of this spec's scope, and bounding the read does not fix it: a
1–5 score is a valid 1–10 value, so the two populations are indistinguishable by value. The real
fix is to normalise one write path and migrate whatever 1–5 rows exist, which needs a look at the
live distribution first. Recommend a bug-lane run before this figure is quoted to a vendor.
This PR does not make it worse — it makes it visible, which is arguably the scope working as
intended.Sent to intake rather than grown into this release
{tenantId, vendor, paid, paidAt}) is served by the existing
{tenantId, paid, paidAt} index with vendor as a residual filter. Fine at current volume;
{tenantId, vendor, paid, paidAt: -1} is the ESR-correct addition if M9 gets heavy use.Accepted, with the reason
apps/docs or apps/help file yet.Context budget: the readiness and security passes read beyond the Inputs table by design (both
sweep the repo for their own failure surfaces), and confirming the two CSAT write paths and the
lead-flow attribution meant reading four files outside touches:. Both overruns bought findings.
05_ship/output/changelog.mdTwo figures are now live on your dashboard and in the KPI dictionary. They are the same responses counted two ways, and the interesting part is where they disagree.
The mean of the scores your customers recorded in the period, to one decimal, with the number of responses behind it stated beside it. Open it and every response is there — the engagement, the customer, the date the work was delivered, the date they answered, their score, and the improvement they suggested where they left one. The rows add up to exactly the average shown.
A response counts in the period the customer answered in, not the period the work was delivered in. An answer that arrives after a month closes lands in the month it arrived; the closed month is not restated.
Beside the average sits the response rate, with its own basis stated rather than blended in: how many surveys went out in the period, how many came back, and how many are still open. A survey that is still waiting for an answer is shown as open, never counted as a refusal.
The same scores, weighted by what each customer has actually paid you. A plain average treats a small engagement and a large one identically, so it can read healthy while the customers carrying your revenue are the unhappy ones. The weighted figure answers the commercial question instead.
It never appears without the share of collected spend it rests on. A weighted average covering a quarter of what you billed is a different claim from one covering nearly all of it, and the figure says which it is every time.
Both figures are shown together with the difference between them stated, so you do not have to work out which way the gap runs.
Open it and you get your top customers by collected spend, each with their satisfaction score or "no response", and their share of spend. A customer who pays and never answers is in that table: they count in the coverage denominator and contribute nothing to the score, rather than quietly being treated as neutral or as average. Where the top ten does not cover everyone who paid, the share it does cover is stated along with how many paying customers there are in total. Each customer's row opens to their own collections.
Below three recorded responses neither figure is published. Instead of a number nobody should quote, the surface says how many responses it has, how many it needs, and lists the responses so far with the surveys still unanswered beside them. That holds for the weighted figure too — weighting a thin average by revenue does not make it sturdier.
Responses recorded before this release carry their answer date approximately: it is taken from the last time the response was written to, which for most is the day it was answered. Dates are exact from here forward.
05_ship/output/investor-update.mdWho it's for: Vendors, CSMs What shipped: Satisfaction average and revenue-weighted satisfaction, each opening to its records. Why it matters: Refine the Bridge — Q2-2026 Objective 1: Establish Product-Market Fit with Vendor Partners. Renewals turn on what paying customers say.
Neither publishes below three responses; the weighted figure states its coverage.
Dig deeper: https://github.com/sustentus/sustentus/pull/858 · https://help.sustentus.com/changelog/2026-08-19-vendor-satisfaction-and-revenue-weighting
05_ship/output/release.md3c97b7f, settled via ci-status.sh after the merge of origin/main — 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, help-centre, marketing. Vercel demo and
storybook were skipped by their ignore steps for this diff — recorded as skipped, not quoted as
passes. This record's own commit re-runs the same set, and the merge is taken only on that round
settling green.technical/packages/services — the Metric families bullet gains the third
family: shared/vendor-satisfaction.ts (the response cohort, the three-response floor, and the
revenue weighting with its coverage share built into the claim) and vendorSatisfactionService,
plus the completedAt basis date csats now carries and why the completion write sets it with
$min.business/roles — a Satisfaction average and revenue-weighted satisfaction
line under what a vendor sees, stating the coverage share, the three-response floor, and what
each figure opens to. feature-role-matrix deliberately unchanged: this stub adds no permission
or role capability — /vendor/metrics stays roles: ["vendor"] and the metric definitions row
stub 1 recorded still covers the read. platform-overview unchanged: two figures on an existing
dashboard are not a headline platform capability..github/workflows/ship-note.yaml fires on this
merge. Both Dig deeper links were filled before the merge, not left as placeholders.apps/docs/archive/pipeline-runs/. The vendor-metrics epic is
not finished — this is stub 5 of 10 — so .icm/intake/vendor-metrics/ stays where it is with
its remaining stubs.completedAt falls in, and a closed period is not
restated (BR-13) — the field is set once, with $min, so no later write can move itrevenueWeightedClaim, not left to a surfaceClaim with
a RecordSet and reconciliation lines; a Claim cannot be constructed without onevendorClaimFilter (BR-23), including the drill-downs — it
throws rather than returning an unscoped query, and vendorId comes from the sessionThe agent has no preview credentials, so the signed-in half of the DoD smoke was never
exercised — 04_verify/output/verify.md leaves all five lines unticked and says so. Shipping went
ahead on Jamie's instruction; these are unchecked checks, not passed ones:
/vendor/metricsEverything reachable without signing in was verified: the unit suite CI runs, both migrations run
against a real database by Migrate preview database, the scoping traced through
vendorClaimFilter, the route policy, and the preview build on every head. No walkthrough clip was
recorded — the agent cannot record one, and the DoD calls the clip visibility rather than a gate.
overallScore is written on two different scales — the one thing to settle before this figure
is quoted to a vendor. /csat (the public form) validates and stores 1–5;
/customer/satisfaction/[leadId] stores 1–10. Both write the same field and every satisfaction
figure — this one and the pre-existing getCustomerHealth — halves it, so a genuine 5/5 from the
public form publishes as 2.5, and the drill-down reconciles perfectly to the wrong number.
Pre-existing and outside this spec; bounding the read does not fix it, because a 1–5 score is a
valid 1–10 value and the two populations are indistinguishable by value. The fix is to normalise
one write path and migrate whatever 1–5 rows exist, which needs the live distribution first.
Recommend a bug-lane run. This PR does not make it worse — it makes it visible./vendor/csat and /vendor/customers still
show the old getCustomerHealth / getTopCustomers figures, computed differently — no vendor
scoping, no floor, no coverage, CSAT counted by createdAt. Stub 10
(vendor-dashboard-reconciliation) closes that, as the spec's Out of scope records.completedAt comes from updatedAt
for anything answered before this migration, so a later write to a completed response has moved
it. Exact from here forward, and the changelog says so in the reader's words.updatedAt rather than across the storyline's dates.{tenantId, vendor, paid, paidAt: -1}
index on invoices if M9 gets heavy use (the existing index serves the read with vendor as a
residual filter), and a row cap with an "…and N more" tail on record sets — the funnel surface
has the same uncapped shape, so this is consistent rather than a regression.Context budget: within the Inputs table.