A currency the meter can fold
The model-credit meter records what a round cost in the model rate's currency and measures it against an allowance in the plan's. On every deployment we run those are different currencies, and the meter — correctly — refused to guess. This page is why that refusal was right, why it was also unlivable, and what replaced it.
Read this before touching ModelCreditLedger.Fold, ModelCreditGuard.ObserveRate or the prices on
a Provider/* model node.
What was measured
On namespace memex (2026-09-03, re-measured 2026-09-04, core issue #3235):
Every ModelCreditCharge row |
currency: "USD", providerPath: "Provider/OpenRouter" |
Every ModelCreditGrace counter |
currency: "CHF" |
Every Admin/Tiers/* allowance |
currency: "CHF" |
Provider/OpenRouter/z-ai/glm-5.3 |
inputPricePerMillionTokens: 1.4, currency: "USD" |
| Error lines in ten hours, five pods | 192, one several-hour-old charge dominating them |
Admin/ModelCredit/Admin/_Grace-2026-09 |
version 330 after 165 rounds in one day |
Nothing was misconfigured by accident. OpenRouter publishes USD, the plans are sold in CHF, and both facts are correct. The meter met them and reached no verdict, so allowance enforcement was OFF for every metered subscriber on the instance — and said so 192 times.
The decision
The meter converts only with a rate a HUMAN declared, as a node. Everything else about the refusal stands.
There were two candidate directions and they are not equivalent.
(a) Price the platform's models in the allowance currency. The log line's own remedy, and it
needs no code at all: ModelDefinition.Currency is authorable, so an operator could stamp CHF
prices on the twelve Provider/OpenRouter/* nodes today. Rejected. It embeds one conversion
into twelve numbers, so every OpenRouter price change means re-deriving twelve CHF figures by hand;
the conversion itself is invisible as a conversion, with no rate and no date to check; the charge row
then claims the round cost CHF, which it did not, and the real number is gone from the audit trail
for ever; and a provider that prices in a third currency reproduces the whole defect.
(b) Give the meter an explicit conversion route. One declared number, in one place, named in the verdict, correctable with one edit, and general over any number of currencies. Chosen.
🚨 A declared rate is not an invented one. The two things this meter has always refused it still
refuses: it never derives a rate (no inverse of a declaration, no cross-rate composed from two of
them), and it never drops a row it cannot fold. Where no declaration covers a charge's currency the
answer is exactly what it was before — UNDETERMINED, the round refused as Misconfigured, the row
intact — with a log line that now names the node to create.
The node
Admin/ModelCredit/_Rate-{FROM}-{TO} nodeType: ModelCreditExchangeRate
{ "$type": "ModelCreditExchangeRate",
"from": "USD", "to": "CHF", "rate": 0.80,
"source": "ECB reference rate, 2026-09-04",
"declaredAt": "2026-09-04T06:00:00Z" }
rate is how many to one from is worth. from, to and rate are the load-bearing fields;
source and declaredAt are audit copy and never disqualify a declaration — silently ignoring a node
an operator can see because a free-text field is blank is a worse failure than an under-annotated
rate, and the node's own createdBy/createdDate record who declared it and when regardless. A
sibling of the subscriber containers, under the
same leading underscore that already marks _Grace-{period}, and read by a scoped LISTING rather
than a point read — a rate node is optional by construction, and a point read of an absent node
terminates the stream and opens the stream cache's storm breaker on the path.
It is the one node in this family that is not excluded from the create and search contexts. The two accounting types are records nobody authors; this one is the fix a log line tells an operator to apply, and hiding it would name a remedy the product does not offer.
- One direction per node.
USD → CHFdoes not implyCHF → USD:1/ratewould invent a rounding and give one pair two answers depending on which way it was read. - No cross-rates.
EUR → USDbesideUSD → CHFdoes not make EUR foldable into CHF. That product is a number nobody wrote down. - A rate of zero or less is ignored, not applied — a rate of nothing prices every charge at nothing, which is the widest possible allowance.
- No expiry. A declaration that goes stale is replaced by a fresh declaration, never by a silence: expiring one would turn an operator's holiday into a fleet-wide refusal.
- 🚨 A rate READ that fails is not "no rate declared".
ObserveDeclaredRatesdeliberately does not catch — an empty table converts nothing, so swallowing a transient query fault there would reportCurrencyMismatch, which the gate reads as a DETERMINISTIC misconfiguration, reserves no grace for, and refuses the round over. The fault propagates instead and becomesUnreadable: no verdict, bounded grace, retryable. That is Plugins#1243's distinction, defended from the other side. An EMPTY result set is still an answer and means exactly what it says.
Fold time, not charge time — and why that is not a contradiction
ModelCreditCharge is emphatic that the money is priced once, at the moment it is spent, so a
re-priced model cannot move a balance under a subscriber. The exchange rate is deliberately the
opposite, and the two rules are about different things.
The model rate is a fact about one round: what those tokens cost. It belongs to the round and is frozen with it. The exchange rate is the basis on which a period is compared to its allowance. A period half-converted at an old declaration and half at a new one is less explicable than one converted consistently; a mistyped rate has to be correctable after the fact or the period is permanently wrong; and rows already in the ledger — every one of them, on every deployment — were written before any declaration existed and can only be folded on read.
So the charge row keeps the currency the round actually cost, for ever, and
ModelCreditLedger.Fold applies the declaration when it sums.
Reported per charge, not per round
The medium-confidence half of #3235, and a real defect on its own.
An unconvertible row is durable: it makes every later read of that period undetermined too. A
meter that re-derives the verdict per pass therefore re-reports the same row on every round of the
month, on every pod — 192 lines from one charge, and a grace counter driven to version 330 before
Plugins#1243 stopped the reservation half of it.
The cure is the house pattern from core #3213: a marker on the record, written through
GetMeshNodeStream(path).Update(...), so the gate can actually become true.
ModelCreditCharge.UnconvertibleReportedAtis stamped on the row the first time the meter reports it.ModelCreditFold.Unreportedfilters on it, and that is the whole of the gate.- 🚨 The guarantee is AT-LEAST-ONCE, converging on once — not exactly-once. Passes that all read
the row before the first marker write settles will all report it, and claiming before logging would
not close that window:
stream.Updatediffs on the READER and sends a merge patch, so two pods still both observenullfirst. What the marker buys is the thing #3235 was actually about — the window is a handful of racing passes instead of every round for the rest of the period. Saying "once, for ever" would be a promise the mechanism cannot keep. - 🚨 Log first, mark second. If the write fails the row stays unmarked and the message repeats, which is the correct behaviour for "we could not record that we told you". Claiming first trades a bounded duplicate for a possible silence, and silence is the failure that costs an operator the fix.
- 🚨 Never a process-wide de-dup cache. Static state is forbidden here, and it would re-notify on every pod start anyway — which is exactly the five-pod shape the incident had.
- Marking silences the notification, never the refusal. The row stays in
ModelCreditFold.Unconvertible, the total staysnull, the round is still refused. A marker that suppressed the fault would be the dropped row wearing a different hat. - One entry per offending CURRENCY, and every offending currency. The fold used to return at the first bad row, so a EUR charge behind forty USD ones was invisible: the operator declared the rate they were told about, the period stayed undetermined, and nothing new appeared in the log.
Operating it
A deployment whose model prices and plan prices are already in one currency declares nothing and behaves exactly as before. Where they differ, the error line names the node:
[ModelCredit] Admin/ModelCredit/Admin/2026-09/35a1c6ae is priced in USD but the plan's allowance
is in CHF — the period total is UNDETERMINED. This meter will not invent an exchange rate and will
not drop the row: declare the conversion it may use as a ModelCreditExchangeRate node at
Admin/ModelCredit/_Rate-USD-CHF (rate = how many CHF one USD is worth), or price the platform's
models in the allowance currency. Reported once per charge.
Create that node and the next read already uses it — the rates are read as a live query beside the
charges, so nothing restarts. The declaration is an accounting policy number, not a market feed:
write down where it came from in source, and revisit it on whatever cadence the business uses.
Or have it kept current for you. A deployment can arm a daily download that writes exactly this
node from a published reference feed, with the mid-market rate, the margin and the publication date
recorded separately so the number can still be taken apart — see
Keeping the exchange rate current (AI/ModelCreditRateFeed). It is off unless a deployment turns
it on, it never derives an inverse or a cross-rate from two declarations, and it never falls back
to a rate anyone hard-coded: a fetch that fails leaves whatever is declared exactly where it is.
Nothing on this page changes when it is armed — the meter still reads one number off one node it did
not choose.
Where this is pinned
| Claim | Test |
|---|---|
| No declaration ⇒ still refuses, and the matching rows are NOT summed on their own | ModelCreditCurrencyConversionTest.WithNoDeclaredRate_ThePeriodStillRefuses_AndNoRowIsDropped |
| A declared rate folds the period at that number | …ADeclaredRate_FoldsThePeriodAtThatNumber, ModelCreditUnconvertibleReportedOnceTest.ADeclaredRate_MakesTheSameLedgerSummable |
| No inverse, no cross-rate, no rate of zero | …NeitherAnInverseNorACrossRateIsEverDerived, …ARateOfNothingIsNotARate |
| Reported once per row, on a real ledger | ModelCreditUnconvertibleReportedOnceTest.AnUnconvertibleCharge_IsReportedOnce_AndTheTotalStaysUndetermined |
| Every offending currency named, each once | …EveryOffendingCurrencyIsNamed_AndEachOnlyOnce, …AMarkedRowDoesNotSilenceANewCurrency |
| A marked row still refuses | …AReportedRow_IsNeverReportedAgain_ButStillRefuses |
The plan-side half of the meter — what includedModelCredit means, why null is never "unlimited",
and the bounded grace — is in the Store's own Subscriptions page.