From 6fe44cd19bbf3dfb73d7ed71939660e7cbeb2f16 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 22:43:44 -0700 Subject: [PATCH 1/4] Phase 7 Step 3a: partition runtime config into supported vs internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the public documentation split of v2's runtime config that Phase 1A deferred. No behavior change. - src/config.ts: declare SUPPORTED_RUNTIME_CONFIG_FIELDS (39 fields — infrastructure, provider/model selection, operator runtime, major feature toggles, cost/cache ops) and INTERNAL_POLICY_CONFIG_FIELDS (66 fields — thresholds, tuning knobs, experimental toggles). Derive SupportedRuntimeConfig / InternalPolicyConfig as Pick<> slices of RuntimeConfig so the split is type-level documentation without constraining threading. - src/__tests__/config-partition.test.ts: new fence asserting the two partitions are disjoint, cover every RuntimeConfig field on the singleton, and reference no stray keys. Any new RuntimeConfig field must be tagged into one bucket or the test fails — prevents silent drift of the public contract. - src/index.ts: re-export the two arrays and four derived types from the root package so consumers can reference SUPPORTED_RUNTIME_CONFIG_FIELDS as the authoritative list of what's stable. - docs/consuming-core.md: new "Config surface: supported vs experimental" section pointing at the authoritative arrays and explaining the stability contract. 961/961 tests pass (957 before + 4 new partition tests). tsc --noEmit clean. fallow --no-cache 0 above threshold. Co-authored-by: Claude Opus 4.7 (1M context) --- docs/consuming-core.md | 26 +++++++ src/__tests__/config-partition.test.ts | 47 +++++++++++++ src/config.ts | 96 ++++++++++++++++++++++++++ src/index.ts | 14 +++- 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/config-partition.test.ts diff --git a/docs/consuming-core.md b/docs/consuming-core.md index 28b5fe2..d28935f 100644 --- a/docs/consuming-core.md +++ b/docs/consuming-core.md @@ -102,6 +102,32 @@ to treat core exactly as it ships. migration convenience and will be narrowed. Research should prefer the root export and raise an issue if something it needs is missing. +## Config surface: supported vs experimental + +Runtime config is split into two contracts. The split is documented in +`src/config.ts` via `SUPPORTED_RUNTIME_CONFIG_FIELDS` (39 fields) and +`INTERNAL_POLICY_CONFIG_FIELDS` (66 fields). A partition test +(`src/__tests__/config-partition.test.ts`) enforces disjointness and full +coverage — any new `RuntimeConfig` field must be tagged into one bucket. + +- **`SupportedRuntimeConfig`** — fields with a stable contract. Consumers may + rely on their semantics, defaults, and presence. Breaking changes go through + a documented deprecation cycle. This is where infrastructure (database, + port), provider/model selection (embedding, LLM, cross-encoder), and major + feature toggles (entity graph, lessons, repair loop, agentic retrieval, etc.) + live. +- **`InternalPolicyConfig`** — experimental / tuning flags. Thresholds, scoring + weights, MMR/PPR lambdas, staging internals, affinity-clustering knobs, + entropy-gate parameters, composite-grouping parameters, etc. **No stability + guarantee.** These may be renamed, re-defaulted, or removed between minor + versions. Consumers must not persist values in deployment configs expecting + them to remain meaningful. Promoted to the supported set when a field's + behavior stabilizes. + +Both types are re-exported from the root package. Docs, code review, and +release notes should reference `SUPPORTED_RUNTIME_CONFIG_FIELDS` as the +authoritative list of what's stable. + ## What belongs in research, not core Research harnesses, benchmarks, eval runners, experimental retrieval diff --git a/src/__tests__/config-partition.test.ts b/src/__tests__/config-partition.test.ts new file mode 100644 index 0000000..57e8cc5 --- /dev/null +++ b/src/__tests__/config-partition.test.ts @@ -0,0 +1,47 @@ +/** + * Phase 7 config-split partition test. + * + * Pins the partition of `RuntimeConfig` fields between the supported public + * contract (`SUPPORTED_RUNTIME_CONFIG_FIELDS`) and the internal/experimental + * policy surface (`INTERNAL_POLICY_CONFIG_FIELDS`). The two must be disjoint + * and their union must cover every runtime field — otherwise the "supported + * vs experimental" documentation drifts silently as new fields land. + * + * This is the Step 3a fence from the post-Phase-6 follow-on plan. + */ + +import { describe, it, expect } from 'vitest'; +import { + config, + SUPPORTED_RUNTIME_CONFIG_FIELDS, + INTERNAL_POLICY_CONFIG_FIELDS, +} from '../config.js'; + +describe('runtime config partition', () => { + const supported = new Set(SUPPORTED_RUNTIME_CONFIG_FIELDS); + const internal = new Set(INTERNAL_POLICY_CONFIG_FIELDS); + const runtimeFields = new Set(Object.keys(config)); + + it('supported and internal partitions are disjoint', () => { + const overlap = [...supported].filter((field) => internal.has(field)); + expect(overlap).toEqual([]); + }); + + it('union covers every RuntimeConfig field present on the singleton', () => { + const missing = [...runtimeFields].filter( + (field) => !supported.has(field) && !internal.has(field), + ); + expect(missing).toEqual([]); + }); + + it('no partition field references a non-existent RuntimeConfig key', () => { + const strays = [...supported, ...internal].filter( + (field) => !runtimeFields.has(field), + ); + expect(strays).toEqual([]); + }); + + it('exposes a stable count for review-time sanity', () => { + expect(supported.size + internal.size).toBe(runtimeFields.size); + }); +}); diff --git a/src/config.ts b/src/config.ts index c32755b..4d6577d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -394,3 +394,99 @@ export function updateRuntimeConfig(updates: RuntimeConfigUpdates): string[] { return applied; } + +/** + * Public/supported operator config surface. Fields listed here are part of + * v2's stable contract: consumers can rely on their semantics and presence, + * and changes go through a documented deprecation cycle. + * + * This is a documentation type — it does not constrain threading. The runtime + * still carries a single `RuntimeConfig` object; this array tags the public + * subset so docs, tests, and future config-split work have a single source of + * truth. See also: `docs/consuming-core.md`. + */ +export const SUPPORTED_RUNTIME_CONFIG_FIELDS = [ + // Infrastructure + 'databaseUrl', 'openaiApiKey', 'port', + // Provider / model selection (startup config) + 'embeddingProvider', 'embeddingModel', 'embeddingDimensions', + 'embeddingApiUrl', 'embeddingApiKey', + 'llmProvider', 'llmModel', 'llmApiUrl', 'llmApiKey', + 'groqApiKey', 'anthropicApiKey', 'googleApiKey', + 'ollamaBaseUrl', 'vectorBackend', 'skipVectorIndexes', 'llmSeed', + 'crossEncoderModel', 'crossEncoderDtype', + // Operator-visible runtime + 'maxSearchResults', 'retrievalProfile', 'retrievalProfileSettings', + // Major feature toggles (surfaced in GET /memories/health) + 'entityGraphEnabled', 'lessonsEnabled', 'agenticRetrievalEnabled', + 'iterativeRetrievalEnabled', 'hybridSearchEnabled', 'repairLoopEnabled', + 'crossEncoderEnabled', 'auditLoggingEnabled', 'adaptiveRetrievalEnabled', + 'consensusValidationEnabled', 'namespaceClassificationEnabled', + // Cost / cache ops + 'extractionCacheDir', 'costLogDir', 'costRunId', 'costLoggingEnabled', +] as const; + +/** + * Internal policy config — experimental / tuning flags. Fields here may + * change semantics, defaults, or be removed without notice. Consumers should + * NOT rely on these in production. Promoted into the supported contract when + * a field's behavior stabilizes. + */ +export const INTERNAL_POLICY_CONFIG_FIELDS = [ + // Retrieval thresholds + 'similarityThreshold', 'audnCandidateThreshold', 'audnSafeReuseMinSimilarity', + 'crossAgentCandidateThreshold', 'clarificationConflictThreshold', + // Repair loop tuning + 'repairLoopMinSimilarity', 'repairSkipSimilarity', + 'repairDeltaThreshold', 'repairConfidenceFloor', + // MMR + 'mmrEnabled', 'mmrLambda', + // Link expansion + 'linkExpansionEnabled', 'linkExpansionMax', + 'linkSimilarityThreshold', 'linkExpansionBeforeMMR', + // Scoring weights + 'scoringWeightSimilarity', 'scoringWeightImportance', 'scoringWeightRecency', + // PPR + 'pprEnabled', 'pprDamping', + // Staging / tracing + 'stagedLoadingEnabled', 'retrievalTraceEnabled', + // Extraction internals + 'extractionCacheEnabled', 'embeddingCacheEnabled', + 'chunkedExtractionEnabled', 'chunkSizeTurns', 'chunkOverlapTurns', + 'consensusExtractionEnabled', 'consensusExtractionRuns', + 'entropyGateEnabled', 'entropyGateThreshold', 'entropyGateAlpha', + // Affinity clustering + 'affinityClusteringThreshold', 'affinityClusteringMinSize', + 'affinityClusteringBeta', 'affinityClusteringTemporalLambda', + // Trust + 'trustScoringEnabled', 'trustScoreMinThreshold', 'trustPenaltyEnabled', + // Decay / caps + 'decayCycleEnabled', 'decayRetentionThreshold', 'decayMinAgeDays', + 'memoryCapEnabled', 'memoryCapMax', 'memoryCapWarnRatio', + // Entity tuning + 'entityResolutionThreshold', 'entitySearchMinSimilarity', + // Lesson tuning + 'lessonSimilarityThreshold', + // Consensus tuning + 'consensusMinMemories', + // Query expansion / augmentation + 'queryExpansionEnabled', 'queryExpansionMinSimilarity', + 'queryAugmentationEnabled', 'queryAugmentationMaxEntities', + 'queryAugmentationMinSimilarity', + // Rerank tuning + 'rerankSkipTopSimilarity', 'rerankSkipMinGap', + // Fast AUDN + 'fastAudnEnabled', 'fastAudnDuplicateThreshold', + // Observation / deferred + 'observationNetworkEnabled', 'deferredAudnEnabled', 'deferredAudnBatchSize', + // Composite grouping + 'compositeGroupingEnabled', 'compositeMinClusterSize', + 'compositeMaxClusterSize', 'compositeSimilarityThreshold', + // Conflict handling + 'conflictAutoResolveMs', +] as const; + +export type SupportedRuntimeConfigField = typeof SUPPORTED_RUNTIME_CONFIG_FIELDS[number]; +export type InternalPolicyConfigField = typeof INTERNAL_POLICY_CONFIG_FIELDS[number]; +export type SupportedRuntimeConfig = Pick; +export type InternalPolicyConfig = Pick; diff --git a/src/index.ts b/src/index.ts index e1df81c..a7d80fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,19 @@ export { MemoryService, type IngestResult, type RetrievalResult } from './servic export { MemoryRepository, type MemoryRow, type SearchResult, type EpisodeRow, type MemoryMetadata } from './db/memory-repository.js'; export { ClaimRepository } from './db/claim-repository.js'; export { pool } from './db/pool.js'; -export { config, updateRuntimeConfig, type EmbeddingProviderName, type LLMProviderName, type VectorBackendName } from './config.js'; +export { + config, + updateRuntimeConfig, + SUPPORTED_RUNTIME_CONFIG_FIELDS, + INTERNAL_POLICY_CONFIG_FIELDS, + type EmbeddingProviderName, + type LLMProviderName, + type VectorBackendName, + type SupportedRuntimeConfigField, + type InternalPolicyConfigField, + type SupportedRuntimeConfig, + type InternalPolicyConfig, +} from './config.js'; export { createMemoryRouter } from './routes/memories.js'; export { type RetrievalCitation } from './services/retrieval-format.js'; export { From 056f95e9344d572b75a99d42afc1146e6589c938 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 22:49:13 -0700 Subject: [PATCH 2/4] Phase 7 Step 3b: deprecate PUT /memories/config for production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gates PUT /memories/config behind a startup-validated flag. Production deploys get 410 Gone; dev/test environments opt in via env var and keep v1 behavior. Follows the workspace rule "config is validated at startup, routes don't re-check at request time" — no NODE_ENV branching in the route handler. - src/config.ts: add runtimeConfigMutationEnabled to RuntimeConfig, parsed from CORE_RUNTIME_CONFIG_MUTATION_ENABLED (default false). Added to SUPPORTED_RUNTIME_CONFIG_FIELDS so the partition test keeps it honest. - src/app/runtime-container.ts: CoreRuntimeConfigRouteAdapter.current() now includes runtimeConfigMutationEnabled. Default adapter populates from config singleton at construction time — memoized, not a per- request env read. - src/routes/memories.ts: RuntimeConfigRouteSnapshot gains the field. PUT /memories/config checks configRouteAdapter.current() .runtimeConfigMutationEnabled; false → 410 Gone with a message pointing at the env var. true → existing behavior preserved. - .env.test.example: documents the flag with CORE_RUNTIME_CONFIG_MUTATION_ENABLED=true for test/dev. .env.test (gitignored) should be updated locally. - .env.example: documents the flag as opt-in; left commented out so production deploys inherit the safe default. - src/__tests__/memory-route-config-seam.test.ts: MutableRouteConfig gains the field (set true for existing tests); new test asserts PUT returns 410 when the flag is flipped to false. - docs/consuming-core.md: new "PUT /memories/config — dev/test only" section explaining the gate and dev-mode setup. - atomicmemory-research/docs/core-repo/readme/readme-migration-from-v1.md: §8 updated to reflect the deprecation path and point at the env var. 962/962 tests pass (961 + 1 new 410 test). tsc --noEmit clean. fallow --no-cache 0 above threshold. Parity audit (see v1-v2-functional-parity-audit.md §2) confirmed zero production callers of PUT /memories/config, so the deprecation is parity-preserving by definition. Co-authored-by: Claude Opus 4.7 (1M context) --- .env.example | 5 +++++ .env.test.example | 4 ++++ docs/consuming-core.md | 17 ++++++++++++++++ .../memory-route-config-seam.test.ts | 20 +++++++++++++++++++ src/app/runtime-container.ts | 8 ++++++++ src/config.ts | 11 ++++++++++ src/routes/memories.ts | 9 +++++++++ 7 files changed, 74 insertions(+) diff --git a/.env.example b/.env.example index 2010903..5dee470 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,11 @@ EMBEDDING_DIMENSIONS=1024 # LLM_PROVIDER=openai # LLM_MODEL=gpt-4o-mini +# --- Runtime config mutation (dev/test only) --- +# Opt-in gate for PUT /memories/config. Leave unset in production — the +# route returns 410 Gone unless this is true. See docs/consuming-core.md. +# CORE_RUNTIME_CONFIG_MUTATION_ENABLED=false + # --- Railway --- # On Railway, DATABASE_URL is injected by the Postgres plugin. # Set OPENAI_API_KEY in Railway service variables. diff --git a/.env.test.example b/.env.test.example index dc275fd..165b99d 100644 --- a/.env.test.example +++ b/.env.test.example @@ -6,3 +6,7 @@ DATABASE_URL=postgresql://supermem:supermem@localhost:5433/supermem OPENAI_API_KEY=test-placeholder EMBEDDING_DIMENSIONS=1024 PORT=3051 + +# Enable PUT /memories/config for tests/local dev. Production leaves this +# unset so the route returns 410 Gone. See docs/consuming-core.md. +CORE_RUNTIME_CONFIG_MUTATION_ENABLED=true diff --git a/docs/consuming-core.md b/docs/consuming-core.md index d28935f..ffb32aa 100644 --- a/docs/consuming-core.md +++ b/docs/consuming-core.md @@ -128,6 +128,23 @@ Both types are re-exported from the root package. Docs, code review, and release notes should reference `SUPPORTED_RUNTIME_CONFIG_FIELDS` as the authoritative list of what's stable. +### `PUT /memories/config` — dev/test only + +As of Phase 7 Step 3b, `PUT /memories/config` is gated by the startup-validated +flag `runtimeConfigMutationEnabled` (env: `CORE_RUNTIME_CONFIG_MUTATION_ENABLED`). + +- **Production** deploys leave the flag unset → the route returns `410 Gone`. + Production config must come from env vars at process start, not runtime HTTP + mutation. +- **Dev / test** deploys set `CORE_RUNTIME_CONFIG_MUTATION_ENABLED=true` → + the route behaves identically to v1. `.env.test` has this set by default so + local test runs and CI continue to work. + +Routes read the flag from a memoized startup snapshot through +`configRouteAdapter.current().runtimeConfigMutationEnabled` — they never +re-check `process.env` at request time, matching the workspace rule that +config is validated once at startup. + ## What belongs in research, not core Research harnesses, benchmarks, eval runners, experimental retrieval diff --git a/src/__tests__/memory-route-config-seam.test.ts b/src/__tests__/memory-route-config-seam.test.ts index 40db6c1..55e2c1e 100644 --- a/src/__tests__/memory-route-config-seam.test.ts +++ b/src/__tests__/memory-route-config-seam.test.ts @@ -26,6 +26,7 @@ interface MutableRouteConfig { crossEncoderEnabled: boolean; agenticRetrievalEnabled: boolean; repairLoopEnabled: boolean; + runtimeConfigMutationEnabled: boolean; } describe('memory route config seam', () => { @@ -48,6 +49,7 @@ describe('memory route config seam', () => { crossEncoderEnabled: true, agenticRetrievalEnabled: false, repairLoopEnabled: true, + runtimeConfigMutationEnabled: true, }; search.mockResolvedValue({ @@ -137,6 +139,24 @@ describe('memory route config seam', () => { expect(updatedHealthBody.config.max_search_results).toBe(7); }); + it('PUT /memories/config returns 410 when runtimeConfigMutationEnabled is false', async () => { + const originalFlag = routeConfig.runtimeConfigMutationEnabled; + routeConfig.runtimeConfigMutationEnabled = false; + try { + const res = await fetch(`${booted.baseUrl}/memories/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ max_search_results: 99 }), + }); + expect(res.status).toBe(410); + const body = await res.json(); + expect(body.error).toMatch(/deprecated/i); + expect(body.detail).toMatch(/CORE_RUNTIME_CONFIG_MUTATION_ENABLED/); + } finally { + routeConfig.runtimeConfigMutationEnabled = originalFlag; + } + }); + it('clamps search limits using the injected adapter snapshot', async () => { await fetch(`${booted.baseUrl}/memories/search`, { method: 'POST', diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index c314d39..47498a1 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -128,6 +128,13 @@ export interface CoreRuntimeConfigRouteAdapter { crossEncoderEnabled: boolean; agenticRetrievalEnabled: boolean; repairLoopEnabled: boolean; + /** + * Startup-validated flag for whether PUT /memories/config should mutate + * runtime config. Production deploys leave this false; dev/test toggles + * it on via the CORE_RUNTIME_CONFIG_MUTATION_ENABLED env var. Routes + * read this snapshot — never re-check env at request time. + */ + runtimeConfigMutationEnabled: boolean; }; update: (updates: { embeddingProvider?: import('../config.js').EmbeddingProviderName; @@ -225,6 +232,7 @@ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime { crossEncoderEnabled: config.crossEncoderEnabled, agenticRetrievalEnabled: config.agenticRetrievalEnabled, repairLoopEnabled: config.repairLoopEnabled, + runtimeConfigMutationEnabled: config.runtimeConfigMutationEnabled, }; }, update(updates) { diff --git a/src/config.ts b/src/config.ts index 4d6577d..eec71cc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -122,6 +122,13 @@ interface RuntimeConfig { costLogDir: string; costRunId: string; conflictAutoResolveMs: number; + /** + * Dev/test-only: when true, PUT /memories/config mutates the runtime + * singleton. Production deploys leave this unset (false) — the route + * returns 410 Gone. Startup-validated; routes read the memoized value + * through configRouteAdapter, never re-check at request time. + */ + runtimeConfigMutationEnabled: boolean; } interface RuntimeConfigUpdates { @@ -347,6 +354,8 @@ export const config: RuntimeConfig = { costLogDir: optionalEnv('COST_LOG_DIR') ?? 'data/cost-logs', costRunId: optionalEnv('COST_RUN_ID') ?? '', conflictAutoResolveMs: parseInt(optionalEnv('CONFLICT_AUTO_RESOLVE_MS') ?? '86400000', 10), + runtimeConfigMutationEnabled: + (process.env.CORE_RUNTIME_CONFIG_MUTATION_ENABLED ?? 'false') === 'true', }; export function updateRuntimeConfig(updates: RuntimeConfigUpdates): string[] { @@ -424,6 +433,8 @@ export const SUPPORTED_RUNTIME_CONFIG_FIELDS = [ 'consensusValidationEnabled', 'namespaceClassificationEnabled', // Cost / cache ops 'extractionCacheDir', 'costLogDir', 'costRunId', 'costLoggingEnabled', + // Dev/test-only mutation gate for PUT /memories/config (see docs/consuming-core.md) + 'runtimeConfigMutationEnabled', ] as const; /** diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 3d3965f..425ad4a 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -39,6 +39,7 @@ interface RuntimeConfigRouteSnapshot { crossEncoderEnabled: boolean; agenticRetrievalEnabled: boolean; repairLoopEnabled: boolean; + runtimeConfigMutationEnabled: boolean; } interface RuntimeConfigRouteUpdates { @@ -266,6 +267,13 @@ function registerHealthRoute(router: Router, configRouteAdapter: RuntimeConfigRo function registerConfigRoute(router: Router, configRouteAdapter: RuntimeConfigRouteAdapter): void { router.put('/config', async (req: Request, res: Response) => { try { + if (!configRouteAdapter.current().runtimeConfigMutationEnabled) { + res.status(410).json({ + error: 'PUT /memories/config is deprecated for production', + detail: 'Set CORE_RUNTIME_CONFIG_MUTATION_ENABLED=true to enable runtime mutation in dev/test environments. Production deploys should use startup env vars.', + }); + return; + } const applied = configRouteAdapter.update({ embeddingProvider: req.body.embedding_provider, embeddingModel: req.body.embedding_model, @@ -686,6 +694,7 @@ function readRuntimeConfigRouteSnapshot(): RuntimeConfigRouteSnapshot { crossEncoderEnabled: config.crossEncoderEnabled, agenticRetrievalEnabled: config.agenticRetrievalEnabled, repairLoopEnabled: config.repairLoopEnabled, + runtimeConfigMutationEnabled: config.runtimeConfigMutationEnabled, }; } From d3697266802283bb3fe0d0eb033bc1b0b672a4b9 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 22:52:26 -0700 Subject: [PATCH 3/4] Phase 7 Step 3c: freeze provider/model selection as startup-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes embeddingProvider, embeddingModel, llmProvider, llmModel from updateRuntimeConfig() and rejects them at the HTTP boundary with 400. This is a bug fix disguised as a deprecation: v1 accepted these fields but the embedding.ts / llm.ts provider caches are stateful and fixed at first use, so mid-flight mutation never actually took effect. Making them explicitly startup-only aligns the API contract with reality. Parity audit (v1-v2-functional-parity-audit.md §4) confirmed no caller relies on runtime provider/model mutation — both in-repo tests and the vendored prototype UI only mutated thresholds / maxSearchResults, which remain mutable in dev/test mode. - src/config.ts: narrow RuntimeConfigUpdates to 4 mutable fields (similarityThreshold, audnCandidateThreshold, clarificationConflictThreshold, maxSearchResults). Delete the four provider/model blocks from updateRuntimeConfig(). Remove the now-dead requireSupportedProvider and requireNonEmpty helpers. - src/app/runtime-container.ts: narrow CoreRuntimeConfigRouteAdapter .update() signature to match. - src/routes/memories.ts: narrow RuntimeConfigRouteUpdates; add STARTUP_ONLY_CONFIG_FIELDS = ['embedding_provider', 'embedding_model', 'llm_provider', 'llm_model'] and reject the PUT with 400 if any appear in the body, with a message pointing at the env vars. - src/__tests__/memory-route-config-seam.test.ts: new test asserts 400 rejection with rejected: ['embedding_provider']. - docs/consuming-core.md + migration doc: document the startup-only constraint with the rationale. 963/963 tests pass (962 + 1 new 400 test). tsc --noEmit clean. fallow --no-cache 0 above threshold. Co-authored-by: Claude Opus 4.7 (1M context) --- docs/consuming-core.md | 11 +++- .../memory-route-config-seam.test.ts | 12 +++++ src/app/runtime-container.ts | 4 -- src/config.ts | 52 +++---------------- src/routes/memories.ts | 19 ++++--- 5 files changed, 39 insertions(+), 59 deletions(-) diff --git a/docs/consuming-core.md b/docs/consuming-core.md index ffb32aa..13ae75a 100644 --- a/docs/consuming-core.md +++ b/docs/consuming-core.md @@ -137,8 +137,15 @@ flag `runtimeConfigMutationEnabled` (env: `CORE_RUNTIME_CONFIG_MUTATION_ENABLED` Production config must come from env vars at process start, not runtime HTTP mutation. - **Dev / test** deploys set `CORE_RUNTIME_CONFIG_MUTATION_ENABLED=true` → - the route behaves identically to v1. `.env.test` has this set by default so - local test runs and CI continue to work. + the route mutates the runtime singleton. `.env.test` has this set by + default so local test runs and CI continue to work. + +Even in dev/test, provider/model fields (`embedding_provider`, `embedding_model`, +`llm_provider`, `llm_model`) are rejected with 400 — these are startup-only +because the embedding/LLM provider caches are fixed at first use. Set them +via env vars and restart the process. Only `similarity_threshold`, +`audn_candidate_threshold`, `clarification_conflict_threshold`, and +`max_search_results` are mutable. Routes read the flag from a memoized startup snapshot through `configRouteAdapter.current().runtimeConfigMutationEnabled` — they never diff --git a/src/__tests__/memory-route-config-seam.test.ts b/src/__tests__/memory-route-config-seam.test.ts index 55e2c1e..59ed393 100644 --- a/src/__tests__/memory-route-config-seam.test.ts +++ b/src/__tests__/memory-route-config-seam.test.ts @@ -139,6 +139,18 @@ describe('memory route config seam', () => { expect(updatedHealthBody.config.max_search_results).toBe(7); }); + it('PUT /memories/config returns 400 when provider/model fields are included (startup-only)', async () => { + const res = await fetch(`${booted.baseUrl}/memories/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ embedding_provider: 'openai', max_search_results: 5 }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/startup-only/i); + expect(body.rejected).toContain('embedding_provider'); + }); + it('PUT /memories/config returns 410 when runtimeConfigMutationEnabled is false', async () => { const originalFlag = routeConfig.runtimeConfigMutationEnabled; routeConfig.runtimeConfigMutationEnabled = false; diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index 47498a1..1b2f0f1 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -137,10 +137,6 @@ export interface CoreRuntimeConfigRouteAdapter { runtimeConfigMutationEnabled: boolean; }; update: (updates: { - embeddingProvider?: import('../config.js').EmbeddingProviderName; - embeddingModel?: string; - llmProvider?: import('../config.js').LLMProviderName; - llmModel?: string; similarityThreshold?: number; audnCandidateThreshold?: number; clarificationConflictThreshold?: number; diff --git a/src/config.ts b/src/config.ts index eec71cc..dfef578 100644 --- a/src/config.ts +++ b/src/config.ts @@ -131,11 +131,14 @@ interface RuntimeConfig { runtimeConfigMutationEnabled: boolean; } +/** + * Fields accepted by `updateRuntimeConfig()`. Provider/model selection + * (embeddingProvider, embeddingModel, llmProvider, llmModel) is intentionally + * absent: embedding.ts and llm.ts cache stateful provider instances at first + * call, so mid-flight mutation never took effect in v1. Freezing these as + * startup-only is a bug fix. Set them via env before process start. + */ interface RuntimeConfigUpdates { - embeddingProvider?: string; - embeddingModel?: string; - llmProvider?: string; - llmModel?: string; similarityThreshold?: number; audnCandidateThreshold?: number; clarificationConflictThreshold?: number; @@ -175,27 +178,6 @@ function parseLlmProvider(value: string | undefined, fallback: LLMProviderName): return value as LLMProviderName; } -function requireSupportedProvider( - provider: EmbeddingProviderName | LLMProviderName, - kind: 'embedding' | 'llm', -): void { - if (provider === 'openai') return; - if (provider === 'ollama') return; - if (provider === 'groq' && kind === 'llm' && config.groqApiKey) return; - if (provider === 'anthropic' && kind === 'llm' && config.anthropicApiKey) return; - if (provider === 'google-genai' && kind === 'llm' && config.googleApiKey) return; - if (provider === 'openai-compatible' && kind === 'embedding' && config.embeddingApiUrl) return; - if (provider === 'openai-compatible' && kind === 'llm' && config.llmApiUrl) return; - throw new Error(`Provider "${provider}" is not configured for ${kind}`); -} - -function requireNonEmpty(value: string, field: string): string { - const trimmed = value.trim(); - if (!trimmed) { - throw new Error(`${field} must be a non-empty string`); - } - return trimmed; -} function requireFiniteNumber(value: number, field: string): number { if (!Number.isFinite(value)) { @@ -361,26 +343,6 @@ export const config: RuntimeConfig = { export function updateRuntimeConfig(updates: RuntimeConfigUpdates): string[] { const applied: string[] = []; - if (updates.embeddingProvider !== undefined) { - const provider = parseEmbeddingProvider(updates.embeddingProvider, config.embeddingProvider); - requireSupportedProvider(provider, 'embedding'); - config.embeddingProvider = provider; - applied.push('embeddingProvider'); - } - if (updates.embeddingModel !== undefined) { - config.embeddingModel = requireNonEmpty(updates.embeddingModel, 'embeddingModel'); - applied.push('embeddingModel'); - } - if (updates.llmProvider !== undefined) { - const provider = parseLlmProvider(updates.llmProvider, config.llmProvider); - requireSupportedProvider(provider, 'llm'); - config.llmProvider = provider; - applied.push('llmProvider'); - } - if (updates.llmModel !== undefined) { - config.llmModel = requireNonEmpty(updates.llmModel, 'llmModel'); - applied.push('llmModel'); - } if (updates.similarityThreshold !== undefined) { config.similarityThreshold = requireFiniteNumber(updates.similarityThreshold, 'similarityThreshold'); applied.push('similarityThreshold'); diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 425ad4a..3e7cc7d 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -43,16 +43,14 @@ interface RuntimeConfigRouteSnapshot { } interface RuntimeConfigRouteUpdates { - embeddingProvider?: EmbeddingProviderName; - embeddingModel?: string; - llmProvider?: LLMProviderName; - llmModel?: string; similarityThreshold?: number; audnCandidateThreshold?: number; clarificationConflictThreshold?: number; maxSearchResults?: number; } +const STARTUP_ONLY_CONFIG_FIELDS = ['embedding_provider', 'embedding_model', 'llm_provider', 'llm_model'] as const; + const defaultRuntimeConfigRouteAdapter: RuntimeConfigRouteAdapter = { current() { return readRuntimeConfigRouteSnapshot(); @@ -274,11 +272,16 @@ function registerConfigRoute(router: Router, configRouteAdapter: RuntimeConfigRo }); return; } + const rejected = STARTUP_ONLY_CONFIG_FIELDS.filter((field) => req.body[field] !== undefined); + if (rejected.length > 0) { + res.status(400).json({ + error: 'Provider/model selection is startup-only', + detail: `Fields ${rejected.join(', ')} cannot be mutated at runtime — the embedding/LLM provider caches are fixed at first use. Set the equivalent env vars (EMBEDDING_PROVIDER, EMBEDDING_MODEL, LLM_PROVIDER, LLM_MODEL) and restart the process.`, + rejected, + }); + return; + } const applied = configRouteAdapter.update({ - embeddingProvider: req.body.embedding_provider, - embeddingModel: req.body.embedding_model, - llmProvider: req.body.llm_provider, - llmModel: req.body.llm_model, similarityThreshold: req.body.similarity_threshold, audnCandidateThreshold: req.body.audn_candidate_threshold, clarificationConflictThreshold: req.body.clarification_conflict_threshold, From bac6086bd46c762138bcc5c56d865ca4bb68ebc4 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 11:05:55 -0700 Subject: [PATCH 4/4] Phase 7 Step 3b/3c followup: update API docs and success-response note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review caught two drift points between the implementation and the docs/response payload: - docs/api-reference.md §PUT /memories/config still documented the pre- Step-3b/3c contract — provider/model mutation fields and "all fields optional". Rewrote the section to: flag dev/test-only gating, list the 4 currently-mutable fields, document the 400/410 responses with example payloads, and explain the startup-only rationale. - src/routes/memories.ts: the success-response `note` contradicted the handler's own behavior. "Provider/model changes are applied in-memory for local experimentation" is no longer true (those fields are rejected earlier in the same handler). Replaced with an honest note pointing threshold-only mutation at dev use and startup restart for provider/ model. 963/963 tests pass. tsc --noEmit clean. Co-authored-by: Claude Opus 4.7 (1M context) --- docs/api-reference.md | 52 ++++++++++++++++++++++++++++++------------ src/routes/memories.ts | 2 +- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index b9fbc1f..530fd57 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -657,15 +657,14 @@ Deactivate a lesson. ### PUT /memories/config -Update runtime configuration. Changes are in-memory only (for experimentation). +Update runtime configuration at runtime. **Dev/test only** — production +deploys return `410 Gone`. Gated by the startup-validated env var +`CORE_RUNTIME_CONFIG_MUTATION_ENABLED`; see `docs/consuming-core.md`. + +**Mutable fields (Phase 7 Step 3c contract — 4 fields total):** -**Request:** ```json { - "embedding_provider": "ollama", - "embedding_model": "mxbai-embed-large", - "llm_provider": "ollama", - "llm_model": "qwen3:8b", "similarity_threshold": 0.3, "audn_candidate_threshold": 0.7, "clarification_conflict_threshold": 0.8, @@ -675,14 +674,39 @@ Update runtime configuration. Changes are in-memory only (for experimentation). All fields are optional. Only provided fields are updated. -**Response:** -```json -{ - "applied": { "llm_model": "qwen3:8b" }, - "config": { "...current config snapshot..." }, - "note": "Provider/model changes are applied in-memory for local experimentation." -} -``` +**Startup-only fields (rejected with 400):** `embedding_provider`, +`embedding_model`, `llm_provider`, `llm_model`. Set these via env vars +(`EMBEDDING_PROVIDER`, `EMBEDDING_MODEL`, `LLM_PROVIDER`, `LLM_MODEL`) +and restart the process. The embedding/LLM provider caches are fixed +at first use, so mid-flight mutation never took effect in v1. + +**Responses:** + +- `200 OK` (success, dev/test with mutable fields): + ```json + { + "applied": ["similarityThreshold", "maxSearchResults"], + "config": { "...current config snapshot..." }, + "note": "Threshold updates applied in-memory for local experimentation. Restart the process to change provider/model." + } + ``` + +- `400 Bad Request` (any startup-only field present): + ```json + { + "error": "Provider/model selection is startup-only", + "detail": "Fields embedding_provider cannot be mutated at runtime — the embedding/LLM provider caches are fixed at first use. Set the equivalent env vars (EMBEDDING_PROVIDER, EMBEDDING_MODEL, LLM_PROVIDER, LLM_MODEL) and restart the process.", + "rejected": ["embedding_provider"] + } + ``` + +- `410 Gone` (production — mutation disabled): + ```json + { + "error": "PUT /memories/config is deprecated for production", + "detail": "Set CORE_RUNTIME_CONFIG_MUTATION_ENABLED=true to enable runtime mutation in dev/test environments. Production deploys should use startup env vars." + } + ``` --- diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 3e7cc7d..b76ad8e 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -290,7 +290,7 @@ function registerConfigRoute(router: Router, configRouteAdapter: RuntimeConfigRo res.json({ applied, config: formatHealthConfig(configRouteAdapter.current()), - note: 'Provider/model changes are applied in-memory for local experimentation.', + note: 'Threshold updates applied in-memory for local experimentation. Provider/model selection is startup-only — restart the process to change it.', }); } catch (err) { handleRouteError(res, 'PUT /memories/config', err);