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:

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:

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:

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 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:

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:

  1. Decide the margin. Read the account's actual currency-conversion fee off Stripe and set ModelCredit:RateFeed:Margin to it. Leaving the default is a decision too — one the node will keep announcing as a PLATFORM DEFAULT and an ASSUMPTION to everyone who reads it.
  2. Set ModelCredit:RateFeed:Enabled=true — on AKS under config.memex_portal in the environment's values overlay (Systemorph/Memex, deployments/aks/<env>/values.*.yaml), as ModelCredit__RateFeed__Enabled — and roll the portal (a hosted service is started once). 🚨 Set it in the OVERLAY, never with a live kubectl set env: an inline env entry overrides envFrom and the next helm upgrade blanks it, which is the 2026-08-24/25 GitHub__App__* incident.
  3. Watch for [ModelCreditRateFeed] armed: … in the log, then declared 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.
  4. Confirm Admin/ModelCredit/_Rate-USD-CHF exists and Admin/ModelCredit/_RateFeed-USD-CHF reads lastOutcome: 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:

🚨 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.

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