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:
AiCatalogLayoutAreas— the scope-tabbed Providers / Models / Tiers catalogs, with create buttons.ModelProviderLayoutAreas— the provider's own page, where a key is set through a masked, write-only dialog. The stored key is shown asset ✓/not setand is never displayed or read back;ModelProviderConfiguration.ApiKeycarries[Browsable(false)]precisely because the generic property grid once rendered it in plaintext, andProviderCredentialSeedencrypts a stored plaintext key in place rather than leaving one.
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:
- Signed out resolves to
null, not"".ModelCreditLedger.Slug("")is_, so an empty subscriber would open the real containerAdmin/ModelCredit/_/{period}with no viewer identity behind it at all. - A refused request is discarded, not errored. It is also reported
(
ModelCreditBill.WasRefused) and the heading names the subscriber actually read — showing Alice's rows under a heading saying "bob" is how someone reports the wrong account's numbers in good faith. - The admin flag is a confirmed answer, never a hopeful default.
hub.IsGlobalAdminis reactive; the area seeds itfalseand only a positive answer moves it, so "we have not heard yet" and "yes" are never the same value. If that flag were optimistic, the gate would move out of a tested pure function and into a race.
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:
- An unanchored
nodeType:ModelCreditChargecannot see these rows at all, and is refused byUnanchoredQueryExceptionbesides. - 🚨 An alternation (
namespace:a|b) is worse than useless: it classifies as anchored and then takes the FAN-OUT path, where narrowing INTERSECTSsearchable_schemasandadminis dropped again. Core'sNotificationService.BellQuerydocuments this at length. A reader wanting two subjects issues two anchored reads and merges them.
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:
- The margin's provenance is not the rate's provenance. A platform DEFAULT standing in for a payment processor's fee schedule and a figure read off the account are the same number and a different fact. One column each, or a reader cannot tell them apart.
ObservedOnis notDeclaredAt. It is the publisher's own token, and it is what distinguishes a fresh observation from the source re-serving the last business day over a weekend or a holiday. A rate declared today may rest on Friday's number, and the bill says so.
🚨 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:
- 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.Localeexists to replace, and the rule covers formatting, not only translated words. C2prints the culture's own currency symbol. A USD charge would render as0,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
It does not widen access to provider nodes. Core's
StorePlansrefuses to read one even as System, commenting that the partition held plaintext keys once (2026-08-25). The bill reads no provider node; it takes the provider name from the charge row's ownproviderPath.It does not enumerate subscribers. There is no
Admin/ModelCredit scope:childrenlisting to do it with — measured 2026-09-04, that query returns zero, because the per-subscriber and per-period containers are not materialized nodes — and a full-ledgerscope:subtreescan on every render would grow with every round ever billed. So an admin types the subscriber they want, into a box that appears only for a confirmed admin.🚨 Hiding that box is not the access control.
ResolveSubscriberis: a request reaching the filter from anywhere at all — a directUpdateData, a restored data section — meets the same gate. The box is hidden from a non-admin because offering a capability the resolver will then silently refuse is a worse experience than not offering it, which is a UX reason and must never be mistaken for a security one.It does not write. No
stream.Update, no marker, no reservation. Rendering a bill must never move a number the bill is describing — which is also why it reads the charges directly rather than throughObserveConsumedWithFault, whose per-row "operator has been told" marker is a write.