Where a model's configuration lives
A model's configuration used to live in three places that could not see each other, and the symptoms — a pinned model declared unusable, a config key no template rendered, duplicate provider nodes nobody could repair, a funded key on a node and a dead one in Key Vault — were four faces of that one gap rather than four bugs. This page is the resolution: one administered home per fact, and a named path from every other place into it.
Read this before touching BuiltInLanguageModelProvider, ProviderCredentialSeed,
ChatClientCredentialResolver, ModelStaticRepoSource or ModelProviderService. The operational
procedures — set a key, rotate one, decouple a partition, the AKS Key Vault layering — are the
/provider-keys skill; this page is why they are shaped that way.
The decision
The NODE is the administered home. Deployment configuration is SEED-ONLY. Maintainer, 2026-08-22.
resolve: the node, and only the node
seed: configuration → node, converging, never read again at resolve time
One place to look, one place to rotate. That is not a preference, it is what removes a whole class of
failure: two live sources that disagree silently, which is exactly what cost an afternoon on
2026-08-21 when a funded OpenRouter key sat on the node while env and Key Vault carried a stale
unfunded one, and every full round on ns memex returned HTTP 402.
One administered source per fact
| Fact | Administered home | Bootstrap seed |
|---|---|---|
| Provider endpoint | Provider/{name} → endpoint |
{Section}:Endpoint, fill-if-absent |
| Provider API key | Provider/{name} → apiKey (enc: at rest) |
{Section}:ApiKey, fill-if-absent |
| The provider's model list | the LanguageModel children of Provider/{name} |
{Section}:Models[], fully synced |
| Model order / price / tier label | the LanguageModel node |
catalog defaults |
| Reasoning effort sent on every call | the LanguageModel node → reasoningEffort |
none — provider default until set |
| Tier registry, tier → model | Provider/Tier/{id} |
ModelTierDefaults, create-if-absent |
| A user's own key | {user}/_Memex/{provider} |
none — the user pastes it |
A deployment's {Section}:* values are the bootstrap fallback for a FRESH deployment, and nothing
else. Nothing in the resolution path reads them. ChatClientCredentialResolver walks node rungs
only — providerRef → Provider/{name} → the legacy per-model fields — and a model whose provider
node has no key is honestly unusable. (A driver factory still applies its own IOptions fallback
when it actually calls out; that is the driver's business, not a source of truth this platform
arbitrates.)
The seam that had to change: a seed allowed to run TWICE
The catalog's static-repo import marks each Provider/{name} SyncBehavior.ExcludeThisOnly, so the
importer CREATES it once and never revisits it. That claim is what protects an admin's key edit from
the next boot's re-seed — and it is also why a key added to a deployment AFTER the node existed
reached every factory and never reached the node.
That is measured, not hypothetical: on memex.systemorph.com, Provider/Anthropic was created
keyless by system-security on 2026-08-14, Anthropic__ApiKey was configured afterwards, and the
node stayed keyless until a human pasted the key in by hand on 2026-08-21. For that week the
deployment could serve claude-* rounds and the platform believed it could not.
A seeder that only runs at CREATION cannot converge that. So the credential is no longer that
seam's business at all: the import strips it (ModelStaticRepoSource.StripApiKey) and
ProviderCredentialSeed runs on every boot of a DB-synced deployment. Its rules:
- Fill-if-absent, per field. A field is written only when the node's own value is empty. An administered value — pasted, rotated, or seeded by an earlier boot — is never touched. Running twice is not merely safe, it is the point.
- Never an unprotected key. Whatever is written goes through
IProviderKeyProtector.Protectand is verified to carry theenc:tag on the exact bytes about to be persisted. - Refuse rather than downgrade. With no
Ai:KeyProtection:MasterKeythe pass reportsRefusedUnprotectedand logs at Error. The provider stays keyless and its models are honestly unusable; it does not quietly store a live credential in the clear. - One snapshot, sequential writes, then complete. It never reads its own writes — the reconcile-write-storm rule.
Two replicas booting together both run it, and that is fine: whichever writes first wins and the
other reports AlreadyAdministered. In the narrow window where both decide to write, both values
decrypt to the same key.
The read boundary — cryptographic, because nothing else can hold
Moving a secret onto a node changes its security class. A Helm value is a deployment secret; a
node's content is application data, readable by whoever can read the node — through the GUI, the
REST API, MCP, exports, sync targets and any GitSync binding on the partition.
And the Provider partition must be publicly readable. Its _Policy sets publicRead: true
because the model picker queries namespace:Provider nodeType:LanguageModel under the user's own
identity; without it RLS filters out every model and the picker is empty. On
memex.meshweaver.cloud that policy was measured still set on 2026-09-01, over seven ModelProvider
nodes.
So the question is not "who may read the node" but "what does the node hand back". Three candidate controls were examined and two of them cannot work here:
| Candidate | Why it does not hold |
|---|---|
| An access rule that hides the field | Access control in MeshWeaver is per-NODE, never per-field, and NodeTypeGate only OPENS surfaces. A policy that opens a provider's LanguageModel children opens the provider node too — the models are its children, and a policy grants at its scope and below. Structural. |
| Redaction at the read boundary | There is deliberately no per-caller content-projection seam in the mesh: a single-node read is a SHARED subscription — the owning hub broadcasts ONE node value to every subscriber on a silo, so it cannot project per reader (see UserPiiRedaction's class doc, which says exactly this and is applied at exactly one call site). Redacting only the identity-carrying surfaces would leave QueryNodes, CatalogDownload, GetVersion, Export and the whole sync path untouched — and a partial redaction is worse than none, because it reads as safety. Nor can it be done in the serializer: hub.JsonSerializerOptions is the same instance the storage adapters persist with, so a converter that dropped apiKey would blank it in Postgres. |
| Ciphertext at rest ✅ | Holds on every surface at once — MCP get, the REST read, the sync stream, export, catalog download, version history — without enumerating any of them, because the master key is deployment configuration and never node data. |
The rule, stated once: a secret's administered home may be a node; its VALUE may never be what a read returns. On this platform that is enforced by encryption, not by an access rule and not by a projection.
[Browsable(false)] on ModelProviderConfiguration.ApiKey is not part of this. It suppresses
the GUI property grid and nothing else — its own doc comment says the value still serializes for the
factory and for persistence. A GUI that stops rendering a field is not a read boundary.
The invariant, and how it is kept
ModelProvider.apiKey is enc:-tagged at rest. Always, on every path.
Two halves, and refusing to write plaintext only ever covered the first:
- Nothing new writes plaintext.
ProtectTHROWS with no master key rather than passing the value through — the passthrough is what put a live OpenRouter key in cleartext into production on 2026-08-24.NoPlaintextCredentialFallbackGuardfails the build on theprotector?.Protect(k) ?? kshape. - What is ALREADY stored converges. Refusing future writes says nothing about the past, and the
platform wrote plenty before the refusal existed: on
memex.meshweaver.cloud,Provider/AzureandProvider/AzureFoundryhave carried the same key in the clear since 2026-08-03/04. SoProviderCredentialSeed's second pass encrypts a stored plaintext key in place — the same credential, a different storage form, soUnprotectreturns exactly what it returned before and the resolver's next read serves the same key.
That second pass walks every provider node in the catalog, whether or not a catalog source still
names it. The qualifier is load-bearing: the duplicate Provider/Azure node is precisely one a
source-driven pass walks past, and duplicates are why a rotation's blast radius is not fixed but
grows with the duplication.
Its boundary is explicit. With no master key the pass can neither repair the node nor pretend it is fine, so it logs at Error naming the exposure and leaves the credential working — a repair that took chat down to fix a storage form would be worse than the exposure, and the actual answer to an exposure is a rotation.
🚨 A key that has been exposed is rotated — encrypting it is not the remedy
Re-protecting repairs the storage defect. It does not undo the window in which the value was readable. Rotate any provider key that has been stored in the clear on a publicly-readable partition, on the exposure window alone — evidence of use is not the test. The re-protect pass logs at Warning saying exactly this, per node, so an operator finds it rather than having to know.
Thinking level — a per-model datum, sent on every call
A reasoning model's provider decides how long every round thinks before its first token, and that
default is the provider's, not ours. z-ai/glm-5.3 on OpenRouter ships with reasoning always on at
max; an Executive Assistant round of nine cheap tool calls took 3 min 11 s on it, 74 s of thinking
before the first tool call, four times over — and the platform had no knob at all. The composer's
"effort" reaches the CLI harnesses only; nothing sent a reasoning effort to a provider.
The knob is reasoningEffort on the LanguageModel node — none, low, medium, high or max
(aliases minimal, xhigh). The round reads it through ChatClientCredentialResolver.ReasoningEffortFor
and sets ChatOptions.Reasoning once, in the one place the round builds its options, so every
provider call of the round — including the tool-loop iterations the function invoker makes — carries
it; the OpenAI adapter puts it on the wire as reasoning_effort, which OpenRouter forwards to the
model. Absent, nothing is sent and the provider's default stays in force, which is exactly the
pre-field behaviour. An unknown spelling parses to "send nothing" and never throws, for the reason
tier gives: a typo on a node costs the setting, never the model.
Tuning a model down is therefore a mesh edit, not a deploy:
patch @Provider/OpenRouter/z-ai/glm-5.3 {"content":{"reasoningEffort":"low"}}
Provider support varies: a model without a reasoning phase ignores the field; one whose reasoning
cannot be disabled treats none as its lowest level. Pinned by ReasoningEffortRoundTest (node →
request) and ReasoningEffortWireTest (request → wire).
What is still open
This page describes the seam. Three pieces of the administered surface are not built yet, and they are independent of everything above:
- Provenance, rendered. The resolver already tags which rung answered
(
providerRef:Provider/Anthropic); the Providers & Models admin surface does not show it. Since the configuration rung was removed the interesting question changed from "node or config" to "WHICH node" — the shared catalog, the user's own{user}/_Memex/…, or a legacy per-model field — and that is what the surface should name, per model. - Per-model health. The last round's outcome per model (OpenRouter 402 credits-exhausted, DeepSeek 410 deprecated, a 401 on a rotated key) has nowhere to live and nowhere to render, so a dead model is discovered by a user mid-thread instead of by an operator.
- Write-time consistency between a provider's dialect and its endpoint.
{ "provider": "Anthropic", "endpoint": "https://…services.ai.azure.com/anthropic/" }saves cleanly today: the wire dialect says direct-Anthropic, the address is Azure AI Foundry, and every round ends in Azure's 404.ProviderModelListernow honours a configured endpoint so listing at least exercises the host chat will call, but nothing refuses the inconsistent pair at write time.
Two known gaps in the invariant itself, both narrower than the above and both stated here so they are not rediscovered:
- The in-memory path still projects a plaintext key into served node content.
BuiltInLanguageModelProviderstamps{Section}:ApiKeyverbatim, and on that path the projection IS the served node. It is re-read from configuration on every read so it can never go stale, andModelStaticRepoSourcestrips it before anything is persisted — but on a deployment servingProviderfrom memory, the publicly-readable node carries the literal. Encrypting the projection needs a stable ciphertext per read (a fresh nonce per enumeration would churn the served node), and refusing to project it without a master key would take local development offline. That trade-off is a maintainer call, not a detail. - A raw MCP
update/patchofapiKeystores plaintext, because it does not route throughProtect. The boot pass heals it on the next restart; until then it is exposed. Healing it at write time means a reconcile that writes into the subtree it watches, which is the write-storm shape — it would have to be built with the convergence proof that rule demands.
Related
/provider-keys— the operational skill: set, rotate, decouple, the master key, the AKS layering.Doc/Architecture/PartitionSyncGuide,Doc/Architecture/StaticRepoImport— what the import claims and what "Not synced" does.Doc/Architecture/AccessControl— why a global admin has no data access, and whypublicReadon a partition is a grant rather than a cap.