vendor-money-states-and-active-service-revenuerun.md02_define/output/spec.mdScope: .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/.
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.
M8 (Active Service Revenue), plus the four-state money vocabulary the rest of the batch sits inside.
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.
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.
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.
apps/demo/lib/mock/vendor-metrics.ts → committedNotYetBilled) 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 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.
ClaimFigure, do not build a second oneStub 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).
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.
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:
Multi-currency conversion, a rate store and a currency-switching flow are out of scope (below).
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.
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.
packages/services/src/shared/metric-claim.ts — MoneyState, 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.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).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.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.md2bc75bd feat: money in four named states, and active service revenue as a stockpackages/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.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.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.
metric-claim.ts, with the order-book caveat above.totalMoney rejects a mixed set at compile time —
asserted with @ts-expect-error, so if it ever starts compiling, Typechecking fails.basis
on the claim, the tile's own label, and Billed/Collected deliberately not rendered as
breakdowns of it.VOID_INVOICE_STATUSES).MRR (proxy), reading the same
claim as the M8 figure.VOID_INVOICE_STATUSES is a guess at vocabulary.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.Context budget: within the Inputs table.
04_verify/output/verify.mda865521 — settled via ci-status.sh after the last pushcomplexity: complex) — findings fixed on branch, listed belowDeployment (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:
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)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)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:
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)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)mode: "matchValue", so rows sum rather than accumulate.
(agent)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)vendor-money.ts:75), stated on the reconciliation line at vendor-money.ts:350. (agent)Operator half — NOT PERFORMED.
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
demonstratedJamie 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).
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.)fullySettled 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)..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).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.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.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.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.mdMost 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.
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.
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.
Every money figure now names which of four things it is:
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.
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.mdWho 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.mdci-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/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/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..github/workflows/ship-note.yaml fires on this
merge. Both Dig deeper links were filled before the merge, not left as placeholders.apps/docs/archive/pipeline-runs/. The vendor-metrics epic is
not finished — 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.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.
MoneyClaim is a discriminated
union with no unlabelled varianttotalMoney fixes S/B from its first argument
via NoInfer, pinned by two @ts-expect-error assertions that fail Typechecking if mixing
ever starts compilingmode: "matchValue", so rows
sum rather than accumulateisFullySettled requires an issued bill per milestone with every one paid;
unit-asserted after Verify caught it firing on the first paid invoiceinvoice_* status vocabulary Verify correctedMRR (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 disagreevendorClaimFilter, which throws rather
than returning an unscoped queryThe agent has no preview credentials, so the signed-in half of the DoD smoke was never
exercised — 04_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:
MRR (proxy) used to be/vendor/metrics M8 opens to the stage partition, and the rows sum to the headline on screenEverything 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.
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.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)./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.