The node is the administered home for a provider key; deployment config seeds it (#1982) - #2067
The node is the administered home for a provider key; deployment config seeds it (#1982)#2067rbuergi wants to merge 7 commits into
Conversation
…#1982) A model provider's credential now has ONE administered home — the ModelProvider NODE — and deployment configuration is a SEED into it rather than a parallel live source. - ProviderCredentialSeed: converging, fill-if-absent, per field. Runs on every boot instead of only at node creation, so a key configured AFTER the provider node exists reaches it (memex.systemorph.com, 2026-08-14 → 2026-08-21). - Refuses to write when the value it produced is not enc:-tagged, i.e. when no Ai:KeyProtection:MasterKey is configured — loud LogError, never a silent plaintext downgrade. - ModelStaticRepoSource strips the ApiKey from the imported content: a per-call ciphertext would destabilise the source's content fingerprint (the 2026-06-25 write storm) and the plaintext must never reach Postgres. - ChatClientCredentialResolver loses #1983's configuration rung; the seed is the cure, the rung was the compensator, and keeping both re-creates the two sources of truth it was fixing. Wiring and tests follow in this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Memex.Portal.Shared runs ProviderCredentialSeed once StaticRepoImportSettled fires, on the DB-synced Provider partition only. Typed registration so a test can see WHICH hosted service it is: a seed nothing starts is a seed that does not exist. - ProviderCredentialSeedWiringTest pins that registration (and its absence on the in-memory / Doc-only paths). - ProviderCredentialSeedDecisionTest: the pure decision table, all six outcomes. - ProviderCredentialSeedTest: a key configured AFTER the node existed converges, lands enc:v1: at rest, resolves back through the node, and a re-run reports AlreadyAdministered without rewriting. A node key differing from a stale configured one wins. Without a master key the seed REFUSES and leaves nothing at rest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DeploymentConfiguredModelUsabilityTest moves onto the DB-synced Provider partition — the shape every portal deployment runs, and the only one where a provider node exists to go stale — and boots it the way a portal does: import, then seed. The claim is unchanged and still enforced: a key that lives only in the deployment's configuration makes its models usable, and an endpoint-only provider stays unusable. Docs: the /provider-keys skill now describes a seed that converges rather than a create-if-absent stamp, names the master-key refusal, and stops implying the env var is read at resolve time. WhatsNew entry for the user-visible half. MeshWeaver.AI.Test: 1224 passed / 0 failed / 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cold resolver snapshot answers "unusable" for every model id, so asserting HasUsableCredential==false straight after EnsureSubscription passed whatever the code did — including with #1983's configuration rung restored, which was how the mutation run caught it. The test now waits until the model's provider node is in the snapshot, then asserts no key resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whichever replica writes first wins; the other reads a keyed node and reports AlreadyAdministered. In the narrow window where both decide to write, both values decrypt to the same key, so there is nothing to serialise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The credential-resolution table was already node-only (it never picked up #1983's rung); what it did not say is WHERE a deployment's configured key goes now, and that the seed is what makes a node-only chain honest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TryResolveForDefinition still described #1983's per-field merge with deployment configuration. It delegates outright now; the comment says why the seam is kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Local verification (macOS, Debug unless noted):
Note for whoever reads the runs list: |
There was a problem hiding this comment.
Pull request overview
Aligns model-provider credential handling with the #1982 decision: provider API keys are administered on the ModelProvider node, and deployment configuration becomes a one-time (per-boot) seed into that node (encrypted), rather than a parallel live resolution source.
Changes:
- Introduces
ProviderCredentialSeedto converge{Section}:ApiKey(and optional endpoint fill-if-absent) ontoProvider/{name}on boot, refusing to persist unprotected values. - Removes the deployment-configuration rung from
ChatClientCredentialResolver, restoring “node rungs only” resolution semantics. - Updates portal wiring, documentation, and tests to validate seeding, encryption-at-rest behavior, idempotence, and boot-hook registration.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.AI.Test/ProviderCredentialSeedTest.cs | End-to-end seed tests (convergence, encryption-at-rest, idempotence, node-wins). |
| test/MeshWeaver.AI.Test/ProviderCredentialSeedDecisionTest.cs | Pure decision-table unit tests for the seed’s outcomes and protection checks. |
| test/MeshWeaver.AI.Test/DeploymentConfiguredModelUsabilityTest.cs | Updates usability contract test to the new “config → seed → node-only resolve” mechanism on DB-synced shape. |
| test/Memex.Portal.Shared.Test/ProviderCredentialSeedWiringTest.cs | Ensures the hosted boot hook is registered only for the DB-synced Provider partition. |
| src/MeshWeaver.AI/ProviderCredentialSeed.cs | New seeding implementation: reads config once, snapshots provider nodes, writes fill-if-absent updates as System, refuses plaintext persistence. |
| src/MeshWeaver.AI/ModelStaticRepoSource.cs | Strips ApiKey from imported provider nodes so DB-synced imports never persist plaintext credentials. |
| src/MeshWeaver.AI/ChatClientCredentialResolver.cs | Removes configuration rung; updates docs/comments to reflect node-only resolution plus seeding. |
| src/MeshWeaver.AI/BuiltInLanguageModelProvider.cs | Clarifies in-memory projection vs DB-synced import+seed behavior for ApiKey. |
| src/MeshWeaver.AI/AgentChatClient.cs | Updates stale-model fallback comment to reference seeding (not config rung). |
| memex/Memex.Portal.Shared/StaticRepoSyncExtensions.cs | Registers and runs the seed after StaticRepoImportSettled on DB-synced path. |
| src/MeshWeaver.Documentation/Data/Architecture/ModelProviders.md | Documents the revised credential resolution model and the seed’s role. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-22-provider-keys-have-one-home.md | What’s New entry describing the single-source-of-truth behavior and refusal without master key. |
| content/ai/Skill/provider-keys.md | Updates operational skill guidance to match the new seed + node-only resolve model. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| logger?.LogWarning(ex, | ||
| "[ProviderCredentialSeed] {Path}: could not seed the API key from section '{Section}'.", | ||
| path, section); | ||
| return Observable.Return(new ProviderCredentialSeedResult( | ||
| path, section, ProviderSeedOutcome.WriteFailed, ex.Message)); |
| .SubscribeOn(System.Reactive.Concurrency.TaskPoolScheduler.Default) | ||
| .Subscribe( | ||
| r => logger?.LogInformation( | ||
| "[ProviderCredentialSeed] {Path}: {Outcome} (configuration section '{Section}').", | ||
| r.ProviderPath, r.Outcome, r.Section), | ||
| ex => logger?.LogWarning(ex, "[ProviderCredentialSeed] seed run failed.")); |
|
|
Test Results (shard 0)1 230 tests 1 230 ✅ 12m 17s ⏱️ Results for commit a74b985. |
Test Results (shard 3) 9 files 9 suites 5m 37s ⏱️ Results for commit a74b985. |
Test Results (shard 5)1 233 tests 1 232 ✅ 6m 56s ⏱️ Results for commit a74b985. |
Test Results (shard 4)2 008 tests 1 706 ✅ 7m 37s ⏱️ For more details on these failures, see this check. Results for commit a74b985. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)3 223 tests 3 223 ✅ 8m 13s ⏱️ Results for commit a74b985. |
Test Results (shard 1)1 748 tests 1 748 ✅ 9m 16s ⏱️ Results for commit a74b985. |
Test Results 55 files 55 suites 50m 0s ⏱️ For more details on these failures, see this check. Results for commit a74b985. ♻️ This comment has been updated with latest results. |
Implements the maintainer's decision on #1982:
A model provider's credential now has ONE administered home — the
ModelProvidernode — and adeployment's
{Section}:ApiKeyis a seed into it, not a parallel live source.The three steps, in the order the issue requires
1. The seed is made real first.
ProviderCredentialSeed(new,MeshWeaver.AI) runs on everyboot of a DB-synced deployment and fills the provider node's
apiKeyonly when it is empty.That is the half that did not exist: the old seam stamped the key inside
BuiltInLanguageModelProvider's static content, which the importer writes create-if-absent andnever revisits — so
Provider/Anthropic, created keyless bysystem-securityon 2026-08-14, couldnot converge when
Anthropic__ApiKeywas configured afterwards, and stayed keyless until a humanpasted the key in on 2026-08-21. A seeder that only runs at CREATION cannot fix that; this one runs
every boot.
2. Only then is #1983's configuration rung removed.
ChatClientCredentialResolverwalks noderungs and nothing else again. The rung was the right compensator for a seeder that could not
converge and the wrong long-term shape — keeping both would re-create the two-sources-of-truth
problem it was fixing. #1983's
ModelWhoseKeyLivesOnlyInDeploymentConfig_IsUsablestill passes,with the key arriving through the seed: its fixture moves onto the DB-synced
Providerpartition(the shape every portal runs, and the only one where a provider node exists to go stale) and boots
it the way a portal does — import, then seed.
3. Backfill. The seed is the backfill: an existing install with a keyless provider node and a
live configured key converges on its next boot, with no operator action. An install whose node
already carries a key (hand-pasted, rotated, or stamped plaintext by the old code) is left exactly
as it is —
Unprotectpasses untagged values through, so those keep working untouched.The two properties
IProviderKeyProtector.Protectand landsenc:v1:at rest.ModelStaticRepoSourcenow stripsthe ApiKey from the imported content, which is what makes that possible: the static source cannot
encrypt (a fresh nonce per call would change its content fingerprint on every enumeration and
re-import the catalog in a loop — the 2026-06-25 write storm), and it must not persist plaintext.
The in-memory path keeps stamping the configured key, where it is the served projection, re-read
from configuration on every read and never persisted.
IMasterKeyProvideris registered: if what we are about to persist does not carry theenc:tag,the seed refuses, logs at
ErrornamingAi:KeyProtection:MasterKey, and leaves the nodekeyless — the model then reads as honestly unusable instead of a credential sitting in Postgres in
the clear.
Wiring
Memex.Portal.Sharedstarts the seed onceStaticRepoImportSettledfires — after the import thatcreates the nodes, on the DB-synced Provider partition only. Registered by TYPE so a test can assert
which hosted service it is: a seed nothing starts is a seed that does not exist.
Tests — executed, and proven non-vacuous by mutation
MeshWeaver.AI.Test: 1224 passed / 0 failed / 3 skipped.Memex.Portal.Shared.Test: 357passed / 0 failed. Release builds of
MeshWeaver.AI+Memex.Portal.Sharedclean at-warnaserror.New:
ProviderCredentialSeedDecisionTest(the pure decision table),ProviderCredentialSeedTest(convergence, encryption at rest, idempotence, node-wins-over-stale-config),
ProviderCredentialSeedWithoutMasterKeyTest(the refusal),ProviderCredentialSeedWiringTest(theboot hook is registered).
Non-vacuity, each mutation applied to
main-plus-this-branch and reverted:A — the seed does nothing (the pre-#1982 create-if-absent world): 4 failed / 1 passed, and
DeploymentConfiguredModelUsabilityTestfails with the exact #1965 shape —B — the protection gate removed (the silent plaintext downgrade):
C — #1983's configuration rung restored:
NoMasterKey_RefusesToSeed_…fails, because the rungmakes the refused model usable again from configuration. That run also caught a vacuity in my own
test — a cold resolver snapshot answers "unusable" for every model id, so the negative assertion had
to warm the catalog first; it now does, and mutation C fails it.
Deliberately NOT fixed
holds it untagged. The seed leaves it alone (it is an administered value) and it keeps working.
Converting it in place would be a security improvement and a fragility one — a later master-key
change turns readable plaintext into unreadable ciphertext — so it wants its own decision.
ExcludeThisAndChildrenstill means a model id added to{Section}:Modelsafter the provider node exists never materialises as a child (Three AKS config keys are set in values and rendered by no template — SelfUpdate__MinRollInterval, WebhookInbox__Targets__0, AzureFoundry__Models__3 #1925/Collapse the duplicate Azure Foundry provider data on memex.meshweaver.cloud — two nodes, plus a stale label the seeder will not overwrite #1880). Itis the same claimed-subtree seam, but it is not a credential and needs the per-FIELD provenance
the issue describes.
needed by fill-if-absent — it only ever writes an empty field — and it is what a future seed that
UPDATES a field it previously set will need. One consequence worth knowing: an admin who clears
a key while configuration still holds one gets it re-seeded on the next boot.
Ai:KeyProtection:MasterKeynowends up with those providers unusable rather than silently plaintext. That is the intended
trade-off; the refusal names the fix.
Closes part of #1982 (the resolve/seed half; the admin surface and per-model health stay open).