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.

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.

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.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.