Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions .env.test.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
52 changes: 38 additions & 14 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."
}
```

---

Expand Down
50 changes: 50 additions & 0 deletions docs/consuming-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,56 @@ 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.

### `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 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
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
Expand Down
47 changes: 47 additions & 0 deletions src/__tests__/config-partition.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>(SUPPORTED_RUNTIME_CONFIG_FIELDS);
const internal = new Set<string>(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);
});
});
32 changes: 32 additions & 0 deletions src/__tests__/memory-route-config-seam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface MutableRouteConfig {
crossEncoderEnabled: boolean;
agenticRetrievalEnabled: boolean;
repairLoopEnabled: boolean;
runtimeConfigMutationEnabled: boolean;
}

describe('memory route config seam', () => {
Expand All @@ -48,6 +49,7 @@ describe('memory route config seam', () => {
crossEncoderEnabled: true,
agenticRetrievalEnabled: false,
repairLoopEnabled: true,
runtimeConfigMutationEnabled: true,
};

search.mockResolvedValue({
Expand Down Expand Up @@ -137,6 +139,36 @@ 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;
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',
Expand Down
12 changes: 8 additions & 4 deletions src/app/runtime-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,15 @@ 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;
embeddingModel?: string;
llmProvider?: import('../config.js').LLMProviderName;
llmModel?: string;
similarityThreshold?: number;
audnCandidateThreshold?: number;
clarificationConflictThreshold?: number;
Expand Down Expand Up @@ -225,6 +228,7 @@ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime {
crossEncoderEnabled: config.crossEncoderEnabled,
agenticRetrievalEnabled: config.agenticRetrievalEnabled,
repairLoopEnabled: config.repairLoopEnabled,
runtimeConfigMutationEnabled: config.runtimeConfigMutationEnabled,
};
},
update(updates) {
Expand Down
Loading