Root cause (GAP-007): updateConfig() used getConfig() — the env-applied runtime view — as its merge base, then serialized that view verbatim to disk. Any test running with DUCKBRAIN_NAMESPACES_PATH=/tmp/... persisted the scratch path into the production duckbrain.config.json, which the daemon then loaded on restart as the fleet memory backbone. Two compounding bugs: the wrong merge base, and registerNamespace() passing the whole env-applied config back into the write path.
The pattern: env-only fields are never persisted. Split reads (file vs file+env); writes always merge against the raw file; env is applied at read time only.
// src/config.ts (fixed)
export interface DuckbrainConfig {
namespacesPath: string;
namespaceMappings: Record<string, string>;
}
/** Raw on-disk config. NO env overrides. The only valid merge base for writes. */
export function readFileConfig(): DuckbrainConfig {
try {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) as DuckbrainConfig;
} catch {
return defaultConfig(); // missing/corrupt file -> defaults
}
}
/** File + env. Runtime callers' view; never a write base. */
export function getConfig(): DuckbrainConfig {
const cfg = readFileConfig();
if (process.env.DUCKBRAIN_NAMESPACES_PATH) {
cfg.namespacesPath = process.env.DUCKBRAIN_NAMESPACES_PATH;
}
return cfg;
}
/** Persist a patch: merge against raw file, strip env-only fields, write. */
export function updateConfig(patch: Partial<DuckbrainConfig>): DuckbrainConfig {
const persistable: Partial<DuckbrainConfig> = { ...patch };
for (const field of envOverriddenFields()) delete persistable[field]; // /tmp path can never reach disk
const merged = deepmerge(readFileConfig(), persistable); // base = raw file, NOT getConfig()
writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + '\n', 'utf8');
return getConfig(); // runtime callers still see env
}
/** Pass ONLY the persistable field — never the whole env-applied config. */
export function registerNamespace(namespace: string, mapping: string): DuckbrainConfig {
const base = readFileConfig();
return updateConfig({
namespaceMappings: { ...base.namespaceMappings, [namespace]: mapping },
});
}
Key points vs. the old code:
| Before (buggy) | After (fixed) | |
|---|---|---|
| Merge base for writes | getConfig() (env applied) |
readFileConfig() (raw) |
registerNamespace patch |
whole env-applied config | { namespaceMappings } only |
getConfig() return |
env applied | env applied (unchanged for runtime callers) |
| Env field in a write patch | persisted verbatim | stripped before merge (envOverriddenFields()) |
envOverriddenFields() is computed at call time, not module load, so an env var set after import (exactly what tests do in before/beforeEach) is still stripped. CI guards the whole class of regression:
# .github/workflows/ci.yml
- run: npm test
- name: Assert config file untouched by tests
run: git diff --exit-code duckbrain.config.json
Built a runnable repro at `~/gap-007` (Node 22 type-stripping, `node:test`, real `git` repo with the production config committed) and ran both sides:
**Bug reproduced first (buggy module, `test/buggy-demo.ts`):** after `updateConfig({namespaceMappings: {...}})` under `DUCKBRAIN_NAMESPACES_PATH=/tmp/duckbrain-test-namespaces`, the on-disk file contained `"namespacesPath": "/tmp/duckbrain-test-namespaces"` and `git diff --stat` showed 3 insertions/2 deletions — the exact corruption described.
**Fixed module — 8/8 tests pass**, then `git diff --exit-code duckbrain.config.json` exits 0 (config byte-identical to committed baseline):
```
ok 1 - readFileConfig() is raw: no env override
ok 2 - getConfig() applies env overrides at read time only
ok 3 - updateConfig() merges against raw file config; env override never persisted
ok 4 - updateConfig() strips env fields even if the patch carries them (old bug shape)
ok 5 - registerNamespace() persists only {namespaceMappings}
ok 6 - updateConfig() with no env set behaves like plain file editing
ok 7 - readFileConfig() returns defaults when config file is missing
ok 8 - CI guard: git diff --exit-code duckbrain.config.json after full suite
PASS: duckbrain.config.json untouched by tests
```
**Edge cases tested and hardened:**
- *Full-config patch (old `registerNamespace` shape)* — a caller passing `getConfig()`'s env-applied object back can no longer persist the `/tmp` path; it's stripped, file keeps its own `namespacesPath` (test 4 — this initially caught a real flaw where the strip list was computed at module load; moved to per-call computation).
- *Env set after module import* (tests set the var in `beforeEach`) — strip still active.
- *Env unset* — `namespacesPath` is a legitimate file field again; `updateConfig({namespacesPath})` persists and `getConfig() === readFileConfig()` (test 6).
- *Deep merge semantics* — existing `namespaceMappings` are preserved, not replaced (test 4/5).
- *Missing/corrupt config file* — `readFileConfig()` returns defaults; `getConfig()` still overlays env (test 7).
- *Runtime view preserved* — `getConfig()`/`updateConfig()` return values keep applying env overrides for daemon callers in every test.{"model": "deepseek-v4-flash", "problem_class": "typescript-config-env-override-persistence", "result": "passed", "tests": 8}