Keeping the exchange rate current
A currency the meter can fold (AI/ModelCreditCurrency) gave the model-credit meter a declared
conversion route: an operator writes Admin/ModelCredit/_Rate-USD-CHF and the meter folds the
period at that number. That page is deliberate that the declaration is an accounting policy number,
not a market feed — and it left one thing to a human's memory: the number goes stale, and nothing
says so.
This page is the other half. A deployment can now arm a daily download that keeps that node current from a published reference feed, and it does so without weakening a single one of the meter's refusals: nothing here derives an inverse, chains two declarations, drops a row, or ever falls back to a rate somebody hard-coded.
Status: SHIPPED, DISARMED. §1 is what it writes, §2 the margin, §3 tax, §4 the trigger and the four alternatives, §5 what a weekend and an outage look like, §6 how to turn it on.
1. What it writes
ModelCreditRateFeedService reads the ECB's daily reference rates —
https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml
— which is a EUR-based table, and derives the direction the meter needs. Measured 2026-09-04,
publication date 2026-09-03: EUR/USD 1.1615, EUR/CHF 0.9390, so the mid-market
USD → CHF is 0.808437 (cross-checked against a second provider, exchangerate-api at
0.808206 — 0.03% apart — before it was written into the tests).
It then writes the SAME node an operator would write by hand, at the SAME path the meter's own error message names:
// Admin/ModelCredit/_Rate-USD-CHF nodeType: ModelCreditExchangeRate
{ "$type": "ModelCreditExchangeRate",
"from": "USD", "to": "CHF",
"rate": 0.824606,
"midRate": 0.808437,
"margin": 0.02,
"marginSource": "PLATFORM DEFAULT (maintainer decision, 2026-09-04): 2%, loaded in the platform's favour … It remains an ASSUMPTION about this account's fee schedule …",
"observedOn": "2026-09-03",
"source": "ECB eurofxref-daily 2026-09-03 · mid 0.808437 × (1 + 2.000% margin) = 0.824606",
"declaredAt": "2026-09-04T06:00:00Z" }
rate is still the one load-bearing field — ModelCreditLedger.ExchangeRatesIn reads from, to
and rate, and nothing else, exactly as before. The four new fields are audit copy: they exist
so the number can be taken apart, and a hand-written declaration that leaves them empty is as valid
as it ever was.
Deriving a cross from a EUR table is not the "cross-rate" the meter refuses
These look alike and are not the same thing, so it is worth being explicit.
The meter refuses to chain two declarations: a EUR → USD node beside a USD → CHF node does
not make EUR → CHF. That product is a number nobody wrote down, and the two declarations may have
been taken on different days, from different sources, by different people — the composition has no
owner and no date.
Here, both legs come from one publication of one source, quoted at one instant, and the division is the standard derivation the publisher documents for its own reference rates. The output is one number, in one direction, on one node — and it is still the operator's declaration that this source may be used at all, at this margin. Nothing about the meter changes: it reads a node a human caused to exist and applies the number on it.
Everything the meter already refused, it still refuses:
- No inverse. The feed declares
USD → CHF.CHF → USDremains undeclared, andExchangeRatesInwill not derive it (pinned byModelCreditRateFeedTest.TheDeclarationLandsOnTheNodeTheMeterReads). - No dropped rows. The feed touches no charge. A period the feed cannot help with is still
UNDETERMINED, and every charge keeps the currency the round actually cost. - 🚨 No fallback, anywhere. There is not one hard-coded rate in the implementation. A fetch that fails, a document that does not parse, or a publication with no quote for the pair all end in "record the failure, write nothing" — §5.
2. The margin, stored beside the rate and never inside it
The platform pays its model provider in USD (OpenRouter publishes USD) and collects in CHF
through Stripe, which charges a currency-conversion fee. A meter that folded the period at the
bare mid-market rate would therefore under-count what the conversion actually costs the platform —
so the declared rate carries a margin.
It is not one opaque number. Three separate, checkable values go onto the node, and the fourth says where the margin came from:
| Field | What it is |
|---|---|
midRate |
The mid-market cross as published, at six decimals. |
margin |
The fraction added on top (0.02 = 2%). |
marginSource |
Where that fraction came from, in words. |
rate |
The derived product the meter actually uses. |
rate = round(midRate × (1 + margin), 6) holds exactly, and it holds against the stored mid
rather than an unrounded quotient — deliberately, so an auditor holding only the node can recompute
the number and get the same answer. (Rounding at the sixth decimal of a ~0.8 rate moves it by about
one part in a million, four orders of magnitude below the margin it sits under; the reproducibility
is the point, the tie rule is not.)
🚨 The sign: LOADED IN THE PLATFORM'S FAVOUR
The margin is 2%, loaded in the platform's favour (maintainer decision, 2026-09-04). Getting that direction wrong is silent — the node would still carry three self-consistent numbers, every other case would still pass, and the platform would quietly bill less than the round cost — so it is worth stating in one sentence and pinning with one example.
ModelCreditLedger.Fold converts a FROM-denominated charge INTO the TO currency by
multiplying: total += charge.Amount * rate. So a rate above the mid makes a given round
consume more of the allowance, and that difference is what covers the conversion fee.
WithMargin therefore multiplies by (1 + margin) and never divides, and never subtracts.
The maintainer's worked example, encoded as ModelCreditRateFeedTest.TheLoadedRateDeductsMoreThanTheMid:
| A USD 0.75 round | deducts |
|---|---|
at the mid 0.808437 |
CHF 0.6063 |
at the loaded 0.824606 |
CHF 0.6185 |
The test asserts both figures and the inequality between them, through the real fold rather than through arithmetic of its own — so no future rounding change can hide an inverted sign.
🚨 2% is a DECISION, and still an ASSUMPTION — the node says both
Stripe's conversion fee depends on the account's own schedule — commonly 1% domestic / 2% cross-border — and this platform's Stripe account has not been read. So 2% is a decision taken against the published range, not a figure lifted off the account, and nothing pretends otherwise:
- the constant is named
ModelCreditRateFeed.PlatformDefaultMargin; - every node the feed writes carries
marginSourcebeginningPLATFORM DEFAULT, naming the decision and its date, saying in full that it remains anASSUMPTIONabout the account's fee schedule, and naming the key that replaces it; - the moment an operator sets
ModelCredit:RateFeed:Margin,marginSourcebecomes that key instead.
So "somebody chose this", "somebody verified this against the account" and "nobody
looked" are three distinguishable states, on the node, for anyone, for ever. That is the whole
reason the three numbers stay separate: midRate is observed, margin is decided, rate is
derived. Collapsing them into one figure would make the decision unreviewable — which is why a
changed default is a changed default and never a re-baked constant.
A margin an operator changes takes effect on the next cycle, even though the publication date has
not moved (§5) — and the re-declaration is honest about it: observedOn still names the publication
the number came from; only declaredAt says when we decided it.
3. Tax is NOT in here, and this is where it belongs instead
🚨 VAT — or any other tax — is deliberately not folded into the FX rate, and must not be.
The FX rate answers one question: how many CHF is one USD of provider cost worth to this platform. The 2% margin belongs there because it scales with the conversion — it is Stripe's fee, charged on the act of converting. A tax does not:
- It is charged on a different transaction. VAT is charged on the sale of the plan to the customer, not on what the platform pays OpenRouter. The two amounts are not the same number and do not move together.
- It is not the platform's cost. VAT collected is remitted to the tax authority; it is a pass-through, not consumption of the customer's model credit. Folding it into the rate would inflate every charge against the allowance by a percentage that has nothing to do with the round.
- It would misstate both numbers. The FX rate would no longer be an FX rate (so nobody could check it against a published one), and the tax would no longer be a visible line (so nobody could check it against an invoice).
Where it belongs: on the plan's price and its invoice — the Store's subscription/checkout path
and Stripe's own tax settings — as its own line, computed on the sale. If a deployment ever needs
the meter to know about tax, that is a change to what a plan's includedModelCredit means
(gross or net), argued on the Store's Subscriptions page. It is not a change to an exchange rate.
4. The trigger: a hosted service with a timer
ModelCreditRateFeedService is an IHostedService registered by the AI module. Past a startup
delay it runs a cycle, then one every interval, through Observable.Timer(…).Select(Defer).Concat()
so cycles can never overlap. The HTTP leaf runs inside the Http IIoPool; every read and write
runs under RunAsSystem, because the Admin partition is exactly the place no user's identity
reaches.
Four alternatives were considered, and each fails on something specific.
An EveryLogon logon action — the shape Notification Retention
(Doc/Architecture/NotificationRetention) chose, and the closest precedent — fights its own
contract here, and not merely because it is a poor fit:
- A logon action runs as the signing-in person.
Doc/Architecture/LogonActionsis explicit: it runs under that user's identity, "neversystem-securityand never a hub address", with exactly one sanctioned exception — reading its own declarations out ofAdmin, where an ordinary user has no standing grant. This is a write intoAdmin, a partition that exists precisely so a subscriber can neither write it nor read it (the same unforgeability argument asModelCreditCharge). So the action would have to run as System on every logon — the identity that page rules out, and the property that makes retention safe: there, the only rows it can reach are ones that person could delete by hand. - It is also the wrong trigger. This writes ONE global node that has nothing to do with who signed in; every person signing in would re-race it; and a portal nobody signs into would leave the rate stale for ever — while its rounds, which agents and MCP clients can still start, keep being metered against it.
A startup Job runs once per deploy, not daily — and, as #3250 states first, a maintenance task
must never be able to stop a portal serving.
A Code MeshNode operation (form-bound inputs + RequestedStatus = Running + live progress) is
the house pattern for work a person runs, and it is the right answer for a one-off correction. It
is the wrong answer for the recurring pass, for the same reason retention gave: a number that only
refreshes when somebody remembers to press Run does not refresh, and "the operator will keep it up
to date" turning into "nobody did" is the entire defect. There are no inputs a person supplies, no
progress worth watching, and no output anyone reads.
A Kubernetes CronJob (the shape assembly-cache-prune uses) is a real option and was rejected on
cost and correctness, not on principle: it would need mesh write credentials from outside the portal
— the only alternatives being a raw psql write, which bypasses the workspace cache and is banned,
or a second authenticated HTTP surface — plus a chart change adopted separately in every namespace,
to do work the portal already holds the identity, the workspace cache and the I/O pool for. Cluster
housekeeping belongs in the chart; a product behaviour of the credit meter belongs with the
meter.
🚨 The two standing objections to timers do not reach this one
They are both real, and both are about something this is not.
#3250's objection is that a retention timer has no reader to scope it to, so it must enumerate partitions and sweep them as System — and there is no partition enumeration API by design, so it becomes an unbounded cross-schema pass run by nobody's identity. Nothing here is enumerated. One known path, in one partition, once a cycle, whatever the deployment's size: the work is O(1) in the number of users, spaces and schemas.
RegistryUpdateReconciler's objection is that "a timer answers 'how stale am I willing to be',
which is a question nobody asked" — true there, because a restart already IS the fan-out: plugin
content and the framework image ship from one CI, so a green build and a pod roll arrive together.
Here that question is the requirement. The observation is published on somebody else's clock, and
no event on this deployment announces it. A restart is not the fan-out; there is no fan-out.
The living precedent for exactly this shape is OpenAICompatibleModelSync in this same repo: a
deferred first run, a timer with Concat, an HTTP leaf in the I/O pool, writes as System, and an
empty or failed fetch that changes nothing.
Why the interval is six hours for a "daily" rate
The ECB publishes around 16:00 CET. A once-per-24h timer anchored to an arbitrary pod start would,
for half of all start times, systematically read the previous day. Four polls a day cost four HTTP
requests and — because a write happens only when the publication's own date moves (§5) — at most
one write. The knob is ModelCredit:RateFeed:IntervalHours, floored at 1.
5. A weekend, a holiday, and an outage
The ECB publishes on TARGET business days only, and in between the feed keeps serving the last published day. Two rules follow, and both are tested.
An unchanged publication writes NOTHING. The decision is
ModelCreditRateFeed.ShouldDeclare(current, target), which compares everything an auditor reads —
rate, midRate, margin, marginSource, observedOn, source, and the direction — and
deliberately not declaredAt, which moves on every cycle by construction. So:
- a Saturday cycle sees Friday's date, matches, and writes nothing at all;
- the node's version does not move (two cycles a day for a year would otherwise leave 730 versions
of one unchanged number — the
_Grace-2026-09at version 330 shape, again); - 🚨 and, the part that would corrupt the audit trail, no fresh
declaredAtis stamped claiming an observation that was never made. A later reader can tell a weekend from a market move.
An unchanged cycle is a SUCCESS. It clears the failure counter, exactly like a written one. A feed that only reset on a write would report every Monday holiday as a two-day outage.
🚨 A publication that has gone BACKWARDS is refused. "Different" and "newer" are not the same
question, and for a number the platform bills against the difference matters: a CDN or proxy serving
a cached document, or a publisher briefly re-serving an earlier day, would otherwise replace today's
rate with yesterday's and stamp a fresh declaredAt — recording an observation we never made, in
the wrong direction. ModelCreditRateFeed.IsRegression compares the two ISO date tokens and the
cycle records a failure rather than writing; a failure, not a quiet "unchanged", because the rate
really is not being refreshed and that has to reach the staleness counter. Same-date re-declaration
(a margin an operator changed) is not a regression and stays allowed, and a token that is not the
ISO yyyy-MM-dd the feed publishes is not compared at all — comparing an unknown format
lexicographically against a known one would be a rule nobody declared.
A failed fetch changes no money. The declaration is left byte-for-byte as it was — the meter never expires a declaration, so the last good rate keeps applying, which is the intended behaviour and not a bug — and the failure is recorded on a separate node:
Admin/ModelCredit/_RateFeed-USD-CHF nodeType: ModelCreditRateFeedState
It carries lastAttemptedAt, lastSucceededAt, lastObservedOn, lastMidRate,
lastDeclaredRate, lastOutcome (Declared / Unchanged / Failed), lastError and
consecutiveFailures. It is a sibling of the declaration and never part of it, because folding
the failure onto the rate node would mean versioning the node whose stability is the requirement —
the same shape as RegistryUpdateReconciler's _RegistryReconcileLedger. Nothing on it is money,
and ExchangeRatesIn never looks at it.
🚨 lastSucceededAt is the staleness signal, and it is the only one there is. The meter has no
expiry by design (an expiring rate would turn an operator's holiday into a fleet-wide refusal), so
a rate that stopped refreshing keeps applying silently. The age of lastSucceededAt, and
consecutiveFailures beside it, is what an operator watches — that is what this node is for. Each
failure also logs a warning naming both paths.
6. Operating it
🚨 It is OFF until a deployment arms it, and that is the opposite of the choice
NotificationRetention made — deliberately. Retention arms itself because a wrong answer deletes a
three-month-old pointer to a node that still exists. Here a wrong answer writes a number the
platform bills against, at a margin nobody has verified. Arming it is the operator's act, taken at
the same moment they choose the margin.
| Key | Default | What it does |
|---|---|---|
ModelCredit:RateFeed:Enabled |
false |
Arms the feed. |
ModelCredit:RateFeed:Margin |
0.02 (platform default, unverified against the account) |
The fraction added to the mid rate, loaded in the platform's favour. |
ModelCredit:RateFeed:Pairs |
USD:CHF |
Comma-separated FROM:TO directions. |
ModelCredit:RateFeed:Url |
ECB eurofxref-daily.xml |
The feed document. |
ModelCredit:RateFeed:IntervalHours |
6 |
Cycle interval, floored at 1. |
ModelCredit:RateFeed:InitialDelaySeconds |
60 |
Delay before the first cycle. |
Every typed key is read as a raw string and parsed, never through GetValue<bool>/<int>: the
Helm ConfigMap renders every allow-listed key even when unset, emitting an empty string, and the
framework's typed readers throw on "" before their own default applies — which kills host startup.
Empty means absent, exactly as an un-rendered key does.
🚨 But a value an operator actually TYPED is never rounded to a default. Absent means "take the shipped default"; a non-blank value that is not a value means they intended something, and each of these would otherwise look exactly like success:
| Typo | What a silent default would do |
|---|---|
Margin: "0,015" (a decimal comma) |
Arms at the 2% default and writes a billing rate nobody chose |
Pairs: "USD-CHF" (a dash) |
Silently maintains the DEFAULT pair instead of the intended one |
Enabled: "yes" |
Leaves the feed inert with the knob apparently set |
So an unusable value becomes ModelCreditRateFeedOptions.ConfigurationError, and the service
refuses to arm, logging one error naming the key and the value. That check runs before the
Enabled one, because the commonest way to get here is a typo in Enabled itself — and "the knob
is set and nothing happens" is precisely the failure this branch exists to make impossible. Nothing
is fetched, nothing is written, and whatever is declared keeps applying.
🚨 On AKS the chart must RENDER these keys, or setting them does nothing. The portal's ConfigMap
template (deploy/helm/templates/memex-portal/config.yaml in the platform repo) names every key
explicitly, and the Deployment's only env path is envFrom — so a values file that sets a key the
template does not render produces nothing, silently. That is the Memex#53 shape the file's own
comments record ("it kept vanishing" — three AKS values files, none of which could reach a
container), and here it would be the worst available failure: Enabled=true set, key never
delivered, feed reads absent, stays disarmed, and the knob looks set. The six keys are rendered
by Systemorph/MeshWeaver#3289; they default to "", which is what this reader treats as absent, so
they change nothing for a deployment that does not set them.
To turn it on:
- Decide the margin. Read the account's actual currency-conversion fee off Stripe and set
ModelCredit:RateFeed:Marginto it. Leaving the default is a decision too — one the node will keep announcing as aPLATFORM DEFAULTand anASSUMPTIONto everyone who reads it. - Set
ModelCredit:RateFeed:Enabled=true— on AKS underconfig.memex_portalin the environment's values overlay (Systemorph/Memex,deployments/aks/<env>/values.*.yaml), asModelCredit__RateFeed__Enabled— and roll the portal (a hosted service is started once). 🚨 Set it in the OVERLAY, never with a livekubectl set env: an inline env entry overridesenvFromand the nexthelm upgradeblanks it, which is the 2026-08-24/25GitHub__App__*incident. - Watch for
[ModelCreditRateFeed] armed: …in the log, thendeclared USD → CHF at …about a minute later.[ModelCreditRateFeed] refusing to arm: …instead means a key was typed but is not a value — the line names which. - Confirm
Admin/ModelCredit/_Rate-USD-CHFexists andAdmin/ModelCredit/_RateFeed-USD-CHFreadslastOutcome: Declared. From that moment the meter folds the period at the declared rate on its next read — nothing restarts, because the rates are read as a live query beside the charges.
A deployment that leaves it off is unaffected, and a hand-written declaration keeps working
exactly as AI/ModelCreditCurrency describes. 🚨 But note that when the feed IS armed for a pair, it
owns that node: a hand edit to _Rate-USD-CHF is replaced on the next cycle whose publication or
margin differs from it. Correct the margin, not the node.
7. Internationalization
There are no user-visible strings in this feature, and that is a property worth stating rather than an omission. What it writes is machine-facing or audit copy:
- the two node names are currency codes, an arrow, a number and a glyph
(
USD → CHF 0.824606,USD → CHF ⟳) — language-neutral by construction, so there is nothing to keep in step across two catalogs; source,marginSourceandlastErrorare audit records — a provider identity, an ISO date, a config key, an exception message — the same class ascreatedBy. Translating them would make two deployments' audit trails incomparable, and a German reader checking a rate against the ECB's own publication needs the publisher's own spelling;- log lines are never localized.
🚨 If a future change surfaces any of this in the UI — an admin tab showing the feed's health, a
"rate refreshed" toast — every string in it needs a key in both strings.en.json and
strings.de.json, plus the mirror in the React client's catalog.
Where this is pinned
| Claim | Test |
|---|---|
| The publication's own date and the measured mid-market cross | ModelCreditRateFeedTest.TheEnvelopeParsesToItsOwnDate_AndTheMeasuredMidMarketCross |
| The base currency is quoted at 1, so EUR is not a hole | …TheBaseCurrencyIsQuotedAtOne |
| A missing quote is no rate, and no declaration | …AMissingQuoteIsNoRate |
| A non-envelope is not a partial observation | …ADocumentThatIsNotAPublicationIsNotAnObservation |
rate = midRate × (1 + margin), recomputable from the node |
…TheDeclaredRateIsTheStoredMidTimesTheMargin_AndBothSurvive |
| The shipped margin names itself a platform default AND an assumption; a set one names its key | …TheShippedMarginNamesItselfAPlatformDefaultAndAnAssumption, …AConfiguredMarginNamesItsKeyInstead |
| 🚨 The SIGN: a USD 0.75 round deducts CHF 0.6063 at mid and CHF 0.6185 loaded, and loaded > mid | …TheLoadedRateDeductsMoreThanTheMid |
| An empty Helm-rendered key means absent | …AnEmptyConfigValueMeansAbsent |
| Disarmed until a deployment arms it | …TheFeedIsDisarmedUntilADeploymentArmsIt |
A weekend writes nothing, and stamps no new declaredAt |
…AnUnchangedPublicationDeclaresNothing, ModelCreditRateFeedNodeTest.AnUnchangedPublicationWritesNothing |
| A moved publication is a new observation | …AMovedPublicationIsANewObservation |
| A changed margin re-declares without claiming a new observation | …AChangedMarginRedeclares_WithoutClaimingANewObservation |
| An unchanged cycle clears the failure run | …AnUnchangedCycleIsASuccessAndClearsTheFailureRun |
| A failure keeps the last success and counts the run up | …AFailedCycleKeepsTheLastSuccessAndCountsTheRunUp |
| A backwards publication is a regression; forward and same-day are not | …AnOlderPublicationIsARegression |
| A non-ISO date token is not compared at all | …AnUnparseableDateTokenIsNotComparedAtAll |
A typed-but-unusable Enabled/Margin/Pairs refuses to arm, naming key and value |
…AnUnusableConfigValueRefusesToArm |
| …and a blank or valid one is not an error (the control for the row above) | …ABlankOrValidConfigValueIsNotAnError |
| On a real mesh: a backwards publication is refused and recorded | ModelCreditRateFeedNodeTest.AnOlderPublicationIsRefused_AndRecorded |
| The declaration lands where the meter reads, and no inverse appears | …TheDeclarationLandsOnTheNodeTheMeterReads |
| The feed state is its own node, invisible to the rate table | …TheFeedStateIsItsOwnNode |
| On a real mesh: the meter folds the period at the declared rate | ModelCreditRateFeedNodeTest.ASuccessfulCycleDeclaresTheRate_AndTheMeterFoldsAtIt |
| On a real mesh: a failed fetch leaves the declaration intact and records the failure | …AFailedFetchLeavesTheDeclarationIntact_AndRecordsTheFailure |
| On a real mesh: a feed that never succeeded declares nothing, and the meter still refuses | …AFeedThatNeverSucceededDeclaresNothing |
Each behaviour that could be silently lost was checked by reverting it: making the margin inert reddened 7 cases, removing the unchanged-publication gate reddened exactly the 2 weekend cases, making a failed fetch fall back to a hard-coded rate reddened exactly the 2 no-fallback cases, disabling the backwards-publication check reddened exactly its 2 cases, and letting an unusable config value fall back to a default reddened exactly its 6. A test that cannot fail is not a test.
One guard is deliberately NOT claimed as pinned. WriteDeclaration re-takes the write decision
inside the update lambda and reports Unchanged when another pod had already written the same
target, so the fact is recorded once across the fleet rather than N times. That race needs two
concurrent writers and cannot be forced deterministically in a test; what IS pinned is the decision
function both sides call, ShouldDeclare. Saying otherwise would be prose asserting a guard nobody
checks.