The transparent bill

ModelCreditCharge has priced every metered round since the meter shipped, and until now nothing rendered it: a subscriber saw a remaining-credit number and nothing that made it up. A balance nobody can decompose is a number you can only believe or disbelieve.

This page is the design of the surface that decomposes it — and, more importantly, of the two things that surface could have got dangerously wrong: whose rows it shows, and what it claims a period was worth.

Read this before touching ModelCreditBill, ModelCreditBillArea, or either of the two readers they call on ModelCreditLedger.

Where it lives, and why there are two doors

Door Who uses it Where
The AiModelCredit layout area anyone, for their own bill /Provider/AiModelCredit, from the ✨ AI menu, beside Providers / Models / Tiers
The AiAdmin settings tab platform admins, for any subscriber Settings ▸ Administration, beside Token Usage and the Plugin Catalog

Both render ModelCreditBillArea.BuildContent, so they cannot disagree. Neither is a new mechanism: the first is the seam AiCatalogLayoutAreas already uses for the Providers, Models and Tiers catalogs, and the second is the SettingsMenuItemProvider seam the Plugin Catalog's admin tabs use, including its positive-confirmation shape for hub.IsGlobalAdmin.

Two doors rather than one because the audiences differ in the way this estate already models: admin surfaces are tabs under Settings ▸ Administration, not browsable Spaces — a Space partition would correctly deny read to everyone else, which was the "Access denied on Plugins" bug — while a personal bill belongs where a person already goes to look at AI.

The configuration half is NOT rebuilt here

The AI Admin tab links to the provider, model and tier catalogs. It does not reimplement them, because they already exist and already handle the part that matters:

A second key-entry surface would be a second place to get that wrong. The bill therefore has no write path to a provider node at all, and reads none: it names the provider a charge came from from the charge's own providerPath string, which is not a credential and not a node read.

The access decision is one pure function

The ledger lives under Admin, which the subscriber cannot read — deliberately, and for the reason ModelCreditCharge gives: a ledger the metered person could read is a ledger they could argue with. So ModelCreditLedger.ObservePeriodCharges reads as System and performs no access check of its own. That leaves exactly one gate between a viewer and someone else's spending:

ModelCreditBill.ResolveSubscriber(viewerId, requested, viewerIsGlobalAdmin)
viewer asked for admin? resolves to
— (signed out) anything either null — nothing is read
alice either alice
alice alice either alice
alice bob no alice — the request is discarded
alice bob yes bob

Three properties of that table are load-bearing and each has its own test:

The negative arm is the security test of this feature. A suite asserting only "an admin can see Bob's bill" would pass while leaking, which is why the tests also assert the resolved value is not bob, and that the ledger PATH built from it does not contain bob — the form the leak would actually take.

The reads are anchored, and never an alternation

Admin is excluded from public.searchable_schemas. Two consequences, both absolute:

So every read goes through one of two helpers, which is where the anchoring is written down once:

Helper Query Notes
ModelCreditLedger.ObservePeriodCharges path:Admin/ModelCredit/{subscriber}/{period} scope:subtree Shares the FOLD's own query id, so a bill open beside a running round is one synced subscription, not two
ModelCreditLedger.ObserveRateNodes path:Admin/ModelCredit scope:children nodeType:ModelCreditExchangeRate A LISTING, empty-on-absent — a point read of an absent node terminates the stream and opens the storm breaker on the path

Neither container in the first query is a materialized node — measured 2026-09-04, path:Admin/ModelCredit scope:children returns zero, while path:Admin/ModelCredit/mkleiner/2026-09 scope:subtree returns the four charge rows. Path-prefix scoping does not require the intermediate nodes to exist, which is also why the fold has always worked.

A fault from either read is rendered as "could not be read", never as an empty bill. An empty bill is an answer — this subscriber spent nothing — and showing it for a failed read tells someone their usage was zero when it may have been anything at all.

Currency: what is shown, and what is refused

This is the half most likely to be quietly wrong, because every wrong version of it looks fine.

Each row shows the currency it was charged in. USD 0.1193, never converted, never re-labelled. The currency lives in the cell rather than the column header, because a period may legitimately mix currencies — that is the whole of the undetermined case — and a header naming one of them turns every other row into a lie.

The period total appears only when it can be reached honestly. ModelCreditLedger.Fold converts only at a rate a human declared as a ModelCreditExchangeRate node. Where none exists for a currency in the period, there is no total, and the bill says so, names the currencies involved, and names the node an operator must create (Admin/ModelCredit/_Rate-{FROM}-{TO}). It does not show zero, and it does not invent a rate.

What was charged is always shown, even then. ModelCreditBill.ChargedByCurrency sums each currency into itself and converts nothing, so a bill in the refusing state still answers "what did this cost?" — Charged: USD 0.4677 over 4 rounds — while declining to answer "what is that worth against the allowance?". Those are different questions, and only the second one needs a rate.

Measured on memex, 2026-09-04: every charge USD, every allowance CHF, no rate node declared. So the refusing state is the live state, and the paragraph above describes what the bill shows today.

The rate, with its working shown

A declared rate is not one opaque number. The platform's standing decision is that it is a reference mid plus a margin, and the FX rate feed (ModelCreditRateFeed, Plugins#1323) records the facts separately: MidRate is observed, Margin is applied, MarginSource says where the margin came from, ObservedOn is the publication date the mid was taken from, and Rate is the derived product the fold multiplies by.

The bill shows all of them, in their own columns. Two of those columns exist because of a distinction that is easy to lose:

🚨 When the recorded mid and margin do not produce the declared rate, the disagreement is shown and the declared rate still wins. Recomputing it would move money by a number nobody wrote down — the invented rate this whole mechanism refuses — and hiding the mismatch would leave a bill whose stated derivation does not produce its stated rate, which is worse than no derivation at all.

🚨 The check is ModelCreditRateFeed.WithMargin(mid, margin) == Rate, exactly — the feed's own pure function, which rounds the stored mid first precisely so the identity holds on the node's own fields. An approximate comparison with a tolerance invented in the bill would be a second opinion about what "derived" means, and it would drift from the producer the first time the rounding scale changed.

Money and dates are text, computed in the viewer's culture

Every amount and timestamp reaches the grid as an already-formatted string, produced from host.ViewerLocale() — i.e. from AccessContext.Locale. They are deliberately not decimal / DateTimeOffset columns with a WithFormat("C2") on them, for two independent reasons:

  1. A grid format string is applied wherever the grid runs, which on Blazor Server is the container's ambient culture — identical for every simultaneous viewer and unrelated to any of them. That is exactly what AccessContext.Locale exists to replace, and the rule covers formatting, not only translated words.
  2. C2 prints the culture's own currency symbol. A USD charge would render as 0,12 € for a German viewer: not a formatting wobble but a false statement about what was charged.

So money is rendered as ISO CODE + a number in the viewer's culture — USD 0.1193 — at four decimals, because a round routinely costs less than a cent and two decimals would round most of a bill to 0.12.

Localization, and the window before the keys land

Every user-visible string is a key in core's strings.{en,de}.json. The catalog ships in the platform image, and this module can be published ahead of an image that carries the keys, while LocalizationCatalog.Get falls back to the raw KEY — so an unguarded call would print ui.modelCreditColAmount at a user. ModelCreditBillArea.Text carries the same guard ModelCreditMessages.Render uses, with the English fallback beside the key; when the keys land in core, the guard starts resolving them and the fallbacks stop being reached.

The cell-level marks are glyphs⚠️ for a round that ran under the bounded grace, for one whose token counts were estimated — so a cell needs no translation at all. The meaning lives in the localized column header.

What this deliberately does not do

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