Skip to content

Commit 09ee252

Browse files
author
woclaw-cron
committed
test(r512): the config file has no interpreter -- a misspelled key is silent
R512. The wire-version plane is already mined three times over (R425 "no protocol version on the envelope", R452 "the compat boundary is drawn by position", R495 "the schema_version label is ACCEPTED but never INTERPRETED"). All three are about a version label on a MESSAGE. This round moves to the version of the artifact an OPERATOR writes: the JSON config file, merged over the env defaults at boot by a bare `{ ...base, ...fileConfig }` with no schema check (R487.1 already noted this). R487 covered the top-level TYPE error mode and found it LOUD -- authToken: null crashes the boot banner. That is a good outcome. This suite covers the other error mode, which R487's shape structurally cannot see: wrong TYPE at top level -> LOUD (boot crash) [R487, shipped] wrong KEY NAME anywhere -> SILENT (no-op) [this suite] The asymmetry is the finding. A crash is a gift; a misspelled key produces a boot that succeeds, a banner that looks normal, and a control that is off. MEASURED (not grepped): F1 encryption: { enable: true } (one `d` missing) loads cleanly. The operator's intent is PRESENT in the config object and UNCONSUMED. A controlled A/B on the production storage class (new ClawDB(config)), three arms, each writing a canary and scanning both the db file and the -wal sidecar: enabled: true (correct) -> ENC:v1: on disk, canary NOT plaintext enable: true (typo) -> canary IN PLAINTEXT on disk no encryption block -> canary IN PLAINTEXT on disk The typo arm is BYTE-IDENTICAL to "encryption was never configured". The operator writes a passphrase, sees the hub boot, and gets plaintext at rest. The typo is CAUSAL, proven by A/B. F2 The encryption state is observable NOWHERE -- zero `encryption` tokens in startup_banner.ts and zero in rest_server.ts. Even a CORRECT encryption config is not echoed. The type carries the field; the fact is knowable and simply not reported. F3 The banner advertises the REQUESTED port, not the BOUND port. The server knows the real one (boundPort / whenListening()) and the banner never uses it: with port 0 the banner prints ws://127.0.0.1:0 while the server bound 36783. F4 A config naming a schema version the hub has never heard of (schemaVersion: "9.9") is accepted silently -- there is no version to compare against. F5 The asymmetry, stated: a spread has no notion of a key it does not recognise, so it cannot distinguish "absent" from "misspelled". METHOD, and the three instrument failures this round paid for (R505's rule: a detector must be MEASURED before its zero is evidence): 1. The first A/B never separated -- it never WROTE anything, so "no ENC:v1:" was true in both arms for an unrelated reason. A zero from a detector that never fired is not evidence of absence. 2. The second A/B wrote but read the wrong file -- SQLite runs in WAL mode, so the fresh bytes live in woclaw.db-wal. The instrument was wrong, not the product. 3. The matrix's M2 was INERT -- it allow-listed keys already present in base, so its guard could never fire. Rebuilt as a real allow-list. Plus two driver defects caught by the count==1 / assert-new!=old guards: M5's needle never matched (reported NOT-INJECTED rather than corrupting the file), and control-first caught a leaked mutation from a manual run -- the matrix ABORTED instead of reporting a result from a dirty baseline. MUTATION MATRIX (control first: 0 failed / 17 passed) M1 loader rejects unknown keys (real-fix shape) KILLED (2) M2 loader allow-lists top-level keys (rebuilt) KILLED (1) M3 banner prints the bound port KILLED (1) M4 banner reports encryption state KILLED (1) M5 rest_server exposes encryption state KILLED (1) M5b rest_server exposes a configVersion SURVIVED (scoped) M6 Config type gains an unread configVersion SURVIVED (scoped) M7 NEGATIVE CONTROL: unrelated banner line added SURVIVED (correct) M5b/M6 are recorded as scoped survivors: the suite's subject is the encryption state and the loader's key handling, and it says so in-source. M7 surviving is the evidence that the KILLED verdicts mean something. POLARITY: this is a defect-PRESENCE gate. It is green while the hub has the defect and turns red when the defect is FIXED (loader rejects unknown keys -> P1/P1b red; banner prints the bound port -> P4 red; encryption reported -> P5 red). Deliberate, and stated in the suite header so the next reader does not have to rediscover it. VERIFICATION focused suite 17/17 PASS tsc --noEmit rc = 0 full hub suite 188 files / 2057 tests -- 2052 passed, 5 failed; the 5 are the known UNTRACKED orphan suite hub/test/r509_pagination_non_total_order.test.ts (db.initialize is not a function), which is not in git and therefore not in CI. It predates this round. git diff --stat -- hub/src EMPTY (zero production change) hub/src/*.ts md5 stable across the whole matrix (read back after every restore) watchdog heartbeat-watchdog.sh check woclaw -> PASS FATHER-GATED -- three product decisions, not bug fixes: what a misspelled key should do (fatal / warn / tolerate); whether a config should carry a version and what an unknown version means; whether the encryption state should be reported. The cheapest real fix is ONE line in the startup banner printing config.encryption?.enabled -- it converts F2 from invisible to visible, and it is the prerequisite for any typo being diagnosable at all. Write-up: memory/2026-10-28-round512-config-file-has-no-interpreter.md
1 parent aa5b059 commit 09ee252

1 file changed

Lines changed: 310 additions & 0 deletions

File tree

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
/**
2+
* R512 — THE CONFIG FILE IS THE HUB'S ONLY OPERATOR-FACING SCHEMA, AND IT HAS
3+
* NO INTERPRETER: A MISSPELLED KEY IS SILENT, AND THE SILENCE IS LOUDEST WHERE
4+
* IT MATTERS MOST (a security control the operator believes is ON).
5+
*
6+
* ── WHY THIS PLANE, AND WHY IT IS NOT A REPEAT ───────────────────────────────
7+
*
8+
* The wire-version plane is already mined, three times over:
9+
* R425 — "the wire envelope carries no protocol version"
10+
* R452 — "the additive-compat boundary is drawn by position"
11+
* R495 — "the schema_version label is ACCEPTED but never INTERPRETED"
12+
*
13+
* All three are about the version label on a MESSAGE. None is about the
14+
* version of the artifact an OPERATOR writes: the JSON config file. That file
15+
* is the hub's only operator-facing schema — `CONFIG_FILE` is merged over the
16+
* env-derived defaults at boot and is never validated (R487.1 called it "the
17+
* bare `{ ...base, ...fileConfig }`, no schema check").
18+
*
19+
* R487 covered the TOP-LEVEL TYPE error mode and found it LOUD: `authToken:
20+
* null` crashes the boot banner with a TypeError. That is a good outcome.
21+
* This round covers the OTHER error mode, which R487's shape cannot see:
22+
*
23+
* wrong TYPE at top level -> LOUD (crash) [R487, shipped]
24+
* wrong KEY NAME anywhere -> SILENT (no-op) [R512, this suite]
25+
*
26+
* The asymmetry is the finding. A crash is a gift: the operator cannot ship a
27+
* config that boots and is wrong about a TYPE. A misspelled key produces a
28+
* boot that succeeds, a banner that looks normal, and a control that is off.
29+
*
30+
* ── WHAT IS MEASURED (not grepped, not argued) ───────────────────────────────
31+
*
32+
* P1 The merged config keeps the operator's misspelled key verbatim ⇒ the
33+
* intent is PRESENT in the object and UNCONSUMED.
34+
* P2 CONTROLLED A/B on the production storage class: the typo arm is
35+
* BYTE-IDENTICAL to "encryption was never configured" (plaintext canary on
36+
* disk), while the correct-spelling arm stores ENC:v1:. So the typo is
37+
* CAUSAL, not correlational.
38+
* P3 A config file naming a schema version the hub has never heard of
39+
* (`schemaVersion: "9.9"`) is accepted silently — the hub cannot tell it
40+
* was handed a file it does not understand.
41+
* P4 The startup banner prints the REQUESTED port, not the BOUND port. With
42+
* `port: 0` the OS assigns an ephemeral port and the banner advertises
43+
* `ws://127.0.0.1:0` — a URL that is not the one serving traffic. The
44+
* server KNOWS the bound port (`boundPort`, `whenListening()`); the
45+
* banner does not use it.
46+
* P5 The encryption state is observable NOWHERE: not in the startup banner,
47+
* not in any REST endpoint. So the operator cannot confirm from the running
48+
* hub that a CORRECT encryption config took effect either.
49+
*
50+
* ── METHOD, AND THE TWO INSTRUMENT FAILURES THIS ROUND PAID FOR ─────────────
51+
*
52+
* R505's rule: a detector must be MEASURED before its zero is evidence. Two
53+
* of my own instruments failed that test and are recorded here because the
54+
* failures are the same shape as the finding:
55+
*
56+
* FAILURE 1 — the first A/B never separated. It never WROTE anything, so
57+
* "the DB contains no ENC:v1:" was true in both arms for a reason
58+
* unrelated to the typo. A 0 from a detector that never fired is not
59+
* evidence of absence; it is evidence that the detector did not fire.
60+
*
61+
* FAILURE 2 — the second A/B DID write, but read only `woclaw.db`. SQLite
62+
* runs in WAL mode (`db.pragma('journal_mode = WAL')`), so the freshly
63+
* written bytes live in `woclaw.db-wal`. The main file read 0 in both
64+
* arms. The instrument was wrong, not the product.
65+
*
66+
* The version that separates drives the PRODUCTION storage class
67+
* (`new ClawDB(config)`) in-process, writes through `setMemory`, and inspects
68+
* the raw bytes of BOTH `woclaw.db` and `woclaw.db-wal`. That is the version
69+
* in P2 below, and it is the only one whose verdict may be quoted.
70+
*
71+
* ── POLARITY — READ THIS BEFORE INTERPRETING A RED RUN ───────────────────────
72+
*
73+
* This is a DEFECT-PRESENCE gate, not a defect-absence gate. It is GREEN while
74+
* the hub has the defect and turns RED when the defect is FIXED:
75+
*
76+
* • fix the loader so it rejects an unknown key -> P1 / P1b go RED
77+
* • make the banner print the bound port -> P4 goes RED
78+
* • report the encryption state anywhere -> P5 goes RED
79+
*
80+
* That is deliberate and is the same construction as R461's arm G ("the day
81+
* the correct resync lands it goes green on purpose", inverted here). A
82+
* reviewer seeing red after a fix should read it as "the fix landed and the
83+
* probe now reports it", not as a regression. The suite header says so, in
84+
* source, so the next reader does not have to rediscover it.
85+
*
86+
* ── WHAT IS DELIBERATELY NOT ASSERTED ───────────────────────────────────────
87+
*
88+
* That "the hub should validate its config file". Whether a typo is fatal,
89+
* warned about, or tolerated is a PRODUCT DECISION (an operator may be
90+
* running a config written for a newer hub and want the extra keys kept).
91+
* This suite pins the SHAPE — the asymmetry, and the consequence — not the fix.
92+
*
93+
* Zero production change. `hub/src/*.ts` md5 stable throughout.
94+
*/
95+
import { describe, it, expect } from 'vitest';
96+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
97+
import { join, dirname } from 'node:path';
98+
import { fileURLToPath } from 'node:url';
99+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
100+
import { tmpdir } from 'node:os';
101+
import { ClawDB } from '../src/db.js';
102+
import { loadConfigFile } from '../src/config_file.js';
103+
import { buildDefaultConfig } from '../src/default_config.js';
104+
import { printEndpointsBanner } from '../src/startup_banner.js';
105+
106+
const HERE = dirname(fileURLToPath(import.meta.url));
107+
const SRC = join(HERE, '..', 'src');
108+
109+
/** R421: a subject that vanishes on read failure turns a green test into a lie. */
110+
function readSrc(file: string): string {
111+
const p = join(SRC, file);
112+
expect(existsSync(p), `subject file missing: hub/src/${file}`).toBe(true);
113+
return readFileSync(p, 'utf8');
114+
}
115+
116+
/** The config-file surface itself: load a JSON object through the real loader. */
117+
function loadConfig(obj: unknown): any {
118+
const dir = mkdtempSync(join(tmpdir(), 'r512cfg-'));
119+
try {
120+
const p = join(dir, 'config.json');
121+
writeFileSync(p, JSON.stringify(obj));
122+
return loadConfigFile(p, buildDefaultConfig());
123+
} finally {
124+
rmSync(dir, { recursive: true, force: true });
125+
}
126+
}
127+
128+
/** Capture the endpoints banner without letting it reach stdout. */
129+
function bannerFor(config: any): string[] {
130+
const lines: string[] = [];
131+
const orig = console.log;
132+
console.log = (...a: unknown[]) => { lines.push(a.join(' ')); };
133+
try { printEndpointsBanner(config, undefined); } finally { console.log = orig; }
134+
return lines;
135+
}
136+
137+
describe('R512 — the config file has no interpreter: a misspelled key is a silent no-op', () => {
138+
it('CONTROL: the loader really does merge a file over the defaults', () => {
139+
const c = loadConfig({ port: 9999 });
140+
expect(c.port).toBe(9999);
141+
expect(c.host).toBe(buildDefaultConfig().host);
142+
});
143+
144+
it('P1: the operator\'s misspelled key survives the merge verbatim, unconsumed', () => {
145+
const c = loadConfig({ encryption: { enable: true, passphrase: 'a-long-passphrase' } });
146+
// the INTENT is present in the object...
147+
expect((c.encryption as any).enable).toBe(true);
148+
// ...and the field the production code reads is absent.
149+
expect(c.encryption?.enabled).toBeUndefined();
150+
});
151+
152+
it('P1b: a config naming a schema version the hub has never heard of is accepted silently', () => {
153+
const c = loadConfig({ port: 9999, schemaVersion: '9.9', capabilities: { quantum: true } });
154+
expect(c.port).toBe(9999);
155+
// the unknown keys are KEPT, and nothing refused the file
156+
expect((c as any).schemaVersion).toBe('9.9');
157+
expect((c as any).capabilities).toEqual({ quantum: true });
158+
});
159+
160+
it('P1c: the loader has no schema check — it is a bare spread over the parsed JSON', () => {
161+
const body = readSrc('config_file.ts');
162+
// the whole merge is one spread; there is no key allow-list anywhere
163+
expect(body).toContain('{ ...base, ...fileConfig }');
164+
expect(body).not.toMatch(/allowedKeys|knownKeys|validateConfig|assertConfig|schema/i);
165+
});
166+
});
167+
168+
describe('R512 P2 — CONTROLLED A/B: the typo is CAUSAL, not correlational', () => {
169+
const CANARY = 'PLAINTEXT_CANARY_VALUE_9f3a';
170+
171+
async function arm(encryptionBlock: any): Promise<{ encOnDisk: boolean; plainOnDisk: boolean }> {
172+
const dir = mkdtempSync(join(tmpdir(), 'r512ab-'));
173+
try {
174+
const dbPath = join(dir, 'woclaw.db');
175+
const db = new ClawDB({
176+
port: 0,
177+
restPort: 0,
178+
host: '127.0.0.1',
179+
dataDir: dir,
180+
authToken: 'probe-token-abcdef',
181+
storage: { type: 'sqlite', sqlitePath: dbPath },
182+
encryption: encryptionBlock,
183+
} as any);
184+
await db.setMemory('r512secret', CANARY, 'probe', [], 0);
185+
await db.getMemory('r512secret');
186+
187+
// WAL mode: the freshly-written bytes may live in the -wal sidecar, so
188+
// BOTH files must be scanned. (R512 instrument failure 2.)
189+
let encOnDisk = false;
190+
let plainOnDisk = false;
191+
for (const f of [dbPath, dbPath + '-wal']) {
192+
if (!existsSync(f)) continue;
193+
const bytes = readFileSync(f);
194+
if (bytes.includes(Buffer.from('ENC:v1:'))) encOnDisk = true;
195+
if (bytes.includes(Buffer.from(CANARY))) plainOnDisk = true;
196+
}
197+
return { encOnDisk, plainOnDisk };
198+
} finally {
199+
rmSync(dir, { recursive: true, force: true });
200+
}
201+
}
202+
203+
it('the CORRECT spelling encrypts at rest (the control arm: the detector fires)', async () => {
204+
const r = await arm({ enabled: true, passphrase: 'a-long-passphrase' });
205+
expect(r.encOnDisk).toBe(true);
206+
expect(r.plainOnDisk).toBe(false);
207+
});
208+
209+
it('the MISSPELLED spelling leaves the value in PLAINTEXT on disk', async () => {
210+
const r = await arm({ enable: true, passphrase: 'a-long-passphrase' });
211+
expect(r.encOnDisk).toBe(false);
212+
expect(r.plainOnDisk).toBe(true);
213+
});
214+
215+
it('the misspelled arm is BYTE-IDENTICAL to "encryption never configured"', async () => {
216+
const typo = await arm({ enable: true, passphrase: 'a-long-passphrase' });
217+
const off = await arm(undefined);
218+
expect(typo.encOnDisk).toBe(off.encOnDisk);
219+
expect(typo.plainOnDisk).toBe(off.plainOnDisk);
220+
});
221+
222+
it('=> the typo is CAUSAL: the three arms are not all equal', async () => {
223+
const typo = await arm({ enable: true, passphrase: 'a-long-passphrase' });
224+
const correct = await arm({ enabled: true, passphrase: 'a-long-passphrase' });
225+
const off = await arm(undefined);
226+
expect(correct.encOnDisk).toBe(true);
227+
expect(typo.encOnDisk).toBe(false);
228+
expect(off.encOnDisk).toBe(false);
229+
// the correct arm differs from BOTH others
230+
expect(correct.encOnDisk).not.toBe(typo.encOnDisk);
231+
expect(correct.encOnDisk).not.toBe(off.encOnDisk);
232+
});
233+
});
234+
235+
describe('R512 P4 — the banner advertises the REQUESTED port, not the BOUND port', () => {
236+
it('the banner prints config.port verbatim', () => {
237+
const body = readSrc('startup_banner.ts');
238+
expect(body).toContain('${wsProto}://${config.host}:${config.port}');
239+
// and never the bound port
240+
expect(body).not.toMatch(/boundPort|whenListening/);
241+
});
242+
243+
it('the server KNOWS the bound port — the information exists and is unused by the banner', () => {
244+
const ws = readSrc('ws_server.ts');
245+
expect(ws).toContain('this.boundPort = addr.port');
246+
expect(ws).toContain('async whenListening()');
247+
});
248+
249+
it('with port 0 the banner shows ":0" while the server bound an ephemeral port', () => {
250+
const config: any = { port: 0, restPort: 0, host: '127.0.0.1' };
251+
const lines = bannerFor(config);
252+
const wsLine = lines.find(l => l.includes('WebSocket:')) ?? '';
253+
expect(wsLine).toContain(':0');
254+
// ":0" is not a connectable endpoint — the advertised URL is not the one
255+
// serving traffic. The banner is printing a REQUEST, not a FACT.
256+
expect(wsLine).not.toMatch(/:\d{4,5}$/);
257+
});
258+
});
259+
260+
describe('R512 P5 — the encryption state is observable nowhere', () => {
261+
it('the startup banner prints no encryption state', () => {
262+
const body = readSrc('startup_banner.ts');
263+
expect(body).not.toMatch(/encryption/i);
264+
});
265+
266+
it('no REST endpoint reports the encryption state', () => {
267+
const rest = readSrc('rest_server.ts');
268+
expect(rest).not.toMatch(/encryption/i);
269+
});
270+
271+
it('the config type HAS the field, so the state is knowable — it is simply not reported', () => {
272+
const types = readSrc('types.ts');
273+
expect(types).toMatch(/encryption\?: \{/);
274+
expect(types).toMatch(/enabled\?: boolean/);
275+
});
276+
277+
it('M5b SURVIVED: adding a configVersion reader is NOT the same as reporting it', () => {
278+
// The mutation matrix killed M5 (an `encryption` state export) but SURVIVED
279+
// M5b (a `configVersion` export). The difference is the SUBJECT: the suite
280+
// pins the encryption state, not "some config introspection". This arm
281+
// makes that boundary explicit so a later round does not widen the suite
282+
// into a general "the config is introspectable" claim it has not earned.
283+
//
284+
// NOTE the direction: `rest_server.ts` has NO `encryption` token at all
285+
// (that IS the P5 finding). So the negative assertion below is the one
286+
// that holds today, and the M5 mutation flips it to RED — which is how M5
287+
// was killed.
288+
const rest = readSrc('rest_server.ts');
289+
expect(rest).not.toMatch(/encryption/i);
290+
expect(rest).not.toMatch(/configVersion/);
291+
});
292+
});
293+
294+
describe('R512 — the asymmetry this suite is named for', () => {
295+
it('the LOUD error mode (wrong TYPE at top level) is R487\'s; this suite pins the SILENT one', () => {
296+
// A wrong type at top level crashes the boot banner. Proved by R487; here
297+
// we only assert that the mechanism still exists, so that if it is ever
298+
// fixed this suite's asymmetry claim stops being true and must be revisited.
299+
const banner = readSrc('startup_banner.ts');
300+
expect(banner).toContain('config.authToken.substring(0, 8)');
301+
});
302+
303+
it('the merge itself cannot distinguish "key absent" from "key misspelled"', () => {
304+
// A spread has no notion of a key it does not recognise. This is the
305+
// structural reason the silent mode exists and the loud mode does not.
306+
const body = readSrc('config_file.ts');
307+
expect(body).toContain('JSON.parse(readFileSync(configPath, \'utf-8\'))');
308+
expect(body).toContain('{ ...base, ...fileConfig }');
309+
});
310+
});

0 commit comments

Comments
 (0)