metric-dictionary-and-claim-surfacerun.md03_define/output/spec.mdScope: 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).
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.
The dictionary becomes data, and the claim surface becomes the only route a figure takes to a screen.
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 |
M1–M10, 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.
Three levels, as approved after the depth-instead-of-density iteration:
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.
Both read the same definition record, so neither can state a different name, summary or group:
/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./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./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.
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).
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.
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).
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.
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).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./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).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.linksTo resolves to another definition in the dictionary, or
is explicitly marked not yet defined. No dangling name renders.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.packages/ui. One consumer, so it stays in apps/web.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.04_build/output/notes.mdcf5b84e (the dictionary as data — model, seed migration, services, three surfaces, tests)RecordSet shapes were carried forward and
re-implemented against the real stack; no demo code was ported wholesale.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.Claim cannot be
constructed without a RecordSet) plus isRenderableClaim for values crossing a boundary the
compiler doesn't see. Unit-tested.?metric=M8 opens the dictionary at that metric.group/order; M1 carries
group: null and shows under "Not on the grid" with no vendor tile.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.SHAPE_LABELS badge).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.Decisions taken while building, that the spec did not settle:
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.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.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.apps/web. One consumer; promoting it to packages/ui needs a second.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.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.mdcomplexity: 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.Agent-verified — reachable without credentials:
/vendor/metrics,
/admin/metrics and /vendor/dashboard each return 307 to
/sign-in?redirect_url=… unauthenticated (agent, curl against the preview).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.Migrate preview database green,
and Audit database green afterwards with both indexes present (agent, CI).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.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/metrics: nine cards in three groups, Revenue → Growth →
Delivery & Quality, each showing KPI name + one-line summary and nothing else (operator).?metric=M8 opens the page with Active Service Revenue already selected (operator).505c24f — worth looking at
specifically (operator)./admin/metrics: grouped registry, "Not on the grid" holding
Service Leads only, governance statement present, and no edit affordance anywhere
(operator).ROUTE_POLICIES entries and both pages call
requireRole server-side; unauthenticated access redirects correctly (agent). Per-persona
sign-in still to be demonstrated (operator).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.
Fixed on branch (505c24f) — production readiness:
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.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.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.MetricDictionaryService.get() — no consumer.Fixed on branch (49c4060) — code review:
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.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.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:
{ 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):
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.mdUntil 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.mdWho 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.mdc55972b — Quality 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/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/roles — KPI 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)..github/workflows/ship-note.yaml fires on this merge1784600000000, applied green against the preview
database and re-audited afterwards (Verify, agent)Claim cannot be
constructed without its RecordSet?metric=M8 opens Active Service Revenue
selected — one metricDictionaryService read behind the dictionary page, the dashboard
popovers and the registrygroup/order fields; M1 carries group: null and shows under "Not on the grid"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"distributeRoundedPercentages, unit-testedvendorClaimFilter throws rather than returning an unfiltered query, but it has no callers
until a metric family lands. Contract in place, not exercisedThe agent has no preview credentials, so every signed-in criterion is undemonstrated. These are not defects — they are unchecked checks:
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.#build thread. Visibility, not a gate.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.{ 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 pages — getTimezoneSource 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.