Skip to Content

← All archived runs

Run: vendor-money-states-and-active-service-revenue

run.md

Run: vendor-money-states-and-active-service-revenue

  • branch: claude/vendor-money-states-revenue-bv3064
  • pr: #859

02_define/output/spec.md

Spec: Money in four named states, and active service revenue as a stock

  • slug: vendor-money-states-and-active-service-revenue
  • personas: Vendor, CSM
  • touches: packages/services/src/shared, packages/services/src/db/services/vendor, packages/services/src/db/migrations, apps/web/app/(app)/vendor, apps/web/components/vendor/metrics, apps/web/components/dashboard/vendor
  • complexity: complex

Scope: .icm/runs/vendor-metrics/01_scope/output/scope.md (BR/Q numbering carried through). Stub: .icm/intake/vendor-metrics/vendor-money-states-and-active-service-revenue.md (4 of 9). Depends on: metric-dictionary-and-claim-surface (stub 1) — shipped 2026-08-10, archived at apps/docs/archive/pipeline-runs/metric-dictionary-and-claim-surface/.

Problem

Most reporting disputes are two people using one word for three different amounts. The vendor dashboard shows unlabelled "revenue" figures that mix committed, billed and collected value, and its headline strip carries an MRR (proxy) tile whose own code says it stands in "until the vendor-metrics money-states stub defines the real revenue reads" (packages/services/src/db/services/vendor/headline.ts). Worse, it shows a snapshot of work in flight next to period totals as though the two were comparable — the scope calls that the single most likely way this dashboard misleads someone.

A vendor's dashboard is the evidence for their subscription, and Refine the bridge / Q2-2026 Objective 1 (Establish Product-Market Fit with Vendor Partners) depends on that evidence surviving being checked. A vendor who catches one wrong number stops believing all of them. Stub 1 made every figure carry its population, period, basis date, measure and as-at moment; it deliberately landed no metric family. This stub lands the first one — and with it the money vocabulary the whole dashboard then has to speak.

Proposed change

M8 (Active Service Revenue), plus the four-state money vocabulary the rest of the batch sits inside.

1. Four money states, named, in the type system

The scope's §3.5 names three states. There are four: Revenue Delivered (M3, stub 3) is a money value over the same work, so Delivered, Committed, Billed and Collected are four different amounts and the never-added-together rule covers all four. The scope's three-state wording predates that decision; this spec states four.

State What it is Basis date
Delivered Contracted value of engagements that reached Delivered The date it reached Delivered
Committed Accepted contracted value not yet billed The date the proposal was accepted
Billed Value of bills issued to the customer The date the bill was issued
Collected Value the customer has actually paid The date payment was received

A MoneyState type joins the claim vocabulary in packages/services/src/shared/metric-claim.ts (client-safe, the same home as Claim and RecordSet), and a money figure on its way to the screen carries one. The rule is structural, not a matter of discipline: the money variant of a claim cannot be constructed without a state, exactly as a claim cannot be constructed without a RecordSet, so BR-10 holds by construction rather than by review. Two figures in different states have no route to being summed or differenced by the surface — the helper that totals a set of money figures takes figures of one state, and a mixed set is a type error, not a runtime warning.

Delivered's figure is stub 3's. This stub defines the state, its label and its slot in the vocabulary; the arithmetic behind Revenue Delivered lands with M3 and reads the same vocabulary.

There is no service fee. Paul answered Q-3 "No Service FEE", and the fee is already gone from the data (packages/services/src/db/migrations/1784400000000-remove-service-fee.ts unset serviceFee and folded it into total). The headline money figure is the customer's committed spend and there is no second net figure underneath it, no fee line, and no gross/net distinction anywhere on the surface.

2. Active Service Revenue (M8) — a stock, as at a moment

The definition record for M8 already exists, seeded by stub 1 (1784600000000-seed-metric-definitions.ts): shape stock, group Revenue, order 0. This stub puts the figure behind it.

  • Population: every engagement of this vendor that has been accepted and has not yet reached Delivered or ended. Membership is the engagement's stage right now, not a date range.
  • Measure: the sum of contracted value — the accepted quote's total, excluding tax — of those engagements, as at the read's as-at moment.
  • Labelled a stock, everywhere it appears. The surface states it is a figure as at a moment, and it is never added to, differenced against, or shown as a comparison with a period total (BR-11). Where it shares a screen region with Billed or Collected in the period, the stock/flow distinction is visible on the screen, in the prototype's wording that earned its place: these are never added together.

The four delivery stages, splitting the same total and summing to it exactly (BR-18):

Stage The engagement is in it when…
Accepted, not started the proposal is accepted and no milestone has been started
Work in progress at least one milestone has been started and not every milestone is complete
Awaiting final acceptance every milestone is complete and the customer has not yet acknowledged completion
Complete, awaiting payment the customer has acknowledged completion and at least one bill is still unsettled

Every engagement in the population is in exactly one of the four, so the splits are a partition and the reconciliation is arithmetic rather than assertion. Milestone state comes from the milestone record (upcoming / in-progress / done / blocked; blocked counts as started); customer acknowledgement and the end of an engagement come from the lead workflow's own statuses via the workflow engine in packages/services, never from a new status field or enum.

Leaving the stock. An engagement leaves active service revenue when every bill against it is settled — at which point it has reached Delivered, which is exactly what the seeded M3 definition already says its basis date is ("every milestone complete and acknowledged by the customer, and every bill settled") — or on the day the engagement ends. The two exits are the only two, so nothing can be in both M8 and Revenue Delivered at once. An engagement that ends mid-delivery leaves on the day it ends: whatever was collected stays collected, and billed-but-unpaid value shows as outstanding, not as revenue.

Opens to the engagements in any stage, each row carrying contracted value, stage, and days in stage.

3. Committed, Billed and Collected

  • Committed = contracted value less what has been billed against it, summed over the engagements in active service revenue. The approved prototype computed exactly this (apps/demo/lib/mock/vendor-metrics.tscommittedNotYetBilled) and it reconciled; the invoice records support it — an invoice is raised per milestone against the engagement's quote, so billed- to-date is the sum of the engagement's currently valid bills.
  • Billed = the value of bills issued in the period, on the date the bill was issued. A bill issued, queried, cancelled and re-issued counts once — the currently valid bill only, never both.
  • Collected = the value paid in the period, on the date payment was received.

Billed and Collected are period flows and say so; Committed is a stock read alongside active service revenue and says so. None of the three is ever added to another.

4. Rendering — reuse ClaimFigure, do not build a second one

Stub 2 (vendor-funnel-and-activation, merged 2026-08-19) shipped the claim renderer: apps/web/components/vendor/metrics/claim-figure.tsx, taking a LiveFigure ({ claim, label, periodLabel, breakdowns, cohort, emptyRecordsLabel }). M8 renders through that component — a second money-shaped renderer beside it is exactly the drift this scope exists to stop, and would give one concept two presentations.

M8 maps onto it directly: the stock is the claim, the four delivery stages are a Breakdown, and the engagements behind the figure are the cohort. One gap to close rather than work around: the shipped BreakdownMode is "atLeastStage" | "byReason", both funnel shapes, and neither describes a partition — every engagement sits in exactly one delivery stage, and the rows must sum to the total rather than nest or accumulate. Add the partition mode to the existing union; do not force the stage split through a funnel mode whose arithmetic is different.

isRenderableClaim is enforced at the boundary: a money figure with no record set, no columns or no reconciliation lines does not reach the screen. Money formatting continues to go through the surface's existing constants — whole currency units, never abbreviated (BR-19).

5. The MRR proxy is replaced

headline.ts's mrr field is a monthly-normalised proxy that HeadlineStrip renders as MRR (proxy) via fmt(headline.mrr, "GBP", 1) — a hard-coded currency, abbreviated to thousands, which BR-19 rules out. It is replaced by Active Service Revenue: the real stock, labelled as one, in whole units of the reporting currency, opening to the same record set as M8 so the dashboard tile and the dictionary figure cannot disagree (BR-4). The proxy, its normalisation constant and its tests go with it.

6. Currency — one reporting currency, the frozen rule recorded

Every quote in the platform is written in EUR today (the model permits EUR/USD/GBP; nothing writes the others), and no exchange rate is stored anywhere. Rather than invent an unexercised rate table, this stub asserts a single reporting currency and holds the line honestly:

  • every money figure is reported in the vendor's reporting currency;
  • an engagement whose currency is not the reporting currency is not summed into any figure. Its count is shown beside the figure it was left out of, so the exclusion is stated rather than assumed (BR-17's spirit, BR-21's discipline) — never silently converted, never silently dropped;
  • BR-12's frozen rule is written into M8's definition record as a stated exclusion, so the vocabulary is complete and a later conversion path has one definition to satisfy: conversion is frozen at the rate on the date of the value event, and historical figures never move because a rate moved.

Multi-currency conversion, a rate store and a currency-switching flow are out of scope (below).

7. Credits and refunds — the rule, without a record to read

BR-13's rule — a credit or refund reduces Collected on the date the credit is issued, and never restates the period the original payment fell in — is written into the Collected measure and M8's definition text. No credit or refund record exists (invoices are paid/unpaid with amounts pinned at min: 0), and this stub reports money rather than moving it, so the credit record type and its write path are out of scope; the read is added when the record exists. What this stub guarantees is that a completed period never changes: no figure is restated retrospectively.

8. Definition-record updates

A migration updates the seeded M8 record's exclusions and measure to carry the currency rule and the credit rule, and adds the stage vocabulary to opensTo. The dictionary stays the single home of every definition — no surface states its own.

Where it goes

  • packages/services/src/shared/metric-claim.tsMoneyState, the money claim variant, the one-state totalling helper.
  • packages/services/src/db/services/vendor/ — the M8 reads and the stage partition, in the tenant-scoped query layer the claim surface already owns (BR-23), with the pure arithmetic split out for unit tests as headline.ts already does.
  • packages/services/src/db/migrations/ — the M8 definition-record update.
  • apps/web/components/vendor/metrics/ — the claim renderer and the drill-down.
  • apps/web/components/dashboard/vendor/headline-strip.tsx — the proxy tile replaced.

Acceptance criteria

  • Every money figure rendered through the claim surface names which of Delivered, Committed, Billed or Collected it is, and a money figure without a state cannot be constructed — the constraint is in the type, not in a review note (BR-10).
  • No screen adds, differences or compares any two of the four states, and the helper that totals money figures rejects a mixed-state set at compile time.
  • Active Service Revenue is presented as a stock as at a moment, labelled as one, and no screen adds it to or compares it against a period total (BR-11).
  • The four delivery-stage splits partition the population — every engagement in exactly one — and sum exactly to the total, with the reconciliation shown as data in the drill-down (BR-18, BR-2).
  • The drill-down lists the engagements behind the figure with contracted value, stage and days in stage, and those rows add up to the figure shown.
  • An engagement leaves active service revenue on the day every bill against it settles, or on the day it ends — and appears in no other money state while it is in the stock.
  • An engagement that ends mid-delivery leaves the stock that day; what was collected stays collected, and billed-but-unpaid value reads as outstanding rather than as revenue.
  • A bill issued, queried, cancelled and re-issued counts once in Billed — the currently valid bill only.
  • The dashboard headline strip shows Active Service Revenue in place of the MRR (proxy) tile, in whole units of the reporting currency and never abbreviated, reading the same record set as the M8 figure so the two cannot disagree (BR-4, BR-19).
  • No service-fee figure, net-of-fee figure or fee line appears anywhere on the surface (Q-3).
  • All figures exclude tax, and each opens to the engagements or bills behind it (BR-2).
  • An engagement whose currency is not the reporting currency is excluded from every money figure and its count is stated beside the figure it was left out of — never silently converted, never silently dropped.
  • M8's definition record states the frozen-conversion rule (BR-12) and the credit rule (BR-13), and remains the only place the definition lives.
  • A vendor's money figures contain only that vendor's own engagements, by every route including the drill-down (BR-23).
  • Where the records give zero, zero renders with the count behind it; nothing renders as illustrative, indicative, placeholder or smoothed (BR-3).

Out of scope

  • Revenue Delivered's figure (M3) — stub 3. This stub defines the Delivered state and its slot in the vocabulary; the arithmetic is stub 3's, against this vocabulary.
  • Revenue at Risk (M10) — stub 8. It reads as a labelled slice of active service revenue rather than a second amount beside it; this stub establishes the vocabulary it has to sit inside, and builds no risk figure.
  • Top customers by spend and revenue-weighted satisfaction (M9) — stub 5.
  • Sweeping the legacy vendor dashboard. Sections not yet on the claim surface — revenue lost by stage, revenue created at BRD, the NRR/GRR retention strip — keep their current wording until stub 9 (vendor-dashboard-reconciliation) brings them onto these definitions. This stub makes an unlabelled money figure unrepresentable on the claim surface, and applies it to M8, the money states and the headline strip.
  • A credit or refund record type and its write path. The rule is recorded in the definition; the read follows the record.
  • Multi-currency conversion — a rate store, an FX provider, an Admin rate surface, or a currency-switching flow. The frozen rule is recorded, not built.
  • Changing pricing, invoicing or how money is billed. This stub reports money; it does not move it.
  • Restating historical periods on these definitions.

Open questions

  • none

Decisions taken at Define

  • Currency (Q-11, BR-12): single reporting currency, FX deferred — record the frozen rule in the definition and refuse to sum any engagement whose currency differs, showing the count. Chosen over an Admin-seeded rate table (a collection, a migration and a surface nothing exercises) and over a frozen-rate-per-event scheme, because every quote today is EUR.
  • Credits (BR-13): the rule goes in the definition and the measure; the read is deferred until a credit record exists. Nothing speculative is built against a record with no writer.
  • The relabelling seam (BR-10): vocabulary plus the claim surface. The legacy dashboard sweep stays stub 9's, per the batch's build order.
  • The stock's exit boundary: settlement, not customer acknowledgement — which is what the already seeded M3 definition says Delivered's basis date is, so the two definitions agree without either being changed.

Context budget: within the Inputs table. One extra read outside it — stub 1's archived spec and the claim-surface source it shipped (packages/services/src/shared/metric-claim.ts) — because this stub is that contract's first consumer and touches: had to name real paths.

03_build/output/notes.md

Build notes: vendor-money-states-and-active-service-revenue

  • commits: 2bc75bd feat: money in four named states, and active service revenue as a stock

What changed

  • packages/services/src/shared/metric-claim.ts: the money vocabulary joins the claim surface — MoneyState, MoneyBasis, MoneyAmount, MoneyClaim, totalMoney, isSameMoney. A money figure cannot be constructed without naming what money it is and whether it is a stock or a flow, and totalMoney uses NoInfer so the state and basis are fixed by the first amount: adding Collected to Billed, or a stock to a period total, is a compile error.
  • packages/services/src/shared/vendor-money.ts (new): the M8 arithmetic — the four-stage partition, the stock, Committed, the period flows, the record sets and their reconciliation lines. Pure, no DB, unit-tested.
  • packages/services/src/db/services/metric-dictionary/vendor-money.ts (new): the tenant-scoped read, through vendorClaimFilter like every other metric family (BR-23).
  • apps/web/components/vendor/metrics/claim-figure.tsx: BreakdownMode gains partition. Superseded at Verify (baf3dd6). Stub 3 landed matchValue on main for the same shape — a row whose cohort value equals the breakdown's, so the rows sum rather than accumulate. Adding a second mode meaning the same thing is the drift this scope exists to stop, so the merge took main's renderer wholesale and M8's breakdown uses matchValue. claim-figure.tsx is unchanged by this branch.
  • apps/web/lib/vendor-money-figures.ts (new) + both vendor surfaces: M8 renders through the existing ClaimFigure, on the dictionary grid and behind the dashboard's money tile.
  • packages/services/src/db/services/vendor/headline.{ts,test.ts} + index.ts: the MRR (proxy) figure, its monthly normalisation and its tests are deleted. headline.ts said in its own comment that it stood in "until the vendor-metrics money-states stub defines the real revenue reads" — this is that stub, so the superseded implementation goes with it.
  • packages/services/src/db/migrations/1787875200000-m8-money-definition.ts (new): M8's definition record gains the frozen-currency rule (BR-12), the credit rule (BR-13) and the stage vocabulary.

Decisions worth a reviewer's attention

Active service revenue is not a fifth money state. The spec's first criterion says every money figure names which of the four states it is. Active service revenue is not cleanly one of them: scope §3.5 puts it on a separate axis — "three milestones' value in Collected and two in Committed, and the whole engagement's contracted value in active service revenue" — and the M8 definition record stub 1 already shipped names no state either. Stamping "Committed" on the whole order book while Committed also names the not-yet-billed part would put one concept on screen with two values, which is exactly what BR-4 forbids. So MoneyClaim is a discriminated union: the four states, or ORDER_BOOK, which is a stock by construction and must state how its value decomposes. Nothing renders unlabelled either way. If Jamie reads the criterion more literally, the fix is small and local to metric-claim.ts.

Days in stage is dated from the transition records, not updatedAt. The obvious implementation reads the lead's updatedAt, which moves on any write at all — days-in-stage would quietly reset whenever something unrelated was touched, and the scope calls days-in-stage "exactly the signal the vendor needs". stageSince instead takes the accepted quote, the last milestone to move, or the acknowledgement, whichever marks the stage the engagement is actually in, and falls back down that chain rather than inventing a date.

Acceptance criteria status

  • Every money figure names its money and cannot be constructed without it — the discriminated union in metric-claim.ts, with the order-book caveat above.
  • No screen adds or compares two of them; totalMoney rejects a mixed set at compile time — asserted with @ts-expect-error, so if it ever starts compiling, Typechecking fails.
  • Active service revenue is presented as a stock and never added to a period total — basis on the claim, the tile's own label, and Billed/Collected deliberately not rendered as breakdowns of it.
  • The four stage splits partition the population and sum exactly to the total.
  • The drill-down lists contracted value, stage and days in stage, and the rows add up.
  • An engagement leaves on full settlement or on ending — the two exits, and no other.
  • An engagement that ends mid-delivery leaves that day; billed-but-unpaid reads as outstanding.
  • A bill issued, queried, cancelled and re-issued counts once (VOID_INVOICE_STATUSES).
  • The headline strip shows active service revenue in place of MRR (proxy), reading the same claim as the M8 figure.
  • No service-fee, net-of-fee or fee line anywhere.
  • All figures exclude tax and open to the records behind them.
  • An engagement in another currency is excluded and counted, never silently converted.
  • M8's definition record states the frozen-conversion and credit rules.
  • A vendor's money figures are that vendor's own, by every route.
  • Zero renders as zero with its count.

Notes for Verify

  • The order-book decision above is the one to challenge first. It is a reading of the scope, not something the spec spells out, and it shapes the central type.
  • VOID_INVOICE_STATUSES is a guess at vocabulary. Corrected at Verify (df0a245) — this note was wrong on both the premise and the values. Invoice status is not free-form: db/services/invoice/index.ts drives it through statusService.assertTransition over invoice_draft | invoice_sent | invoice_paid | invoice_rejected | invoice_disputed. (The transitions live in that service, not in workflows.json, which carries only lead and verification — hence the wrong conclusion here.) None of the guessed tokens existed, so the void set matched nothing: rejected and disputed bills counted as money owed, drafts counted as issued, and a rejected row pinned an engagement in the stock forever. Now VOID_INVOICE_STATUSES = ["invoice_rejected", "invoice_disputed"] and ISSUED_INVOICE_STATUSES = ["invoice_sent", "invoice_paid"], matched case- and whitespace-insensitively.
  • acknowledgedAt falls back to the lead's updatedAt. There is no status-history read in this service, so for the "complete, awaiting payment" stage only, days-in-stage inherits the imprecision I removed everywhere else. Reading statushistories would fix it; it needs another query and I kept the diff to the spec.
  • Billed/Collected are computed and tested but render nowhere yet. They are on the report for stub 8 (Revenue at Risk) and stub 9 (reconciliation). Rendering them would have meant inventing dashboard layout the spec puts out of scope.
  • The demo tenant has EUR-only quotes, so the currency-exclusion path shows nothing on the preview. Its behaviour is covered by unit tests rather than by the smoke pass.

Context budget: within the Inputs table.

04_verify/output/verify.md

Verify: vendor-money-states-and-active-service-revenue

  • ci: GREEN on a865521 — settled via ci-status.sh after the last push
  • previews smoked: web (https://web-git-claude-vendor-money-states-revenue-bv3064-sustentus.vercel.app) · docs, help-centre, marketing built and untouched by this diff · demo and storybook Ignored ("Canceled by Ignored Build Step") — no preview exists for this commit, and neither is touched by the diff, so nothing was claimed on them
  • production-readiness: run — migration, index and env findings below, all clear or fixed
  • code-review: high effort (spec complexity: complex) — findings fixed on branch, listed below
  • security-review: run (the diff adds a tenant-scoped read of vendor money) — no findings at or above the reporting threshold
  • playwright: TODO — manual DoD smoke performed instead

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

Deployment (agent). The web preview serves all three affected routes: / 200, /vendor/dashboard 200, /vendor/metrics 200. Signed out, the two vendor routes resolve through to the Clerk sign-in page — this evidences that the branch deployed and the routes are reachable, not that the figures render correctly. Nothing about the money surface is demonstrable without credentials.

Compile-time criteria (agent). Three acceptance criteria are structural rather than visual, and CI's Typechecking step is their demonstration — it passed on a865521:

  • A money figure cannot be constructed without naming its money — MoneyClaim is a discriminated union in metric-claim.ts:348; there is no unlabelled variant to construct. (agent — traced in the diff, enforced by the type)
  • The helper that totals money rejects a mixed-state set at compile time — totalMoney fixes S/B from the first amount via NoInfer (metric-claim.ts:312), and metric-claim.test.ts:243-263 pins it with two @ts-expect-error assertions. Those invert: if mixing ever starts compiling, the unused directive fails Typechecking. (agent)
  • No service-fee, net-of-fee or fee line anywhere on the surface (Q-3) — grep across apps/web/app/(app)/vendor, components/dashboard/vendor, components/vendor and both money modules returns nothing. (agent)

Wiring traced in the diff (agent). Read-level, not screen-level — these say the code path exists and is shaped right, and they are exactly the lines the operator pass would have confirmed on screen:

  • The headline tile and the M8 grid figure read the same claim — dashboard/page.tsx:154 passes moneyReport.activeServiceRevenue, and vendor-money-figures.ts hands the same object to ClaimFigure as M8.claim. One object, so the two cannot disagree (BR-4). (agent)
  • The stock is labelled as one — periodLabel: "A stock, as at the moment this was read — not a period total", and Billed/Collected are deliberately not breakdowns of it (BR-11). (agent)
  • The stage split is a partition — mode: "matchValue", so rows sum rather than accumulate. (agent)
  • Vendor isolation (BR-23) — every query in db/services/metric-dictionary/vendor-money.ts goes through vendorClaimFilter and carries tenantId; the module throws rather than falling back when either is missing. (agent)
  • All figures exclude tax — contracted value is the accepted quote's total ex-tax (vendor-money.ts:75), stated on the reconciliation line at vendor-money.ts:350. (agent)

Operator half — NOT PERFORMED.

  • Signed-in acceptance criteria on the preview (Vendor sees the "Active service revenue / Order book" tile in place of MRR (proxy); /vendor/metrics M8 opens to the stage partition; the drill-down lists contracted value, stage and days in stage, summing to the headline) — not demonstrated
  • auth: Vendor / CSM sign-in reaching their dashboards — not demonstrated
  • payments: not touched — this stub reports money, it does not move it (agent — diff)
  • notifications: none expected; the diff adds no notification path (agent — diff)

Jamie authorised the gate directly — Ready to merge ticked on PR #859 and "ship it" in conversation — rather than reporting results from the signed-in walkthrough. That is his call to make and it is what closed the gate, but the record must not read as though the walkthrough happened. No figure on this surface has been seen rendering against real data by anyone. The first vendor to open the dashboard after merge is the first human to see it.

The walkthrough clip for #build was not recorded (visibility, not a gate — the agent cannot record one).

Findings & cleanup

Three review passes ran; every finding below is fixed on this branch.

  • VOID_INVOICE_STATUSES matched no real status — the guessed set was ["cancelled", "rejected", "void"]; the platform writes invoice_draft | invoice_sent | invoice_paid | invoice_rejected | invoice_disputed through statusService.assertTransition in db/services/invoice/index.ts. Nothing matched, so rejected and disputed bills counted as money owed (understating Committed), drafts counted as issued, and a rejected row pinned an engagement in the stock forever — fixed in df0a245. Highest-impact finding of the stage. (The audit that surfaced it cited workflows.json:287-345 for the invoice workflow; that file carries only lead and verification. The citation was wrong, the conclusion was right — verified directly against the service.)
  • Settlement fired on the first paid invoicefullySettled was true as soon as any invoice was paid, so a four-milestone engagement with one paid bill dropped its whole contracted value out of the order book mid-delivery. Extracted as isFullySettled, which now requires an issued bill per milestone with every one paid — fixed in df0a245, pinned by unit tests.
  • ended ignored isRejected — the funnel treats it as an independent lost signal, so a rejected engagement stayed in the stock. Now status === ENDED_STATUS || isRejected === true — fixed in df0a245.
  • stageSince read the lead's updatedAt — caught in Build's own self-review before push; updatedAt moves on any write, so days-in-stage would silently reset. Now derived from the transition records (accepted quote, last milestone to move, acknowledgement).
  • Accepted-quote read was unordered — added .sort({ createdAt: 1 }) so the accepted proposal is deterministic when more than one exists.
  • EngagementInput name collision — stub 3 landed a type of that name in shared/vendor-delivery.ts, and shared/index.ts re-exports both, so the dts build and typecheck failed with TS2308. Renamed mine to MoneyEngagementInput — fixed in a865521.
  • partition breakdown mode superseded — stub 3 shipped matchValue on main for the same shape. A second mode meaning the same thing is the drift this scope exists to stop, so the merge took main's renderer wholesale; claim-figure.tsx is now unchanged by this branch (baf3dd6).
  • production-readiness: the migration's down is symmetric (restores the seeded wording verbatim rather than unsetting, and drops the index it created). No new env vars, so nothing to add to turbo.json's globalEnv. The new { tenantId: 1, vendor: 1 } index on leads is created by the migration as well as declared on the schema, because autoIndex is off in production.
  • Accepted, not fixed — acknowledgedAt falls back to the lead's updatedAt. For the "complete, awaiting payment" stage only, days-in-stage inherits the imprecision removed everywhere else; a statushistories read would fix it and needs a query the spec does not scope. Recorded here and in build notes rather than silently carried.
  • Accepted — the order book is not a fifth money state. ORDER_BOOK is its own MoneyClaim variant, a stock by construction that must state its composition. Scope §3.5 keeps active service revenue on a separate axis and the seeded M8 record names no state; stamping "Committed" on it while Committed also names the not-yet-billed part would put one concept on screen with two values (BR-4). Flagged as the first thing to challenge; not challenged.
  • Accepted — Billed and Collected are computed and tested but render nowhere. They are on the report for stub 8 (Revenue at Risk) and stub 9 (reconciliation); rendering them here would have meant inventing dashboard layout the spec puts out of scope.
  • Build notes carried two claims that later turned out wrong (the invoice-status premise and the partition mode). Both are struck through and corrected in place in 03_build/output/notes.md rather than left to mislead whoever reads the archived run.

Context budget: within the Inputs table.

05_ship/output/changelog.md


title: Active service revenue, and money that always says which money it is date: 2026-08-19T16:00:00Z personas: [vendor, csm] slug: vendor-money-states-and-active-service-revenue pr: https://github.com/sustentus/sustentus/pull/859

Active service revenue, and money that always says which money it is

Most arguments about a revenue number are two people using one word for three different amounts. Your dashboard now names which amount it means, every time, and it no longer puts a snapshot next to a period total as though the two could be compared.

Active service revenue replaces the MRR proxy

The money tile on your dashboard used to read MRR (proxy) — a placeholder, and its own description said so. In its place is active service revenue: the contracted value of every engagement you have accepted that is not yet settled and has not ended, as at the moment the page was read.

It is labelled a stock, not a monthly figure, because that is what it is — a snapshot of the work in flight right now. A stock is not a rate and does not belong in the same sum as one, so nothing on the dashboard adds it to a period total or compares it against one.

The figure is shown in whole units of your reporting currency and is never abbreviated. The tile and the entry in the KPI dictionary read the same records, so the two cannot disagree.

It opens to where the work is stuck

Open the figure and you get the four delivery stages the work sits in. Every engagement is in exactly one stage, so the rows add up to the total shown rather than nesting inside each other, and the arithmetic is printed underneath so you can check it.

Beneath the stages sit the engagements themselves — contracted value, stage, and how many days each has been sitting in that stage. Those rows add up to the figure above them. A number that says how much work is in flight is useful; a number that also says where it has stopped moving is actionable.

Four states, never mixed

Every money figure now names which of four things it is:

  • Delivered — the contracted value of work that reached delivered
  • Committed — accepted value that has not been billed yet
  • Billed — the value of bills you have issued
  • Collected — what the customer has actually paid

No screen adds two of these together, and no screen compares them. They answer different questions and a figure that blends them answers none of them.

A bill that is issued, queried, cancelled and re-issued counts once — the bill that is currently valid, not every version of it. When an engagement ends part-way through delivery it leaves active service revenue that day: what was collected stays collected, and value that was billed but never paid reads as outstanding rather than as revenue you earned.

One currency, and it says what it left out

Figures are read in your reporting currency. An engagement priced in a different currency is left out rather than converted at a rate nobody agreed to, and the number of engagements excluded is stated beside the figure — so an amount that looks low tells you why.

All figures exclude tax, and every one of them opens to the engagements or bills behind it. Where the records genuinely give zero, you see zero with the count behind it. Nothing on this surface is illustrative, indicative or smoothed.

05_ship/output/investor-update.md

Money that says which money it is

Who it's for: Vendors, CSMs What shipped: Active service revenue as a labelled stock, opening to where the work is stuck. Why it matters: Refine the Bridge — Q2-2026 Objective 1: Establish Product-Market Fit with Vendor Partners. A vendor who catches one wrong number stops trusting all of them.

Delivered, Committed, Billed and Collected are never added together.

Dig deeper: https://github.com/sustentus/sustentus/pull/859 · https://help.sustentus.com/changelog/2026-08-19-vendor-money-states-and-active-service-revenue

05_ship/output/release.md

Ship: vendor-money-states-and-active-service-revenue

  • pr: #859 · merge: authorised — Ready to merge ticked by Jamie. This record rides the squash, so it carries no post-merge SHA; the PR's own merged state is the record of the merge.
  • CI: GREEN on the head that merged, settled via ci-status.sh after the last push. Two rounds ran in this stage. The first was on 62c0920, the merge of origin/main that cleared the branch's conflicted state; the second is on this record's own commit, which brings in the docs, the changelog and this file. The merge is taken only on that second round settling green, and only from ci-status.sh — not from the Vercel comment table, which reported demo and storybook as Building before their ignore steps settled them to Ignored.
  • technical docs: technical/packages/services — the Metric families bullet gains the fourth family: shared/vendor-money.ts and the money types in shared/metric-claim.ts (the four named states, MoneyClaim having no unlabelled variant, totalMoney refusing a mixed set via NoInfer), active service revenue as a stock carrying its composition and its stage partition, and vendorMoneyService reading through vendorClaimFilter in one reporting currency with the excluded count stated rather than converted.
  • business docs: business/roles — an Active service revenue line under what a vendor sees: the tile that replaces MRR (proxy), why it is labelled a stock and never added to a period total, what it opens to, the four money states and the never-mixed rule, and the one-currency-with-a-stated-exclusion behaviour. feature-role-matrix deliberately unchanged — this stub adds no permission or role capability; /vendor/metrics and /vendor/dashboard keep their existing policies. platform-overview unchanged: replacing one dashboard tile and adding one dictionary figure is not a headline platform capability.
  • release notes: both
  • sent: ship note queued for #product-update — .github/workflows/ship-note.yaml fires on this merge. Both Dig deeper links were filled before the merge, not left as placeholders.
  • close-out: archive this run to apps/docs/archive/pipeline-runs/. The vendor-metrics epic is not finished — four stubs remain in .icm/intake/vendor-metrics/ (revenue at risk, customer health and churn risk, retention by speed and region, dashboard reconciliation) — so the intake folder stays where it is.

Merge-conflict reconciliation (this stage)

The branch went dirty while it sat at Verify: #861, #858 and its close-out landed on main underneath it. Five files conflicted and every one was an additive collision — two metric families registering on the same surfaces, not two answers to one question:

  • shared/index.ts, db/services/index.ts, metric-dictionary/instance.ts — both families register; both lines kept.
  • vendor/metrics/page.tsx — the page now reads all four families in one Promise.all and spreads all four figure sets. M8 does not collide with M4/M9, so no figure overwrites another.
  • vendor/index.ts — took main's import of CSAT_SCALE_FACTOR, which moved to shared/vendor-satisfaction.ts when #858 shipped, and dropped HeadlineEngagement: its only use went away when this branch took active service revenue out of the headline read. The type now has no reference anywhere in the tree.

metric-claim.ts needed no reconciliation — this branch only appended to it, so Claim, RecordSet and RecordColumn are unchanged and #858's satisfaction claims are unaffected.

Acceptance check (vs spec)

  • Every money figure names which of Delivered, Committed, Billed or Collected it is, and one without a state cannot be constructed (BR-10) — structural: MoneyClaim is a discriminated union with no unlabelled variant
  • No screen adds, differences or compares two of the four states, and the totalling helper rejects a mixed-state set at compile time — totalMoney fixes S/B from its first argument via NoInfer, pinned by two @ts-expect-error assertions that fail Typechecking if mixing ever starts compiling
  • Active Service Revenue is presented as a stock as at a moment, labelled as one, and no screen adds it to or compares it against a period total (BR-11) — the tile reads "A stock, as at now · never added to a period total"; Billed and Collected are deliberately not breakdowns of it
  • The four delivery-stage splits partition the population and sum exactly to the total, with the reconciliation shown as data in the drill-down (BR-18, BR-2) — mode: "matchValue", so rows sum rather than accumulate
  • The drill-down lists the engagements with contracted value, stage and days in stage, and those rows add up to the figure shown
  • An engagement leaves active service revenue on the day every bill against it settles, or on the day it ends — isFullySettled requires an issued bill per milestone with every one paid; unit-asserted after Verify caught it firing on the first paid invoice
  • An engagement that ends mid-delivery leaves the stock that day; collected stays collected and billed-but-unpaid reads as outstanding
  • A bill issued, queried, cancelled and re-issued counts once in Billed — the currently valid bill only, against the real invoice_* status vocabulary Verify corrected
  • The headline strip shows Active Service Revenue in place of MRR (proxy), in whole units and never abbreviated, reading the same record set as the M8 figure (BR-4, BR-19) — one claim object is passed to both, so they cannot disagree
  • No service-fee, net-of-fee figure or fee line anywhere on the surface (Q-3) — grep-verified at Verify across both money modules and all three vendor component trees
  • All figures exclude tax, and each opens to the engagements or bills behind it (BR-2)
  • An engagement in a non-reporting currency is excluded and its count stated beside the figure — never silently converted, never silently dropped
  • M8's definition record states the frozen-conversion rule (BR-12) and the credit rule (BR-13), and remains the only place the definition lives
  • A vendor's money figures contain only that vendor's own engagements, by every route including the drill-down (BR-23) — every query goes through vendorClaimFilter, which throws rather than returning an unscoped query
  • Zero renders with the count behind it; nothing renders as illustrative or smoothed (BR-3)

Still the operator's, carried from Verify unperformed

The agent has no preview credentials, so the signed-in half of the DoD smoke was never exercised04_verify/output/verify.md says so in its own words, and shipping went ahead on Jamie's instruction. These are unchecked checks, not passed ones:

  • a vendor signs in and sees the Active service revenue tile where MRR (proxy) used to be
  • /vendor/metrics M8 opens to the stage partition, and the rows sum to the headline on screen
  • the drill-down lists contracted value, stage and days in stage for each engagement
  • the excluded-currency count renders beside a figure on a tenant that has one

Everything reachable without signing in was verified: the unit suite, the migration run against a real database by Migrate preview database, the scoping traced through vendorClaimFilter, and the preview build on every head. No walkthrough clip was recorded.

Carried forward

  • acknowledgedAt falls back to the lead's updatedAt for the "complete, awaiting payment" stage only, so days-in-stage there inherits the imprecision removed everywhere else. A statushistories read fixes it and needs a query this spec does not scope.
  • Billed and Collected are computed and tested but render nowhere. They are on the report for stub 8 (Revenue at Risk) and stub 10 (dashboard reconciliation); rendering them here would have meant inventing layout the spec puts out of scope.
  • The order book is deliberately not a fifth money state. ORDER_BOOK is its own MoneyClaim variant — a stock by construction that must state its composition. Labelling it Committed while Committed also names the not-yet-billed part would give one concept two values (BR-4).
  • Two values for one concept remain live elsewhere. /vendor/retention still carries the older money reads; stub 10 (vendor-dashboard-reconciliation) closes that, as the spec records.

Context budget: within the Inputs table.