Skip to content

The node is the administered home for a provider key; deployment config seeds it (#1982) - #2067

Open
rbuergi wants to merge 7 commits into
mainfrom
feat/1982-provider-key-seed
Open

The node is the administered home for a provider key; deployment config seeds it (#1982)#2067
rbuergi wants to merge 7 commits into
mainfrom
feat/1982-provider-key-seed

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Implements the maintainer's decision on #1982:

resolve:  the node, and only the node
seed:     config → node, first boot, never read again at resolve time

A model provider's credential now has ONE administered home — the ModelProvider node — and a
deployment's {Section}:ApiKey is 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 every
boot of a DB-synced deployment and fills the provider node's apiKey only 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 and
never revisits — so Provider/Anthropic, created keyless by system-security on 2026-08-14, could
not converge when Anthropic__ApiKey was configured afterwards, and stayed keyless until a human
pasted 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. ChatClientCredentialResolver walks node
rungs 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_IsUsable still passes,
with the key arriving through the seed: its fixture moves onto the DB-synced Provider partition
(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 — Unprotect passes untagged values through, so those keep working untouched.

The two properties

  • Write-only keys, encrypted. The value the seed writes goes through
    IProviderKeyProtector.Protect and lands enc:v1: at rest. ModelStaticRepoSource now strips
    the 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.
  • An unset master key fails LOUDLY. The check is on the produced VALUE, not on which
    IMasterKeyProvider is registered: if what we are about to persist does not carry the enc: tag,
    the seed refuses, logs at Error naming Ai:KeyProtection:MasterKey, and leaves the node
    keyless — the model then reads as honestly unusable instead of a credential sitting in Postgres in
    the clear.

Wiring

Memex.Portal.Shared starts the seed once StaticRepoImportSettled fires — after the import that
creates 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: 357
passed / 0 failed
. Release builds of MeshWeaver.AI + Memex.Portal.Shared clean at
-warnaserror.

New: ProviderCredentialSeedDecisionTest (the pure decision table), ProviderCredentialSeedTest
(convergence, encryption at rest, idempotence, node-wins-over-stale-config),
ProviderCredentialSeedWithoutMasterKeyTest (the refusal), ProviderCredentialSeedWiringTest (the
boot 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
DeploymentConfiguredModelUsabilityTest fails with the exact #1965 shape —

Expected the observable to emit a value matching the predicate within 30s because the deployment's
configured key serves this provider's models, but it did not. Last of 600 emission(s) was:
CredentialResolution { Endpoint = https://probe.example/anthropic/v1/messages, ApiKey = ,
Source = providerRef:Provider/AnthropicProbe }.

B — the protection gate removed (the silent plaintext downgrade):

ProviderCredentialSeedDecisionTest.WithoutProtection_TheWriteIsRefused_NotDowngraded [FAIL]
  Expected value to be RefusedUnprotected, but found Seeded.
ProviderCredentialSeedWithoutMasterKeyTest.NoMasterKey_RefusesToSeed_RatherThanPersistingPlaintext [FAIL]
  Expected value to be RefusedUnprotected because no master key means Protect() is a passthrough —
  writing would store the credential in the clear, which must fail loudly rather than downgrade
  quietly, but found Seeded.

C — #1983's configuration rung restored: NoMasterKey_RefusesToSeed_… fails, because the rung
makes 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

  • Plaintext keys already at rest. A node whose key was stamped by the old create-if-absent import
    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.
  • The frozen MODEL LIST. ExcludeThisAndChildren still means a model id added to
    {Section}:Models after 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). It
    is the same claimed-subtree seam, but it is not a credential and needs the per-FIELD provenance
    the issue describes.
  • Per-field provenance and the admin surface ("which source answered", per-model health). Not
    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.
  • A DB-synced deployment with configured provider keys and no Ai:KeyProtection:MasterKey now
    ends 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).

rbuergi and others added 5 commits August 22, 2026 12:32
…#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>
Copilot AI lite review requested due to automatic review settings August 22, 2026 10:59
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>
@rbuergi

rbuergi commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Local verification (macOS, Debug unless noted):

Suite Result
MeshWeaver.AI.Test 1224 passed / 0 failed / 3 skipped
Memex.Portal.Shared.Test 357 passed / 0 failed
MeshWeaver.Threading.Test 135 passed / 0 failed
MeshWeaver.AI + Memex.Portal.Shared build clean, -c Release -warnaserror

MeshWeaver.Hosting.Monolith.Test was still running locally when this was posted; CI covers it.

Note for whoever reads the runs list: main-cd.yml fails at 0s on every push here, including
pushes to main and to unrelated branches (fix/1782-entitlement-anchor,
docs/follow-release-all-repos). Pre-existing and unrelated to this PR — it is not one of this PR's
checks.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ProviderCredentialSeed to converge {Section}:ApiKey (and optional endpoint fill-if-absent) onto Provider/{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.

Comment on lines +317 to +321
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));
Comment on lines +179 to +184
.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."));
@rbuergi

rbuergi commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

MeshWeaver.Hosting.Monolith.Test (the one still running when the table above was posted): 754 passed / 0 failed / 3 skipped, 9m19s. Local verification is complete and green across all four suites.

@github-actions

Copy link
Copy Markdown

Test Results (shard 0)

1 230 tests   1 230 ✅  12m 17s ⏱️
    9 suites      0 💤
    9 files        0 ❌

Results for commit a74b985.

@github-actions

Copy link
Copy Markdown

Test Results (shard 3)

    9 files      9 suites   5m 37s ⏱️
1 751 tests 1 747 ✅ 4 💤 0 ❌
2 233 runs  2 229 ✅ 4 💤 0 ❌

Results for commit a74b985.

@github-actions

Copy link
Copy Markdown

Test Results (shard 5)

1 233 tests   1 232 ✅  6m 56s ⏱️
   10 suites      1 💤
   10 files        0 ❌

Results for commit a74b985.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Test Results (shard 4)

2 008 tests   1 706 ✅  7m 37s ⏱️
    9 suites    300 💤
    9 files        2 ❌

For more details on these failures, see this check.

Results for commit a74b985.

♻️ This comment has been updated with latest results.

@github-actions

Copy link
Copy Markdown

Test Results (shard 2)

3 223 tests   3 223 ✅  8m 13s ⏱️
    8 suites      0 💤
    8 files        0 ❌

Results for commit a74b985.

@github-actions

Copy link
Copy Markdown

Test Results (shard 1)

1 748 tests   1 748 ✅  9m 16s ⏱️
   10 suites      0 💤
   10 files        0 ❌

Results for commit a74b985.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Test Results

    55 files      55 suites   50m 0s ⏱️
11 193 tests 10 886 ✅ 305 💤 2 ❌
11 675 runs  11 368 ✅ 305 💤 2 ❌

For more details on these failures, see this check.

Results for commit a74b985.

♻️ This comment has been updated with latest results.

@rbuergi
rbuergi enabled auto-merge August 22, 2026 12:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants