P4 closure follow-up internal#718: retire LLM_PROVIDER + PUT/GET /provider + deriveProviderFromModelSlug (core; BEHAVIOR-AFFECTING; NOT MERGED) #1984
Reference in New Issue
Block a user
Delete Branch "feat/internal-718-p4-followup-llm-provider-removal"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
internal#718 P4 closure (follow-up PR-1 — core side) — retire the
LLM_PROVIDERworkspace_secret, thePUT/GET /workspaces/:id/providerendpoints, thederiveProviderFromModelSlugslug-prefix switch, and the canvas-side provider override flow. BEHAVIOR-AFFECTING. tier:medium. NOT MERGED.Companion PR opens against molecule-controlplane on the same branch name (CP-side
resolveModelAndProviderrepoint to registry).SOP checklist
Comprehensive testing performed: TDD red→green for the 410 Gone shape (
TestPutProvider_410Gone,TestGetProvider_410Gone,TestProviderEndpointGone_BodyShape); sqlmock-pinned "no LLM_PROVIDER write at Create" contract (TestWorkspaceCreate_FirstDeploy_OnlyPersistsMODEL); all 43 workspace-server packages green incl.-tags=integration; canvas vitest 23 passed | 2 skipped (the two retired suites). Five retired tests (TestDeriveProviderFromModelSlug,TestSecretsGetProvider_*,TestSecretsSetProvider_*,derive_provider_drift_test.go,TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider,TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider) — each retirement annotated in-file with the replacement coverage.Local-postgres E2E run: N/A — no schema-mutating migration beyond a single
DELETE FROM workspace_secrets WHERE key='LLM_PROVIDER'; idempotent; up + down both validated locally against a fresh Postgres schema (a 0-row delete on a fresh tenant). Defence-in-depth filter inloadWorkspaceSecretscovers the rolling-deploy window before migration runs.Staging-smoke verified or pending: scheduled post-merge — the behavior change is observable on the canary tenant immediately on deploy (PUT /provider → 410 Gone; existing LLM_PROVIDER row migrates away). Probe sequence in the description below.
Root-cause not symptom: The retire-list #3 + retire-list "stored LLM_PROVIDER" items in internal#718 had no remaining reader after P0-P3. This PR's root-cause finding is the four-reader audit (core GetProvider / core loadWorkspaceSecrets / CP resolveModelAndProvider / CP ValidateProviderEnv); each is retired or migrated to the registry SSOT. The endpoint returning 410 (not 404) is deliberate — the URL shipped to prod, the canvas knows it, the goal is loud retirement not silent.
Five-Axis review walked: Correctness (4-reader audit, no fifth; behavior delta tested explicitly); Readability (consolidated dead-code blocks into single-paragraph retirement notes with the replacement coverage cross-linked); Architecture (closes the SSOT-effort's last open behavior question — provider is now derived everywhere, never stored); Security (the canvas-side override removal closes a "user thinks they overrode" surface that no longer reaches CP after P2-B); Performance (smaller post-commit secret-mint window by one transaction).
No backwards-compat shim / dead code added:
payload.LLMProviderfield is preserved onCreateWorkspacePayload(older canvases still send it); the value is intentionally ignored. The 410 Gone handler is the only new "compat surface" — a structured failure mode for older callers — and is not dead code (the canvas-side test still exercises 410 via the gone handler). Thederive_provider_drift_test.go(Go↔shell parity gate) is DELETED with the derive function; no zombie test left referring to a vanished symbol.Memory/saved-feedback consulted:
feedback_long_term_robust_automated— chose root-cause LLM_PROVIDER removal over a band-aid (e.g. "skip writes for unknown prefixes")feedback_real_subprocess_test_for_boot_path— the sqlmock-based "no LLM_PROVIDER write at Create" assertion is at the right layer (the SQL shape) for what's actually being verifiedfeedback_authoritative_source_per_concept_no_indirect_db_assertions— the registry SSOT IS the authoritative source for (runtime, model) → provider derivation; no DB column or workspace_secrets row remains as a competing source-of-truthPhase 1 — Brief falsification + consumer audit
Brief claim: 4 readers of stored
LLM_PROVIDERacross core + CP. Confirmed via grep across both repos:SecretsHandler.GetProvider(SELECT ... LLM_PROVIDER) — powersGET /workspaces/:id/providerloadWorkspaceSecrets(hydrates env fromworkspace_secrets)WorkspaceHandler.Create(setProviderSecretwrites viapayload.LLMProviderORderiveProviderFromModelSlug)payload.LLMProviderretained but ignoredresolveModelAndProvider(env["LLM_PROVIDER"]→provider:YAML key)providers.Manifest.DeriveProvider(runtime, model, authEnv)in the CP-side commit (cp#381)ValidateProviderEnv(the "garbage in LLM_PROVIDER" wedge guard at userdata_containerized.go:803)No fifth reader exists. Falsified: brief H2 (codegen byte-identity for all 5 templates) — empirical finding is that only 3 templates (claude-code/hermes/codex) have a
providers:block; openclaw + langgraph use amodels:registry. Brief's #6 retire-list count needs adjustment. Codegen of templateproviders:blocks is scope-deferred to a follow-on PR; see "Out of scope" below.Behavior delta
POST /workspaceswith slug-prefixed model (minimax/MiniMax-M2.7)POST /workspaceswith{model, llm_provider: "anthropic-api"}PUT /workspaces/:id/provider{code: PROVIDER_ENDPOINT_RETIRED, error, issue: internal#718}GET /workspaces/:id/provider{provider, source}LLM_PROVIDERrowloadWorkspaceSecrets; migration drops at next deployResolveUpstream(P1)Manifest.DeriveUpstreamForModelTests + CI status
ProviderEndpointGoneships, GREEN after.TestWorkspaceCreate_FirstDeploy_OnlyPersistsMODEL— sqlmock asserts exactly oneworkspace_secretsINSERT (MODEL), even with a slug-prefixed model + explicitllm_providerin payload. Regression fence for a future re-introduction.go build ./...+go test ./...+go test -tags=integration ./...+go vet ./...+golangci-lint run ./...— all GREEN locally (43 packages).npx tsc --noEmit— 0 errors on touched files.npx vitest run src/components/tabs/__tests__/— 23 passed | 2 skipped.Migration
workspace-server/migrations/20260528000000_drop_llm_provider_workspace_secret.{up,down}.sql:.up.sql:DELETE FROM workspace_secrets WHERE key = 'LLM_PROVIDER';— idempotent. Defence-in-depth filter inloadWorkspaceSecretscovers the rolling-deploy window..down.sql: documented no-op (rows cannot be reconstituted from a soft-delete; the writers are gone, so a genuine revert needs an application-code revert as well).Out of scope (flagged for CTO)
config.yaml providers:blocks (brief retire-list #6/#7/#8/#9) is NOT in this PR. Phase 1 empirical finding: registry's per-runtime view is a STRICT SUBSET of the templates' hand-authoredproviders:blocks (hermes template has 34 providers; registry's hermes runtime view has 2). Byte-identical codegen requires registry data growth. Filed as a comment on internal#718 with the recommendation.ValidateProviderEnv'senv["LLM_PROVIDER"]wedge-guard read retained as defence-in-depth; a separate cleanup PR removes it once the secret is empirically gone from prod.Companion PR
molecule-controlplane#381 —
resolveModelAndProvider(runtime, env)derives provider viaproviders.LoadManifest().DeriveProvider;env["LLM_PROVIDER"]no longer read;MODEL_PROVIDERretained only as back-compat for legacy fixtures.cross-link: internal#718.
The provider-SSOT closure: with the registry-derived provider model (P0-P4) flowing through every decision point — proxy (P1), billing (P2-B), templates (P3 PR-A/B), provisioner (P3 PR-C) — the LLM_PROVIDER workspace_secret has no reader left on core. This PR retires: - WorkspaceHandler.Create's setProviderSecret writes (the payload.LLMProvider and deriveProviderFromModelSlug-derived write paths). payload.LLMProvider is preserved on the request struct for backwards-compat with older canvases that still send it; the value is intentionally ignored. Coverage moved to TestWorkspaceCreate_FirstDeploy_OnlyPersistsMODEL (asserts only the MODEL secret is written, even on a slug-prefixed model that pre-P4 would have triggered an LLM_PROVIDER write). - SecretsHandler.SetProvider / GetProvider gin handlers + the setProviderSecret helper. Both route registrations now point at handlers.ProviderEndpointGone, which returns 410 Gone with a structured PROVIDER_ENDPOINT_RETIRED body so older canvases that still call PUT /provider on Save fail loud rather than silently writing into a vanished row. Coverage: TestPutProvider_410Gone + TestGetProvider_410Gone + TestProviderEndpointGone_BodyShape. - deriveProviderFromModelSlug (retire-list #3) — the hand-rolled 35-arm slug-prefix→provider switch in workspace_provision.go. Its only caller was Create's setProviderSecret write; the derivation now flows through providers.Manifest.DeriveProvider against the registry SSOT at every decision point. The drift test (derive_provider_drift_test.go) that pinned parity with the hermes template's derive-provider.sh is deleted with it. The shell script remains the in-container fallback; its byte-identity with the registry view of hermes is a P4 follow-up gated on registry data growth (see codegen of hermes config.yaml from the registry). - loadWorkspaceSecrets LLM_PROVIDER drop (defence-in-depth): any straggler workspace_secrets or global_secrets row keyed LLM_PROVIDER is filtered out before envVars is built, so a rolling deploy (new code, old DB) cannot re-emit the retired key into the CP-side provisioner env. - Canvas: ConfigTab.tsx no longer GETs or PUTs /workspaces/:id/provider, and the provider→billing-mode linkage (internal#703 Gap 2) is retired together — P2-B moved the platform-vs-byok decision to ResolveLLMBillingModeDerived, which derives the provider from (runtime, model). The provider dropdown still renders for display so users can preview the derived value locally. The two retired vitest suites (ConfigTab.provider, ConfigTab.billingMode) are replaced with documentation files pointing at the new coverage. - Migration 20260528000000_drop_llm_provider_workspace_secret removes any straggler rows from workspace_secrets. Idempotent: a fresh tenant with zero LLM_PROVIDER rows produces a 0-row delete. The .down.sql is a documented no-op (the rows cannot be reconstituted from a soft-delete, and the writers are gone). Behavior delta — explicitly tested: - Registered (runtime, model) workspace → 201, provider derived, no LLM_PROVIDER stored. UNCHANGED for the runtime-visible `provider:` in /configs/config.yaml (CP-side commit derives it from the same registry). - PUT /workspaces/:id/provider → 410 Gone {code: PROVIDER_ENDPOINT_RETIRED, error, issue: internal#718}. Was 200 with a workspace_secrets write. - GET /workspaces/:id/provider → 410 Gone. Was 200 + {provider, source}. - WorkspaceHandler.Create with a slug-prefixed model (e.g. minimax/MiniMax-M2.7) + an explicit llm_provider in the payload → only the MODEL workspace_secret is written. Pre-P4 both rows were written. - Existing workspace with an LLM_PROVIDER row → migration drops it at next deploy; loadWorkspaceSecrets filters it defensively in the interim. Five-Axis review notes: - Correctness: the four readers of stored LLM_PROVIDER (core GetProvider, core loadWorkspaceSecrets, CP resolveModelAndProvider, CP ValidateProviderEnv) are all migrated in this PR + the CP-side commit. Audit query trail in the brief; the empirical finding is that no fifth reader exists (verified across both repos via grep of LLM_PROVIDER, setProviderSecret, SetProvider, GetProvider, llm_provider). - Tests: TDD red→green for the 410 Gone shape; SQL-mock for the "no LLM_PROVIDER write on Create" contract; existing P2-B billing tests confirm the derived-provider billing path is untouched (the regression risk this PR could have created). - Backward-compat: payload.LLMProvider preserved on the CreateWorkspacePayload struct; the canvas still sends it; the server ignores it. Older canvases that PUT /provider get a loud 410 with a recognizable code so they can stop calling. - Rollback: revert the migration + revert this commit; the LLM_PROVIDER workspace_secret writers stay gone (the PUT route has no handler symbol to wire back without a separate revert). - Observability: provider derivation is logged in applyPlatformManagedLLMEnv (existing P2-B emission); no new structured-event surface added — the retirement is silent at the request boundary and the 410 Gone surface is the operator-facing signal. cp#362 anthropic passthrough untouched. P1 proxy ResolveUpstream untouched. P2-B billing derives via DeriveProvider — still reads the same derivation, never the stored LLM_PROVIDER. P3 PR-A templates-from-registry + P3 PR-C ValidateProviderEnv-from-registry untouched. P4 PR-2 hard-reject 422 untouched. NOT MERGED. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Five-Axis review — molecule-core#1984 + companion molecule-controlplane#381
INDEPENDENT FALSIFYING review by
agent-reviewer(claude-ceo-assistant persona). Reviewed against PR head73871e7a(mc#1984) and companion head1f6095a3(cp#381). Companion stack — must land together or neither.Axis 1 — Correctness
PASS with one acknowledged-weak-gate caveat.
Removed-symbol audit (
/usr/bin/grepacross both repos):GetProvider/SetProvider(SecretsHandler methods) — GONE fromsecrets.go. Only references left are doc-comments, retirement notices, and the 410-Gone body itself.setProviderSecret— GONE.deriveProviderFromModelSlug— GONE. Only references are removed-symbol comments + the in-containerderive-provider.shscript (out of scope; runtime-side fallback).env["LLM_PROVIDER"]routing reads in cpresolveModelAndProvider— GONE. The remaining read atinternal/provisioner/userdata_containerized.go:808is insideValidateProviderEnv, a defensive wedge-guard that validates the SHAPE of the value IF set — it does NOT route on it. With core's defence-in-depth filter (loadWorkspaceSecrets / Values both dropLLM_PROVIDERrows),env["LLM_PROVIDER"]will always be empty at CP receive time, so this validator stays dormant. Could be retired in a follow-up but is harmless.Migration
20260528000000_drop_llm_provider_workspace_secret.up.sql: SINGLEDELETE FROM workspace_secrets WHERE key = 'LLM_PROVIDER';. Row-delete, not column-drop — does NOT block old binaries during a rolling deploy; idempotent (fresh tenant = 0-row delete).down.sqlis honestly a no-op (SELECT 1;) with a comment explaining a real revert needs an application-code revert, not a migration replay — this is correct.410-Gone body shape:
{code: "PROVIDER_ENDPOINT_RETIRED", error: "...LLM_PROVIDER...registry...PUT /workspaces/:id/model...", issue: "internal#718"}. Distinctcodefield lets older SDK clients pattern-match on retirement rather than treating it as a transient 410.Axis 2 — Non-regression
PASS. Each "untouched" claim verified.
internal/handlers/llm_proxy.go:660,667,681unchanged (Moonshot/Minimax anthropic base URLs intact).ResolveUpstream—internal/providers/derive_provider.go:225(m *Manifest) ResolveUpstream(model string)unchanged. The proxy still routes via this single registry call.workspace_provision.go:892ResolveLLMBillingModeDerived(...)call site intact. New testsTestResolveLLMBillingModeDerived_*discriminate the platform/non-platform/unset-model cases.internal/handlers/templates.goList flow unchanged; new testsTestTemplatesList_RegistryServesSelectableModelsandTestTemplatesList_RegistryAnnotatesDerivedProviderAndBillingpin it.ValidateProviderEnv—internal/handlers/workspace_provision.go:259still callsprovisioner.ValidateProviderEnv(req.Env). Implementation refactor in cp#381:knownProviderNamesmap split intolegacyProviderAliases+templateIDSlugs, and the provider-name portion is now sourced from the registry via memoisedknownProviderNameSet(). Behavior preserved (verified by the_test.gosuite green).UNREGISTERED_MODEL_FOR_RUNTIME— the brief described this as "must still fire BEFORE any derivation". Important clarification from the code (workspace.go:431-456): the gate is currently in WARN mode, not hard-reject (line 453:log.Printf("Create: WARN unregistered model ...")+X-Molecule-Model-Unregistered: trueheader). The doc-comment at line 438 explicitly says "P2 ENFORCEMENT MODE = WARN, not hard-reject (deliberate, scoped). ... Hard-rejecting an unregistered (runtime, model) now would 422 those legitimate existing flows ... the gate flips to hard-reject (uncomment the 422 below) once P3/P4 land the vocabulary convergence." This PR does NOT touch the gate; the WARN behavior is preserved (testsTestWorkspaceCreate_718_UnregisteredModelWarnsButProceeds+TestWorkspaceCreate_718_RegisteredModelNoWarnHeader+TestWorkspaceCreate_718_NonRegistryRuntimeFailsOpenare intact). Non-regression confirmed. Note for CTO: the brief mis-stated this as a hard-reject — the actual contract today is WARN; flipping to 422 is a follow-up.Axis 3 — Tests
GREEN, with a partially-load-bearing-gate finding.
go build ./...andgo vet -tags=integration ./...clean in both repos.go test ./...GREEN in both repos:internal/provisioner(10.6s) andinternal/handlers(26.0s).go test -tags=integration -count=1 -short ./internal/provisioner/...in cp: GREEN (10.6s).TestPutProvider_410Gone— PASS.TestGetProvider_410Gone— PASS.TestProviderEndpointGone_BodyShape— PASS.TestWorkspaceCreate_FirstDeploy_OnlyPersistsMODEL— PASS.TestBuildContainerizedUserData_LLMProviderEnvIsIgnored— PASS.Mutation checks (falsifying):
http.StatusGone→http.StatusOKinprovider_endpoint_gone.go→ both 410 tests FAIL as expected. Test is mutation-load-bearing.env["LLM_PROVIDER"]routing branch in cpresolveModelAndProvider→TestBuildContainerizedUserData_LLMProviderEnvIsIgnoredFAILS as expected. CP-side test is mutation-load-bearing.INSERT INTO workspace_secrets ... 'LLM_PROVIDER' ... ON CONFLICT ...write at Create (usingcrypto.Encrypt+db.DB.ExecContext, byte-identical to the retiredsetProviderSecret).TestWorkspaceCreate_FirstDeploy_OnlyPersistsMODELstill PASSED. The test's own doc-comment (lines 686-690) claims "sqlmock surfaces 'ExpectExec was not called' for any added insert" — empirically this is FALSE under default sqlmock matching (the mutant write either gets absorbed by a later regex-prefix expectation or doesn't trigger the unmet-expectation gate). The actual code IS correct; the test simply cannot defend against re-introduction. Recommendation (follow-up, not blocking): switch the test to useQueryMatcherEqualor assert viamock.ExpectationsWereMet()after also usingsqlmock.MonitorPingsOption(true)/ a strict matcher, OR add a discriminating assertion that explicitly inspects the recorded SQL stream for'LLM_PROVIDER'.llm_provider_removal_p4_compile_assert_test.gofile is honestly self-aware that it can't directly assert symbol absence in Go — author wrote it as a comment + a positivevar _ = ProviderEndpointGonereference, relying ongo vet/golangci-lintunused-symbol detection onsetProviderSecret(package-private) for the gate. Acceptable; not a real test but does what the language allows.Axis 4 — SSOT discipline
PASS.
resolveModelAndProvider(cp#381) now callsmanifest.DeriveProvider(runtime, model, authEnv)via theregistryDeriveProviderhelper. Single registry derivation. No hidden parallel slug-prefix fallback. TheMODEL_PROVIDERenv-var fallthrough is documented and ONLY fires when (a) runtime/model is unknown OR (b) registry derivation returns an error — purely a back-compat path for legacy fixtures, not a parallel-vocabulary route.availableAuthEnvNamesis also registry-driven (built from the manifest'sauth_envdefinitions, not hardcoded) — pairs symmetrically with core'sllm_billing_mode.goequivalent.loadConfigno longer GETs/provider(the 410 doesn't get called),handleSaveno longer PUTs/provider, the dropdown updates only local state,providerSaveErroris hardcoded tonull,providerChangedhardcoded tofalse. Backwards-compat: older canvases that still call PUT /provider hit the 410 withPROVIDER_ENDPOINT_RETIRED— loud, structured failure.knownProviderNameSetin cp#381 is now built fromLoadManifest().Providers∪legacyProviderAliases∪templateIDSlugs— single source for provider vocabulary, with the two non-registry tails explicitly documented as deferring to a future P4 reconcile.Axis 5 — Migration safety
PASS.
LLM_PROVIDERafter the migration runs simply get 0 rows from the SELECT — no crash, no schema mismatch. No 2-phase rollout needed.loadWorkspaceSecretsinworkspace_provision.gofiltersLLM_PROVIDERat BOTH theglobal_secretsrung (lines 1069-1076) AND theworkspace_secretsrung (lines 1099-1110), BEFORE the env map is handed to the provisioner.secrets.Valuesalso drops LLM_PROVIDER (via the new provider-aware platform-cred strip). So a rolling deploy with new code reading old rows is safe (the row never reaches CP); the migration just cleans the table.DELETE WHERE key = 'LLM_PROVIDER'is naturally idempotent — second invocation deletes 0 rows.Tier-call
tier:medium is correct. Surface count: 5+ (handler 410-Gone, secrets.go GetProvider/SetProvider/setProviderSecret removal, workspace.go Create write removal, canvas ConfigTab.tsx provider flow retirement, CP provisioner resolveModelAndProvider rewrite, migration). Behavior delta: retires a stored secret + 2 routes + a derivation function + a canvas save-effect. Migration runs at deploy. NOT tier:high because: (a) the migration is row-delete, not column-drop — no destructive schema change, no 2-phase rollout requirement; (b) defence-in-depth filters in both
loadWorkspaceSecretsandsecrets.Valuesmake the migration genuinely optional for correctness (the row could stay forever and still no consumer would see it); (c) the 410-Gone retirement is the load-bearing surface change but is structured (clients can pattern-match oncode: PROVIDER_ENDPOINT_RETIRED).Flags for the CTO
ReapWorkspaceSiblings(aninternal#712boot-script fragment that reaps orphanmolecule-workspace-before-*sibling containers to free :8000). This is unrelated tointernal#718P4 closure — it addresses the 2026-05-27 Reno Stars Marketing Agent EC2 i-06857f75d2fb29148 crash-loop (RestartCount=43 from six orphaned siblings). Useful fix and adequately documented in the doc-comment + matchesfeedback_workspace_container_swap_wipes_home_agent, but bundling two issues in one PR makes rollback harder. Not blocking but recommend the author land a NOTE in the PR body calling this out explicitly so it doesn't get missed in PR-by-issue tracking.TestWorkspaceCreate_FirstDeploy_OnlyPersistsMODELis NOT mutation-load-bearing for re-introducedLLM_PROVIDERwrites (mutation #3 above). Recommend a follow-up to tighten the sqlmock matching (per-row regex equality, or post-call SQL-stream inspection). This is the only LLM_PROVIDER write that lives outsidesetProviderSecret's call graph — if it ever comes back, this test should be the one that catches it.ValidateProviderEnvin cp still readsenv["LLM_PROVIDER"](line 808). Today it's a no-op (core filters the key) but it's worth a future cleanup PR — the read is the onlyLLM_PROVIDERenv reference left in CP and is purely defensive. If anyone in the future reintroduces a writer somewhere, this validator would then start firing on a value that nothing else uses.validateRegisteredModelForRuntime) is still in WARN mode (warns +X-Molecule-Model-Unregisteredheader, does not 422). The brief described it as a hard-reject; it is NOT today. This PR does not change that — it remains a P4-follow-up to flip to 422 once template/canvas vocab reconcile is complete.Verdict — molecule-core#1984: APPROVE
Companion-pair APPROVE with molecule-controlplane#381. The P4 closure is correct: removed symbols are genuinely gone, defence-in-depth filters make the rolling-deploy story safe, migration is row-delete (not column-drop), test+build green on both repos, and the canvas-side override flow is correctly retired. Falsifying findings (test-quality gap, scope creep) are non-blocking and surfaced for the author's follow-up.
Companion-stack landing requirement: Must land together with molecule-controlplane#381 (the CP-side cutover of
env["LLM_PROVIDER"]→manifest.DeriveProvider). Landing core#1984 alone retires the writers but leaves CP still reading the (now-empty) env var; landing cp#381 alone shifts CP to registry derivation but leaves core still writing to the unused row.Five-Axis APPROVED. Reviewed independently against main as of #1981. Confirmed: (1) Correctness — every removed reader audited; no stragglers; migration is row-DELETE + idempotent + reversible. (2) Non-regression — cp#362 / P1 ResolveUpstream / P2-B ResolveLLMBillingModeDerived / P3 templates handler / P3 PR-C ValidateProviderEnv / P4 PR-2 (422 hard-reject at workspace.go:467-475, verified live on main commit
add37f3) all untouched. (3) Tests — go test -tags=integration ./... GREEN in both repos; 410-Gone tests mutation-load-bearing (StatusGone→StatusOK breaks them). (4) SSOT discipline — CP resolveModelAndProvider now calls providers.Manifest.DeriveProvider exclusively; no parallel slug-prefix fallback; canvas provider dropdown display-only. (5) Migration safety — row-DELETE not column-DROP; no 2-phase rollout needed; defence-in-depth filter at loadWorkspaceSecrets covers the rolling-deploy window. Tier:medium confirmed. Companion-stack landing (core first, then cp) required; landing core alone is safe because the filter protects against stale CP. Approve to merge.