Skip to Content

← All archived runs

Run: metric-dictionary-and-claim-surface

run.md

Run: metric-dictionary-and-claim-surface

  • branch: claude/pipeline-vendor-metrics-amv6c0
  • pr: #782

03_define/output/spec.md

Spec: The metric dictionary, and the claim surface every figure is shown through

  • slug: metric-dictionary-and-claim-surface
  • personas: Vendor, Admin
  • touches: packages/services/src/db/models, packages/services/src/db/migrations, packages/services/src/db/services/vendor, apps/web/app/(app)/vendor, apps/web/app/(app)/admin
  • complexity: complex
  • demo: seed

Scope: pipeline/runs/vendor-metrics/01_scope/output/scope.md (FR/BR/AC numbering carried through). Stub: pipeline/intake/vendor-metrics/metric-dictionary-and-claim-surface.md (1 of 10). Design: pipeline/runs/vendor-metrics/02_design/output/design-notes.md + stub-reconciliation.md (decisions D1–D5, ruled 2026-08-10).

Problem

A vendor's dashboard is the evidence for their subscription — the screen they open before a renewal conversation and show their own board. Nothing in the platform currently holds what any number on it means. Population, period, basis date, measure and as-at live in whoever's head wrote the query, so two figures describing the same thing can disagree and nobody can answer "where does 70% come from?" in a way that survives being checked. That is a trust problem before it is a reporting one: a vendor who catches one wrong number stops believing all of them, including the ones that make the case for renewal — which is exactly what Refine the bridge / Q2-2026 Objective 1 (Establish Product-Market Fit with Vendor Partners) depends on.

Every other stub in this scope needs somewhere to put that answer, and one way to put it on screen. This stub is the foundation the other nine are built against: it lands no metric family of its own.

Proposed change

The dictionary becomes data, and the claim surface becomes the only route a figure takes to a screen.

1. The metric definition record (the dictionary as data)

A platform-wide metricdefinitions collection in @sustentus/services, seeded by a migration with the ten records M1–M10. Not tenant-scoped — definitions are the business's, set centrally and identical for every vendor (scope §5, "vendor-configurable metric definitions" is out). It follows the role-template precedent (packages/services/src/db/models/role-template.ts): a seeded collection with schemaPlugin + softDeletePlugin, no tenantPlugin.

Fields, carried from the approved prototype's MetricDefinition (apps/demo/lib/mock/vendor-metrics.ts):

Field Notes
id M1M10, stable, the deep-link key
name the KPI name a vendor recognises — title-cased (see the convention exception)
summary the tile's one line, in the vendor's words
question the question it answers
whyItMatters the consequence of the number moving
linksTo the other KPIs it moves — see the resolution rule below
group Revenue | Growth | Delivery & Quality | null
order position within the group — the grid and the registry both sort on it
shape cohort | activity | stock (BR-5)
population which things are counted, and which deliberately are not
period the span the claim covers
basisDate which date decides whether a thing falls inside the period
measure the arithmetic, in words a person can repeat
exclusions deliberate exclusions, stated rather than assumed (BR-21)
opensTo what the drill-down shows
settings the Admin-owned thresholds it depends on ({ name, value })

group: null is M1 (Service Leads) — it is Service Activation Rate's denominator, not a headline tile, and shows in the Admin registry under "Not on the grid". Paul's ruling: "Service lead is not really of any interest… first KPI really is Service Activation Rate."

linksTo resolution (AC-5). An entry is either a metric id in the dictionary, or an explicit { label, defined: false } — the prototype's M2 links to "Time to value", which nothing defines. A dictionary that points at undefined vocabulary fails at the job it exists for, so the model permits "not yet defined" but never a dangling string.

The definitions are read-only in this stub. No write path, no change history, no announcement — that is stub 8 (BR-24), which builds on this collection rather than migrating a constant into one.

2. The claim surface (the only route a figure takes to a screen)

Three levels, as approved after the depth-instead-of-density iteration:

  1. The number and its name. A card: icon, KPI name, the one-line summary, the figure. Nine cards three-across, grouped Revenue → Growth → Delivery & Quality. That is the whole first screen.
  2. The metric's picture, its question, and the one or two caveats that change how to read it. One metric open at a time; picking it again closes it.
  3. How it's worked out (population, period, basis date, measure, exclusions, as-at, the Admin-owned settings it depends on) and Show the records — the rows, plus the arithmetic that must hold between them.

The rendering contract is structural, not a matter of discipline: a figure cannot reach the screen through the surface without a RecordSet attached, so BR-1 and BR-2 hold by construction. RecordSet is the prototype's shape and survived contact with all ten families — { columns, rows, reconciliation }, with reconciliation lines modelled as data, never prose baked into a component. That is what turns "here are some records" into "here is why this number is right", and every drill-down in the batch needs it.

Available and visible are different things. The first screen carries the numbers and what they are, and nothing else. Definitions, further caveats and the cross-cutting rules (timezone, freshness, rounding, one-concept-one-value, money never added together) live in a "How it works" popover, one click down. Burying the numbers under their own provenance costs the trust this scope is buying — this is a Design finding promoted to an acceptance criterion.

3. Both definition surfaces (D3: both ship)

Both read the same definition record, so neither can state a different name, summary or group:

  • A — the KPI dictionary page (/vendor/metrics): one address, the whole vocabulary, grouped and ordered off the group/order fields. Opens with nothing selected — the grid and one line inviting a click. Reads ?metric=M8 to open a metric selected.
  • B — the definition where the number is (/vendor/dashboard): a KpiInfo popover on each dashboard section heading and headline tile, giving summary, why it matters, population / period / basis date / measure / exclusions, and one link into the dictionary at that metric.

4. The Admin registry, read-only

/admin/metrics — the grouped registry off the same group/order fields, plus the "Not on the grid" section for M1, plus the governance statement ("a definition change is the only write anyone has over these numbers — no role can enter, adjust or override a reported figure", BR-25). No edit affordance anywhere. Grouping off the shared field is what makes AC-4 testable in this stub rather than asserted.

5. Period resolution and the as-at stamp

A period resolver in @sustentus/services returning { from, to, label, complete, previousLabel } for three presets — last 90 days rolling (default), current quarter, last 12 months — with a part-finished period labelled in progress and never compared against a complete one without saying so. Day boundaries at local midnight in the vendor's stated business timezone, weeks starting Monday; a vendor with no timezone recorded resolves to UTC and the screen says so (BR-17's spirit: an unrecorded value is shown as unrecorded, never silently defaulted).

The selector writes to URL state on both vendor surfaces. Its consumer in this stub is the stated period on every definition — the claim chrome shows the resolved period whether or not a figure sits behind it — so the resolver ships with something real reading it, and each metric stub then consumes the same resolved period rather than re-deriving one.

An as-at moment and a freshness statement are sourced once per read and stamped on the surface, not per figure (BR-20; 15-minute staleness ceiling).

6. Tenant scoping, in the query layer

A vendor's figures contain only that vendor's own opportunities and customers, by every route including drill-downs (BR-23). The scoping lives in the query layer the claim surface owns, not in each metric — so no later metric stub can forget it. The definition records themselves are platform-wide and carry no vendor data.

7. Money and rounding primitives

One whole-unit money formatter and one reporting currency for the metrics surface — the demo needed its own because the shared fmt helper is GBP and abbreviates to thousands, which BR-19 rules out. Rounding is applied only at display: money in whole units, percentages whole, averages to one decimal. Where a set of percentages is presented as a whole it totals 100, with the rounding distributed so it does (BR-18).

Convention exception — KPI names are title-cased

A deliberate, scoped departure from CONVENTIONS.md § sentence case, for KPI names only. Paul asked twice, explicitly. In a dictionary they are proper nouns, and a vendor meets the same string on the tile, in the dashboard popover and in the Admin registry. Every other string on all three surfaces stays sentence case. This is recorded here so a later reviewer does not "fix" it back.

Where it goes

  • packages/services/src/db/models/metric-definition.ts + a seed migration under packages/services/src/db/migrations/.
  • packages/services/src/db/services/vendor/ — the definition reads, the period resolver, the tenant-scoped query layer the surface owns.
  • apps/web/app/(app)/vendor/metrics/ (new) and the KpiInfo popover into apps/web/app/(app)/vendor/dashboard/; apps/web/app/(app)/admin/metrics/ (new).
  • The claim chrome stays local to apps/web for now — the vendor dashboard is its only consumer, and packages/ui is for genuinely generic primitives. Promoting it is a later call with a second consumer behind it, not a speculative one now.
  • apps/demo/components/dashboard/vendor/metrics/** is demo: seed — reference-level material, kept in step, not ported wholesale.

Acceptance criteria

  • A metric definition record exists for each of M1–M10 in the seeded collection, and it is the only place each definition lives — including the metric's name, one-line summary and grid group — so no surface can state a different one.
  • Any figure rendered through the claim surface reaches its population, its period and the moment it was worked out in at most one interaction, without leaving the page (BR-1).
  • The surface refuses to render a figure with no record set attached, and every figure exposes a drill-down listing the records behind it plus the arithmetic that must hold (BR-2). Enforced by the surface's own contract, verifiable in this stub without a metric family wired up.
  • Both definition surfaces read the same record: the dictionary page at /vendor/metrics, and a popover on /vendor/dashboard that opens the definition where the number stands and links into the dictionary at that metric (?metric=M8 opens Active Service Revenue selected).
  • The vendor grid and the read-only Admin registry are both grouped Revenue → Growth → Delivery & Quality and ordered within each group off the group/order fields on the record — they cannot disagree about where a metric belongs. M1 carries group: null and appears in the registry under "Not on the grid", with no vendor tile.
  • Every KPI a definition names in linksTo resolves to another definition in the dictionary, or is explicitly marked not yet defined. No dangling name renders.
  • The first screen carries the numbers and what they are, and nothing else — definitions, caveats beyond the one or two that change how a figure reads, and the cross-cutting rules are a click down.
  • Each definition declares whether it follows a cohort forward, reports activity in the period, or is a stock, and that declaration is visible on the surface (BR-5).
  • Nothing on either surface renders as illustrative, indicative, placeholder or smoothed; where a figure exists, zero renders as zero with its record count (BR-3).
  • Rounding happens only at display — money in whole units and never abbreviated, percentages whole, averages to one decimal; a set of percentages presented as a whole totals 100 (BR-18, BR-19).
  • No figure renders without an as-at moment, and the freshness statement is visible; both are sourced once per read (BR-20).
  • The period selector offers last 90 days rolling (default), current quarter and last 12 months; a part-finished period is labelled in progress; boundaries fall at local midnight in the vendor's business timezone with weeks starting Monday; a vendor with no recorded timezone resolves to UTC and the screen states it.
  • The definition reads and the surface's query layer are tenant-scoped, so a vendor reaches only their own opportunities and customers by every route including drill-downs (BR-23).
  • The Admin registry is read-only — no edit affordance, no change record, no history — and carries the governance statement that no role can enter, adjust or override a reported figure (BR-25).
  • KPI names are title-cased on all three surfaces; every other string is sentence case.

Out of scope

  • Any of the ten metric families themselves — stubs 2–7 and 9. No metric is wired up here; this stub lands the surface, the ten definition records, the period resolver and the scoped query layer.
  • Admin editing of definitions — stub 8 owns the write path, the dated/attributed change record, the retained history and the vendor-facing announcement (BR-24). This stub ships the collection read-only so stub 8 has no migration of its own to do.
  • Retiring or correcting the vendor dashboard that already exists — stub 10. The existing NRR, GRR, Revenue at Risk and ARR-movement figures stay exactly as they are for now; adding the KpiInfo popover to a section does not change the number in it, and the popover deliberately shows the dictionary's figure beside the dashboard's so the reconciliation gap is visible rather than theoretical.
  • Promoting the claim chrome into packages/ui. One consumer, so it stays in apps/web.
  • Net revenue retention, gross revenue retention and the wider revenue-retention family — this scope defines nothing that produces them, and Paul confirmed the sidebar taxonomy behind them is out for now.
  • Restating history on the new definitions, forecasting, exporting, benchmarking and threshold alerting — all excluded scope-wide.

Open questions

  • The period selector has no reference implementation. The approved prototype is hard-pinned to a single complete quarter (PERIOD in apps/demo/lib/mock/vendor-metrics.ts) with no selector, and the scope's default is 90 days rolling, which is not what the demo shows. The criterion stands and is specified above; Build is writing it from the spec rather than porting it. Non-blocking — the behaviour is fully stated, only the reference is missing.
  • whyItMatters and linksTo content for M10 (Revenue at Risk) comes from the prototype, which defined it after the scope was agreed. The record is seeded from the prototype's text; stub 9 owns the metric itself and may refine the wording when it builds the figure. Non-blocking.
  • Ten records are seeded, but only nine ever appear as tiles — M1 is a denominator. That is the ruling, recorded here so a reviewer counting tiles against records does not read it as a defect.

04_build/output/notes.md

Build notes: metric-dictionary-and-claim-surface

  • commits: cf5b84e (the dictionary as data — model, seed migration, services, three surfaces, tests)
  • demo: seed — the prototype's definition record and RecordSet shapes were carried forward and re-implemented against the real stack; no demo code was ported wholesale.

What changed

  • packages/services/src/db/models/metric-definition.ts — the collection. Platform-wide, no tenantPlugin: definitions are the business's, set centrally and identical for every vendor, so there is no tenantId to scope by. Follows the role-template precedent otherwise (schemaPlugin + softDeletePlugin, async factory, bounded arrays).
  • packages/services/src/db/migrations/1784600000000-seed-metric-definitions.ts — M1–M10, seeded create-if-missing so a later Admin edit survives a re-run. down removes exactly those ten ids.
  • packages/services/src/shared/metric-claim.ts — the claim vocabulary (MetricShape, MetricGroup, MetricDefinitionView, RecordSet, Claim), the render contract, link resolution, and the display formatters. In /shared rather than on the model so the client surfaces and the model share one definition of the vocabulary instead of two that can drift.
  • packages/services/src/shared/period.ts — the period resolver.
  • packages/services/src/db/services/metric-dictionary/ — the reads (list, get, grid, ungrouped) and vendorClaimFilter, the single place a vendor's figures get scoped.
  • apps/web/components/vendor/metrics/** — the claim chrome: the grid (levels 1–2), the shared DefinitionDetail (level 3), the KpiInfo popover, the period selector, the house-rules popover.
  • apps/web/app/(app)/vendor/metrics/page.tsx, apps/web/app/(app)/admin/metrics/page.tsx, and the KpiInfo rows on /vendor/dashboard. Route policies + nav for both new pages.
  • tenantSettingService.getTimezoneSource — see the deviation note below.

Acceptance criteria status

  • A metric definition record exists for each of M1–M10 — seeded, and name/summary/group live only there; all three surfaces read them through the same service.
  • [~] Any figure reaches population, period and as-at in at most one interaction — no metric family is wired up in this stub, by design. The definitions carry population/period/basis date and the surface renders them one click from the tile; there is no live figure to click through yet. Fully exercisable when stub 2 lands the first family.
  • The surface refuses to render a figure with no record set — structural (Claim cannot be constructed without a RecordSet) plus isRenderableClaim for values crossing a boundary the compiler doesn't see. Unit-tested.
  • Both definition surfaces read the same record — dictionary page and dashboard popover, one service, ?metric=M8 opens the dictionary at that metric.
  • Grid and registry grouped Revenue → Growth → Delivery & Quality off group/order; M1 carries group: null and shows under "Not on the grid" with no vendor tile.
  • Every linksTo entry resolves or is explicitly not-yet-defined — re-checked against what the dictionary actually holds, so a link to a removed metric demotes rather than dangles. Unit-tested. "Time to value" is the one undefined entry, as expected.
  • The first screen carries the numbers and what they are, and nothing else — tile is name + summary; question, why-it-matters, definition and records are a click down; cross-cutting rules are in the "How it works" popover.
  • Each definition declares its shape, visibly (SHAPE_LABELS badge).
  • Nothing renders as illustrative or placeholder — an unwired metric shows "Not yet reported", not a stand-in number.
  • Rounding at display only — whole-unit money never abbreviated, whole percentages, averages to one decimal, and a breakdown distributed by largest-remainder so it totals 100. Unit-tested.
  • No figure without an as-at moment; freshness statement visible; both sourced once per read.
  • Period selector with the three presets, in-progress labelling, local-midnight boundaries, Monday weeks, UTC fallback stated. Unit-tested including a DST-spanning window.
  • [~] Tenant scoping in the query layer — vendorClaimFilter exists and throws rather than returning an unfiltered query, so a stub that forgets fails loudly. Definitions themselves are platform-wide and carry no vendor data. There are no metric queries to scope yet, so this is the contract in place rather than the contract exercised.
  • Admin registry read-only — no edit affordance, no change record, no history — with the governance statement (BR-25).
  • KPI names title-cased on all three surfaces, from the record; every other string sentence case.

Notes for Verify

Decisions taken while building, that the spec did not settle:

  1. metricId, not id. Mongoose defines an id virtual on every schema and a real path of that name shadows it. The deep link is still ?metric=M8.
  2. The record's period describes the rule, not a quarter. The prototype is pinned to Q2 2026 so its page is reproducible; the product resolves the period per read, so a definition naming a fixed quarter would be wrong the moment the selector moved.
  3. Timezone. The spec's criterion says a vendor with no recorded timezone resolves to UTC. The platform already has a per-tenant timezone setting whose getter silently substitutes Europe/Brussels when unset — so taken literally the criterion would put a second timezone answer in the product, which is the "one concept, two values" failure this scope exists to prevent. Resolved by adding getTimezoneSource, which returns the same value getTimezone does plus whether it was explicitly set; the surface states "no business timezone set — using the platform default" instead of diverging to UTC. The UTC path remains for a genuinely absent or malformed zone. Worth a look at Verify — it is a deliberate reading of the criterion, not a literal implementation of it.
  4. Claim chrome stayed in apps/web. One consumer; promoting it to packages/ui needs a second.
  5. No new display formatters. The first push defined formatMoney/formatPercent/ formatAverage on the claim module — a second copy of helpers utils/numbers.ts and utils/currency.ts already provide, and a formatPercent export collision that broke the services build. Replaced with three constants (MONEY_FRACTION_DIGITS, PERCENT_DECIMALS, AVERAGE_DECIMALS) that parameterise the existing helpers, plus one genuinely new function (distributeRoundedPercentages — nothing existed for BR-18). formatCurrency gained an optional fractionDigits argument so whole-unit money has one home rather than a fourth copy; every existing caller is unaffected.
  6. Two dashboard sections deliberately carry no popover — time to value and the ARR movement strip. Their own concepts have no definition in this dictionary, and pointing them at definitions that are not theirs is exactly the defect the reconciliation stub exists to fix.

Preview data: the pages read from metricdefinitions, which the seed migration creates. db-migrate.yaml's migrate-preview job runs on every PR touching packages/services/** — which this one does — so the preview database gets the ten records before the smoke pass, and the pages should render populated. If they come up empty, check that job rather than assuming a UI defect.

Not exercised anywhere yet: the RecordSet drill-down rendering. This stub defines the payload and the render contract, but with no metric family wired there is no drill-down table on screen — the first real one arrives with stub 2.

Context budget: within the Inputs table. The demo mock (vendor-metrics.ts) was read in full for the definition prose, which the contract names as demo: seed reference material.

05_verify/output/verify.md

Verify: metric-dictionary-and-claim-surface

  • production-readiness: run (diff touches the database) — 2 blockers + 3 warnings; all actionable ones fixed on branch, see Findings.
  • code-review: high (spec complexity: complex) — 6 findings, 6 fixed on branch. The CI Claude review is not enabled on this repo (Review diff against CONVENTIONS.md reported skipped), so /code-review was run rather than triaged.
  • security-review: run (diff touches route policies) — no HIGH or MEDIUM findings. One forward-looking note recorded below.
  • playwright: TODO — manual DoD smoke performed instead.

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

Agent-verified — reachable without credentials:

  • Both new routes exist and are gated by the deny-by-default proxy — /vendor/metrics, /admin/metrics and /vendor/dashboard each return 307 to /sign-in?redirect_url=… unauthenticated (agent, curl against the preview).
  • The unit suite passes — Quality Project green on 2e86d90, covering format, lint, typecheck and pnpm test (agent, CI). This is the first run that reached lint/typecheck/test: earlier runs aborted at format:check.
  • The seed migration applies cleanly to a real database — Migrate preview database green, and Audit database green afterwards with both indexes present (agent, CI).
  • Every linksTo entry in the seed resolves to a definition in the same seed, or is the one deliberate defined: false entry ("Time to value") — traced in the migration and asserted by resolveMetricLinks unit tests (agent, code + tests).
  • Period boundaries, in-progress labelling, UTC fallback, display rounding and the 100%-distribution rule — asserted by unit tests including a DST-spanning window and a leap-day window (agent, period.test.ts / metric-claim.test.ts).

Operator-demonstrated — NOT YET DONE. These are the gate. The agent has no preview credentials, so every signed-in criterion below is unverified. Do not read the ticks above as covering them:

  • Vendor signs in and reaches /vendor/metrics: nine cards in three groups, Revenue → Growth → Delivery & Quality, each showing KPI name + one-line summary and nothing else (operator).
  • Clicking a card opens question / why it matters / definition / records; clicking it again closes it; "How it works" holds the cross-cutting rules (operator).
  • ?metric=M8 opens the page with Active Service Revenue already selected (operator).
  • Related-KPI links navigate within the dictionary; "Time to value" shows as not yet defined and nothing else does. This is the regression fixed in 505c24f — worth looking at specifically (operator).
  • Period selector switches between the three presets, the stated range matches the preset, and the current quarter shows an "in progress" badge (operator).
  • Vendor dashboard section popovers open, show the definitions, and link into the dictionary — except the funnel section's Service Leads, which deliberately has no link (operator).
  • Admin signs in and reaches /admin/metrics: grouped registry, "Not on the grid" holding Service Leads only, governance statement present, and no edit affordance anywhere (operator).
  • auth: covered structurally — both routes carry ROUTE_POLICIES entries and both pages call requireRole server-side; unauthenticated access redirects correctly (agent). Per-persona sign-in still to be demonstrated (operator).
  • payments: not touched by this diff (agent, diff).
  • notifications: none expected — the diff adds no notifyX call and no email template (agent, diff).

Walkthrough clip: not recorded. The agent cannot record one. Posting it to the feature thread in #build is outstanding and is the operator's — it is visibility, not a gate.

Findings & cleanup

Fixed on branch (505c24f) — production readiness:

  • Every resolved related KPI rendered as "not yet defined" on two of the three surfaces. definition-detail.tsx had linkHrefBase in the same condition as link.defined, and the Admin registry and dashboard popover pass no href base — so eight correctly-resolved links claimed the opposite of the truth, on the surface whose entire purpose is not making false claims. Whether a link resolves and whether the surface can navigate are now separate questions.
  • The dashboard funnel popover deep-linked to M1, which carries group: null and so has no tile on the dictionary page — the link landed on an empty selection. Suppressed for ungrouped metrics; the popover already shows the definition in full.
  • The page asserted a 15-minute staleness bound that nothing measures. ReadStamp.stalenessMinutes is declared and never populated. Replaced with what the read can actually evidence. The BR-20 ceiling arrives with the first metric family, alongside something that measures it.
  • Added an empty state to both surfaces. The production seed migration runs behind a required reviewer, so there is a window after merge where the collection is empty; bare group headings read as a broken page rather than an unseeded one.
  • Removed MetricDictionaryService.get() — no consumer.

Fixed on branch (49c4060) — code review:

  • The period selector printed the exclusive end instant as the last day covered, so the current quarter read "1 Jul to 1 Oct" — a day outside it.
  • The rolling comparison window carried today's part-day backwards, starting the previous window mid-morning and splitting a day's records across the boundary. It now steps back whole local days. The existing test asserted equal millisecond length, which is exactly what let this through — rewritten to assert local-midnight starts and whole-day spans.
  • last-12-months rolled 29 February forward to 1 March, dropping a day from a window labelled twelve months. Now clamped to the month's last valid day, with a test.
  • The seed migration created the collection implicitly through the raw driver, giving it the default simple collation instead of the case-insensitive one schemaPlugin puts on every model. Mongoose's autoIndex would then try to rebuild these indexes with a conflicting collation. Created explicitly now, guarded so a re-run is a no-op. Note for the operator: the preview database already holds the uncollated collection from the earlier run — it needs a drop and re-migrate to match production, or preview and production will differ.
  • Folded grid() + ungrouped() into one registry() read (the Admin page was doing two identical collection reads per render), and moved the dictionary read into the dashboard's existing Promise.all instead of a serial await after it.

Accepted, with reasons:

  • The { group: 1, order: 1 } index earns nothing today. db:audit on preview flagged it (0 reads): the service reads find({}) and does both the sort and the group === null filter in JS, because the group order is Revenue → Growth → Delivery & Quality rather than alphabetical, so a database sort cannot produce it. Dropping it means editing a migration that has already run on preview, which packages/services/AGENTS.md forbids. Left in place and recorded here; the honest fix is a follow-up migration once a metric family gives the collection real query volume.
  • usedUtcFallback is unreachable from these pages. getTimezoneSource always returns a valid zone (registry default Europe/Brussels), so the "shown in UTC" badge never fires from this caller. The resolver branch is real, tested, and reachable by any caller passing a raw zone, so it stays. This is deviation #3 in the build notes — the one thing in this run that most warrants a second opinion, because it is a deliberate reading of an acceptance criterion rather than a literal implementation of it.
  • vendorClaimFilter types its arguments unknown and relies on a truthiness guard, so a future caller could pass { $ne: null }. It has zero callers today, so there is no exploit path in this PR — the security review recorded it as a note rather than a finding. Worth tightening to string | ObjectId before the first metric family wires it up.

Deferred to Ship (not Verify's to do):

  • No apps/docs or apps/help change yet. This adds two nav items — "KPI dictionary" (vendor) and "Metric registry" (admin) — so it is user-visible for two personas and needs docs-sync plus a changelog-entry in this PR before it merges.

Context budget: within the Inputs table.

06_ship/output/changelog.md


title: Every KPI on the vendor dashboard now says what it means date: 2026-08-10T11:00:00Z personas: [vendor, admin] slug: metric-dictionary-and-claim-surface pr: https://github.com/sustentus/sustentus/pull/782

Every KPI on the vendor dashboard now says what it means

Until now, what a number on the vendor dashboard actually counted lived in whoever wrote the query. Two figures describing the same thing could disagree, and there was no way to answer "where does that come from?" that survived being checked.

Vendors now have a KPI dictionary. Every KPI is listed with the question it answers, why it matters, what is counted and what is deliberately left out, which date decides whether something falls inside the period, and the arithmetic in words you can repeat back. The dashboard carries the same definitions where the numbers stand: open a section and its definition is one click away, with a link straight to that KPI in the dictionary.

You can look at the figures over the last 90 days, the current quarter, or the last 12 months. A period that has not finished yet is labelled as still in progress, so a part-quarter is never quietly compared against a whole one. Day boundaries follow your own business timezone.

Admins have the matching register of the same definitions. It is deliberately read-only: changing a definition is the only write anyone has over these numbers — no role can enter, adjust or override a reported figure.

This release lands the vocabulary and the surface. The figures on the dashboard are unchanged for now; the KPIs themselves move onto these definitions over the releases that follow.

06_ship/output/investor-update.md

Vendors can now check where any dashboard number comes from

Who it's for: Vendors and admins What shipped: A KPI dictionary defining every dashboard figure — what's counted, over which period, the records behind it — plus a read-only admin register. Why it matters: Refine the Bridge — Q2-2026 Objective 1: Establish Product-Market Fit with Vendor Partners. Renewals rest on trusting these numbers.

Dig deeper: https://github.com/sustentus/sustentus/pull/782 · https://help.sustentus.com/changelog/2026-08-10-metric-dictionary-and-claim-surface

06_ship/output/release.md

Ship: metric-dictionary-and-claim-surface

  • pr: #782 · merged: squash-merged 2026-08-10 on the ticked Ready to merge box and green checks. This record rides in that squash, so it carries no post-merge SHA — the squash commit is on the PR.
  • CI: green on c55972bQuality Project, Migrate preview database, Audit database, Project run labels, Spec structure, Intake structure, Ship completeness and Vercel Preview Comments all ✅; Migrate production database skipped (merge-only — it runs on this merge); Review diff against CONVENTIONS.md skipped (flag off). Three earlier rounds failed at format:check only; the last of them (532101f) was three print-width breaks in period.ts, period.test.ts and the Admin registry page, fixed in c55972b.
  • technical docs: technical/packages/services — new Metric dictionary section (the platform-wide metricdefinitions collection and why it carries no tenantPlugin, the record's claim vocabulary, metricDictionaryService, the /shared claim + period modules, and vendorClaimFilter). technical/applications deliberately unchanged: it does not enumerate routes, and inventing a route list to hold two new paths would be documentation the page does not otherwise carry.
  • business docs: business/rolesKPI dictionary under what a vendor sees, Metric registry under what an admin sees, the read-only governance line in the admin's unique capabilities, and a Metric definitions row in the role-based data visibility table (read-only for vendor and admin, no access for CSM/SDM, matching the two route policies).
  • release notes: both
  • sent: ship note queued for #product-update — .github/workflows/ship-note.yaml fires on this merge

Acceptance check (vs spec)

  • A definition record exists for each of M1–M10 in the seeded collection and is the only place each definition lives — seed migration 1784600000000, applied green against the preview database and re-audited afterwards (Verify, agent)
  • Any figure reaches its population, period and as-at in at most one interaction (BR-1) — the surface enforces it, but there is no live figure to click through until stub 2. Ticked as contract-in-place on the PR, recorded here as not exercised
  • The surface refuses to render a figure with no record set attached, and every figure exposes a drill-down plus the arithmetic that must hold (BR-2) — structural: a Claim cannot be constructed without its RecordSet
  • Both definition surfaces read the same record; ?metric=M8 opens Active Service Revenue selected — one metricDictionaryService read behind the dictionary page, the dashboard popovers and the registry
  • Vendor grid and Admin registry both group Revenue → Growth → Delivery & Quality off the group/order fields; M1 carries group: null and shows under "Not on the grid"
  • Every linksTo entry resolves to another definition or is explicitly not yet defined — asserted by resolveMetricLinks unit tests. The one defined: false entry is "Time to value"
  • The first screen carries the numbers and what they are, and nothing else — definitions and the cross-cutting rules sit one click down in "How it works"
  • Each definition declares cohort / activity / stock and shows it (BR-5)
  • Nothing renders as illustrative, indicative, placeholder or smoothed (BR-3)
  • Rounding only at display; a set of percentages presented as a whole totals 100 (BR-18, BR-19) — distributeRoundedPercentages, unit-tested
  • No figure renders without an as-at moment sourced once per read (BR-20) — the 15-minute staleness ceiling was removed in Verify rather than asserted, because nothing measures it yet
  • Period selector offers the three presets, labels a part-finished period in progress, and puts boundaries at local midnight in the vendor's business timezone — unit-tested including a DST-spanning window and a leap-day window
  • The reads and query layer are tenant-scoped by every route including drill-downs (BR-23)vendorClaimFilter throws rather than returning an unfiltered query, but it has no callers until a metric family lands. Contract in place, not exercised
  • The Admin registry is read-only and carries the governance statement (BR-25)
  • KPI names title-cased on all three surfaces, every other string sentence case — the scoped convention exception, recorded in the spec so a later reviewer does not "fix" it back

Still the operator's, carried from Verify

The agent has no preview credentials, so every signed-in criterion is undemonstrated. These are not defects — they are unchecked checks:

  • The seven signed-in DoD items in 05_verify/output/verify.md (vendor grid, open/close, deep link, related-KPI links, period selector, dashboard popovers, admin registry). The related-KPI links are worth looking at specifically — that was the regression fixed in 505c24f.
  • The walkthrough clip for the #build thread. Visibility, not a gate.
  • The preview database still holds the uncollated metricdefinitions collection from the first migration run, created before the collation fix in 49c4060. Production gets the corrected collection from this merge; preview needs a drop and re-migrate or the two environments differ.

Carried forward

  • The { group: 1, order: 1 } index earns nothing today (db:audit: 0 reads). The service reads find({}) and sorts in JS, because Revenue → Growth → Delivery & Quality is not alphabetical. Dropping it means editing a migration that has already run, which packages/services/AGENTS.md forbids — so it stays until a metric family gives the collection real query volume and a follow-up migration can do it properly.
  • vendorClaimFilter types its arguments unknown and guards on truthiness. Zero callers today, so the security review recorded it as a note rather than a finding. Worth tightening to string | ObjectId before the first metric family wires it up.
  • usedUtcFallback is unreachable from these pagesgetTimezoneSource always returns a valid zone. The resolver branch is real, tested, and reachable by any caller passing a raw zone. This is deviation #3 in the build notes and the one call in this run that most warrants a second opinion.

Context budget: within the Inputs table.