diff --git a/apps/gateway/src/database/database.module.spec.ts b/apps/gateway/src/database/database.module.spec.ts index 1cb1b7ce4..246b761f7 100644 --- a/apps/gateway/src/database/database.module.spec.ts +++ b/apps/gateway/src/database/database.module.spec.ts @@ -196,3 +196,76 @@ describe('KBN-101-02a DatabaseModule — state lives under the declared root', ( expect(dataDir.startsWith(`${stateRoot}/`)).toBe(true); }); }); + +/** + * The lifecycle, not just the factories. + * + * Provider factories construct handles; `onModuleInit()` is what actually runs + * DDL — `runPgliteMigrations(handle)` and `storageAdapter.migrate()`. A guard + * placed only in the factories leaves that path reachable whenever a handle is + * obtained by any other route, and every case above would stay green. These + * cases drive the lifecycle hook directly. + */ +describe('KBN-101-02a DatabaseModule — migrations are a lifecycle effect, not a factory effect', () => { + interface Handle { + db: unknown; + close: () => void; + } + + async function instantiate(config: unknown): Promise<{ + instance: { onModuleInit: () => Promise }; + adapterMigrate: ReturnType; + }> { + const mod = await import('./database.module.js'); + const adapterMigrate = vi.fn(async () => {}); + const handle: Handle = { db: {}, close: vi.fn() }; + const adapter = { name: 'mock', migrate: adapterMigrate, close: vi.fn(async () => {}) }; + const instance = new mod.DatabaseModule( + handle as never, + adapter as never, + config as never, + ) as unknown as { onModuleInit: () => Promise }; + return { instance, adapterMigrate }; + } + + it('refuses to migrate a postgres storage shape while the local policy is established', async () => { + vi.resetModules(); + await establishLocalChannel(); + const { instance, adapterMigrate } = await instantiate({ + tier: 'local', + storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5433/mosaic' }, + }); + + await expect(instance.onModuleInit()).rejects.toThrow(/LOCAL_START_/); + expect(adapterMigrate, 'storage migrate() ran on a refusing start').not.toHaveBeenCalled(); + expect(runPgliteMigrationsMock).not.toHaveBeenCalled(); + }); + + it('refuses to migrate a non-local tier while the local policy is established', async () => { + vi.resetModules(); + await establishLocalChannel(); + const { instance, adapterMigrate } = await instantiate({ + tier: 'standalone', + storage: { type: 'pglite' }, + }); + + await expect(instance.onModuleInit()).rejects.toThrow(/LOCAL_START_/); + expect(adapterMigrate).not.toHaveBeenCalled(); + expect(runPgliteMigrationsMock).not.toHaveBeenCalled(); + }); + + it('migrates against the frozen policy rather than the injected config', async () => { + vi.resetModules(); + await establishLocalChannel(); + // The injected config claims a tier the policy does not allow. A lifecycle + // hook that branches on `this.config.tier` follows the injected value; one + // that reads the frozen policy does not. + const { instance, adapterMigrate } = await instantiate({ + tier: 'standalone', + storage: { type: 'pglite' }, + }); + + await instance.onModuleInit().catch(() => undefined); + expect(adapterMigrate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/local-dev.spec.ts b/apps/gateway/src/local-dev.spec.ts index 40ae7ab26..f70414cb0 100644 --- a/apps/gateway/src/local-dev.spec.ts +++ b/apps/gateway/src/local-dev.spec.ts @@ -10,6 +10,8 @@ * zero calls to each. */ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync } from 'node:fs'; import { readFile, mkdtemp, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -100,12 +102,36 @@ async function declareAdmissibleLocalStart(mod: LocalDevModule): Promise describe('KBN-101-02a local-dev — row 9, import order is structural', () => { it('does not statically import main.js: the module able to start must not load first', async () => { const source = await readFile(join(HERE, 'local-dev.ts'), 'utf8'); - const staticMainImport = /^\s*import\s[^\n]*from\s+['"]\.\/main\.js['"]/m; + + // Both static forms, because they have identical effect and only one of them + // has a `from` clause. A guard written against `import … from './main.js'` + // alone is satisfied by the bare side-effect form `import './main.js'`, + // which loads the module graph just as eagerly — the guard would be green + // while the property it names is false. + const staticBindingImport = /^\s*import\s[^\n]*from\s+['"]\.\/main\.js['"]/m; + const staticSideEffectImport = /^\s*import\s+['"]\.\/main\.js['"]/m; + const staticRequire = /require\(\s*['"]\.\/main\.js['"]\s*\)/; // A static import would load main.js — and everything it pulls in, up to and // including the DB client — before the policy exists. "Refused before // connection" then becomes an assertion rather than a structural property. - expect(staticMainImport.test(source)).toBe(false); + expect(staticBindingImport.test(source), 'static binding import of main.js').toBe(false); + expect(staticSideEffectImport.test(source), 'static side-effect import of main.js').toBe(false); + expect(staticRequire.test(source), 'static require of main.js').toBe(false); + }); + + // The negative guards above are satisfied by a module that never references + // main.js at all. This is the positive half: the dynamic import must exist and + // must sit after the establish call, so removing the establish line cannot + // leave the file passing. + it('places the dynamic import of main.js after the establish call, in source order', async () => { + const source = await readFile(join(HERE, 'local-dev.ts'), 'utf8'); + const dynamicImport = source.search(/await\s+import\(\s*['"]\.\/main\.js['"]\s*\)/); + const establish = source.search(/establishFrozenLocalPolicy\s*\(/); + + expect(dynamicImport, 'no dynamic import of main.js').toBeGreaterThan(-1); + expect(establish, 'no establishFrozenLocalPolicy call').toBeGreaterThan(-1); + expect(establish).toBeLessThan(dynamicImport); }); it('dynamically imports main.js', async () => { @@ -226,3 +252,94 @@ describe('KBN-101-02a local-dev — the channel has its own command', () => { expect(manifest.scripts?.['dev']).toBe('tsx watch src/main.ts'); }); }); + +/** + * The package script, executed rather than read. + * + * The string assertions above are satisfied by `"dev:local": "tsx src/local-dev.ts"` + * pointed at a module that exports `runLocalDev()` and never calls it — which is + * exactly what this file is today: running that command right now exits 0 having + * started nothing and refused nothing. A route that silently succeeds is worse + * than one that is missing, because the operator's shell says it worked. + * + * Safety of running the real route in-band: the child is given no + * BETTER_AUTH_SECRET and no DATABASE_URL. If a future GREEN wrongly reached + * `main.ts`, `bootstrap()` throws on the missing secret at its first statement, + * before `loadConfig()` and before `detectAndAssertTier()` — so no configuration + * is read, no connection is attempted, and nothing listens. The assertions below + * treat that path as a FAILURE rather than a pass, so it cannot be mistaken for + * a refusal. + */ +describe('KBN-101-02a local-dev — the package route refuses, not merely the module', () => { + const GATEWAY_DIR = join(HERE, '..'); + const CHILD_TIMEOUT_MS = 120_000; + + function runDevLocal(extraEnv: Record = {}): { + status: number | null; + signal: NodeJS.Signals | null; + output: string; + } { + const manifest = JSON.parse(readFileSync(join(GATEWAY_DIR, 'package.json'), 'utf8')) as { + scripts?: Record; + }; + const script = manifest.scripts?.['dev:local']; + expect(script, 'no dev:local script to execute').toBeDefined(); + + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value === undefined) { + continue; + } + // Ambient declarations would defeat the point: this case is about what the + // route does when nothing has been declared. + if (key.startsWith('MOSAIC_LOCAL_DEV_') || key === 'DATABASE_URL') { + continue; + } + env[key] = value; + } + delete env['BETTER_AUTH_SECRET']; + + const result = spawnSync('sh', ['-c', script as string], { + cwd: GATEWAY_DIR, + env: { ...env, ...extraEnv }, + encoding: 'utf8', + timeout: CHILD_TIMEOUT_MS, + killSignal: 'SIGKILL', + }); + + return { + status: result.status, + signal: result.signal, + output: `${result.stdout ?? ''}${result.stderr ?? ''}`, + }; + } + + it('exits nonzero with a refusal code when nothing is declared', () => { + const { status, signal, output } = runDevLocal(); + + expect(signal, 'the route hung and had to be killed').toBeNull(); + expect(status, 'the route exited 0 with nothing declared').not.toBe(0); + // Nonzero alone would be satisfied by a crash, a missing dependency, or the + // BETTER_AUTH_SECRET throw inside bootstrap — all of which mean the refusal + // did NOT happen and the start was merely broken. + expect(output).toMatch(/LOCAL_START_[A-Z_]+/); + expect(output, 'exited via bootstrap rather than refusing before it').not.toContain( + 'BETTER_AUTH_SECRET', + ); + }); + + it('exits nonzero with a DSN refusal, and echoes no DSN, when one is inherited', () => { + const { status, signal, output } = runDevLocal({ + DATABASE_URL: 'postgresql://mosaic:mosaic@localhost:5433/mosaic', + MOSAIC_LOCAL_DEV_TIER: 'local', + MOSAIC_LOCAL_DEV_STORAGE: 'pglite', + MOSAIC_LOCAL_DEV_QUEUE: 'local', + MOSAIC_LOCAL_DEV_STATE_ROOT: mkdtempSync(join(tmpdir(), 'kbn-02a-route-')), + }); + + expect(signal, 'the route hung and had to be killed').toBeNull(); + expect(status).not.toBe(0); + expect(output).toContain('LOCAL_START_POSTGRES_DSN_PRESENT'); + expect(output, 'the refusal echoed the DSN it refused').not.toContain('mosaic:mosaic'); + }); +}); diff --git a/apps/gateway/src/local-start/local-start-policy.spec.ts b/apps/gateway/src/local-start/local-start-policy.spec.ts index 127534232..985a3a1b5 100644 --- a/apps/gateway/src/local-start/local-start-policy.spec.ts +++ b/apps/gateway/src/local-start/local-start-policy.spec.ts @@ -10,10 +10,11 @@ * Nothing here connects to anything. DSN strings are fixture text. */ -import { mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises'; +import type * as NodeFs from 'node:fs'; +import { chmod, mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type * as PolicyNamespace from './local-start-policy.js'; import type { @@ -21,6 +22,7 @@ import type { LocalStartDeclaration, LocalStartRefusalCode, } from './local-start-policy.js'; +import type { LocalStartSourceLabel } from './source-inventory.js'; const FORBIDDEN_DSN = 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; @@ -32,6 +34,16 @@ async function freshPolicy(): Promise { return import('./local-start-policy.js'); } +/** Undo module substitutions so one case's mock cannot leak into the next. */ +const mockedFsCases: (() => void)[] = []; + +afterEach(() => { + while (mockedFsCases.length > 0) { + mockedFsCases.pop()?.(); + } + vi.resetModules(); +}); + interface Bed { tempRoot: string; anchor: string; @@ -275,21 +287,125 @@ describe('KBN-101-02a establishFrozenLocalPolicy — row 8, PGlite and disposabl ); }); + // The traversal segment is concatenated, NOT built with join(). `join()` + // normalizes `a/../a` to `a` before the policy is ever called, so a case built + // that way asserts a refusal on a string identical to the valid one — it can + // only be made green by also refusing the valid declaration. A red that cannot + // go green for its stated reason is the same defect class as a green that + // cannot go red. it('refuses a state root that is not its own canonical form', async () => { const bed = await makeBed(); const policy = await freshPolicy(); + const noncanonical = `${bed.tempRoot}/session-state/../session-state`; + expect(noncanonical).not.toBe(bed.stateRoot); + expect(noncanonical).toContain('/../'); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), stateRoot: noncanonical }, + }), + 'LOCAL_START_STATE_ROOT_ESCAPES', + ); + }); + + it('refuses a state root that does not exist rather than creating it', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + const absent = join(bed.tempRoot, 'never-created'); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), stateRoot: absent }, + }), + 'LOCAL_START_STATE_ROOT_ABSENT', + ); + }); + + it('refuses a state root that exists but is a file', async () => { + const bed = await makeBed(); + const notADir = join(bed.tempRoot, 'a-file'); + await writeFile(notADir, 'not a directory\n', 'utf8'); + const policy = await freshPolicy(); await expectRefusal( policy, establishInput(bed, { - declaration: { - ...validDeclaration(bed.stateRoot), - stateRoot: join(bed.tempRoot, 'session-state', '..', 'session-state'), - }, + declaration: { ...validDeclaration(bed.stateRoot), stateRoot: notADir }, }), - 'LOCAL_START_STATE_ROOT_ESCAPES', + 'LOCAL_START_STATE_ROOT_NOT_DIRECTORY', ); }); + // lstat, not stat: a stat on a symlinked root reports the TARGET, so every + // later bar would describe a directory the operator never named. + it('refuses a state root that is itself a symlink, even to a valid directory', async () => { + const bed = await makeBed(); + const realRoot = join(bed.tempRoot, 'real-root'); + await mkdir(realRoot, { recursive: true, mode: 0o700 }); + const linkedRoot = join(bed.tempRoot, 'linked-root'); + await symlink(realRoot, linkedRoot, 'dir'); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), stateRoot: linkedRoot }, + }), + 'LOCAL_START_STATE_ROOT_IS_SYMLINK', + ); + }); + + it('refuses a state root readable by group or other', async () => { + const bed = await makeBed(); + const shared = join(bed.tempRoot, 'shared-root'); + await mkdir(shared, { recursive: true }); + await chmod(shared, 0o755); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), stateRoot: shared }, + }), + 'LOCAL_START_STATE_ROOT_NOT_PRIVATE', + ); + }); + + // Ownership is the one bar that cannot be built on the filesystem without + // privileges: an unprivileged process cannot chown, and a privileged one owns + // everything it creates, so a filesystem-built case would pass trivially + // wherever CI runs as root. That is a vacuous case, not a cheap one. The stat + // result is therefore substituted directly. This couples the case to the + // mechanism (the implementation must learn ownership from an lstat), which is + // inherent to the bar rather than an accident of this test. + it('refuses a state root owned by another user', async () => { + const bed = await makeBed(); + const foreignUid = (process.getuid?.() ?? 0) + 1; + vi.resetModules(); + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + lstatSync: (target: string, ...rest: unknown[]) => { + const stats = ( + actual.lstatSync as unknown as (...args: unknown[]) => Record + )(target, ...rest); + if (target === bed.stateRoot) { + return { + ...stats, + uid: foreignUid, + isDirectory: () => true, + isSymbolicLink: () => false, + }; + } + return stats; + }, + }; + }); + mockedFsCases.push(() => { + vi.doUnmock('node:fs'); + }); + const policy = await import('./local-start-policy.js'); + await expectRefusal(policy, establishInput(bed), 'LOCAL_START_STATE_ROOT_NOT_OWNED'); + }); + it('refuses when a derived child escapes the state root through a symlink', async () => { const bed = await makeBed(); const escapeTarget = join(bed.tempRoot, 'outside'); @@ -384,3 +500,137 @@ describe('KBN-101-02a establishFrozenLocalPolicy — row 9, one-shot freeze', () expect((thrown as { code?: string }).code).toBe('LOCAL_START_POLICY_NOT_ESTABLISHED'); }); }); + +/** Where each file source lives, relative to the bed's anchor and home base. */ +function sourcePath( + bed: Bed, + label: Exclude, +): string { + const repo = join(bed.anchor, '..', '..', '..'); + const daemonDir = join(bed.homeBase, '.config', 'mosaic', 'gateway'); + const paths: Record, string> = { + 'daemon-dotenv': join(daemonDir, '.env'), + 'monorepo-root-dotenv': join(repo, '.env'), + 'gateway-local-dotenv': join(repo, 'apps', 'gateway', '.env'), + 'daemon-json': join(daemonDir, 'mosaic.config.json'), + 'app-json': join(repo, 'apps', 'gateway', 'mosaic.config.json'), + 'monorepo-root-json': join(repo, 'mosaic.config.json'), + }; + return paths[label]; +} + +const CLEAN_LOCAL_JSON = JSON.stringify({ + tier: 'local', + storage: { type: 'pglite' }, + queue: { type: 'local' }, + memory: { type: 'none' }, +}); + +async function writeSource(bed: Bed, label: Parameters[1], body: string) { + const path = sourcePath(bed, label); + await mkdir(join(path, '..'), { recursive: true }); + await writeFile(path, body, 'utf8'); + return path; +} + +/** + * A source that cannot be read or parsed has UNKNOWN contents, and an unknown + * source has not been shown to be free of a forbidden value. Skipping it — or + * letting a clean higher-precedence source stand in for it — is precisely the + * "a forbidden value refuses even when shadowed" rule failing in its other + * direction: here the forbidden value is not absent, it is unreadable. + */ +describe('KBN-101-02a establishFrozenLocalPolicy — unreadable and malformed sources', () => { + it('refuses malformed JSON in a LOSING source even when the winning source is clean', async () => { + const bed = await makeBed(); + await writeSource(bed, 'monorepo-root-json', CLEAN_LOCAL_JSON); + await writeSource(bed, 'app-json', '{ "tier": "local", '); + const policy = await freshPolicy(); + await expectRefusal(policy, establishInput(bed), 'LOCAL_START_SOURCE_MALFORMED'); + }); + + it('refuses malformed JSON in the daemon config specifically', async () => { + const bed = await makeBed(); + await writeSource(bed, 'monorepo-root-json', CLEAN_LOCAL_JSON); + await writeSource(bed, 'daemon-json', 'not json at all'); + const policy = await freshPolicy(); + await expectRefusal(policy, establishInput(bed), 'LOCAL_START_SOURCE_MALFORMED'); + }); + + // A directory where a file is expected fails the read with EISDIR for every + // uid. Mode 0o000 would not: a process running as root reads it anyway, so + // that case would pass trivially in exactly the environments CI uses. + it('refuses a source that cannot be read at all', async () => { + const bed = await makeBed(); + await writeSource(bed, 'monorepo-root-json', CLEAN_LOCAL_JSON); + await mkdir(sourcePath(bed, 'gateway-local-dotenv'), { recursive: true }); + const policy = await freshPolicy(); + await expectRefusal(policy, establishInput(bed), 'LOCAL_START_SOURCE_UNREADABLE'); + }); + + it('names the offending source label without quoting its contents', async () => { + const bed = await makeBed(); + await writeSource(bed, 'app-json', `{ "tier": "local", "note": "${FORBIDDEN_DSN}"`); + const policy = await freshPolicy(); + let thrown: unknown; + try { + policy.establishFrozenLocalPolicy(establishInput(bed)); + } catch (error) { + thrown = error; + } + const refusal = thrown as InstanceType; + expect(refusal?.code).toBe('LOCAL_START_SOURCE_MALFORMED'); + expect(refusal.sourceLabels).toContain('app-json'); + expect(`${refusal.message} ${JSON.stringify(refusal.sourceLabels)}`).not.toContain( + FORBIDDEN_DSN, + ); + }); +}); + +/** + * The freeze is a promise about the FUTURE, and a snapshot taken at establish + * time proves nothing about it. These cases mutate the underlying sources after + * establish and then read the policy back: a consumer that re-reads a file on + * demand, or holds a live reference into one, fails here and passes every case + * that only inspects the object returned at establish time. + */ +describe('KBN-101-02a establishFrozenLocalPolicy — the freeze survives source mutation', () => { + it('retains the frozen policy after a source file is rewritten to carry a DSN', async () => { + const bed = await makeBed(); + await writeSource(bed, 'monorepo-root-json', CLEAN_LOCAL_JSON); + const policy = await freshPolicy(); + const established = policy.establishFrozenLocalPolicy(establishInput(bed)); + + await writeSource( + bed, + 'monorepo-root-json', + JSON.stringify({ + tier: 'standalone', + storage: { type: 'postgres', url: FORBIDDEN_DSN }, + queue: { type: 'bullmq' }, + memory: { type: 'none' }, + }), + ); + + const after = policy.requireFrozenLocalPolicy(); + expect(after).toBe(established); + expect(after.tier).toBe('local'); + expect(after.storage.type).toBe('pglite'); + expect(JSON.stringify(after)).not.toContain(FORBIDDEN_DSN); + }); + + it('retains the frozen policy after the ambient environment gains a DSN', async () => { + const bed = await makeBed(); + await writeSource(bed, 'monorepo-root-json', CLEAN_LOCAL_JSON); + const policy = await freshPolicy(); + const env: Record = {}; + const established = policy.establishFrozenLocalPolicy(establishInput(bed, { env })); + + env['DATABASE_URL'] = FORBIDDEN_DSN; + + const after = policy.requireFrozenLocalPolicy(); + expect(after).toBe(established); + expect(after.storage.type).toBe('pglite'); + expect(JSON.stringify(after)).not.toContain(FORBIDDEN_DSN); + }); +}); diff --git a/apps/gateway/src/local-start/local-start-policy.ts b/apps/gateway/src/local-start/local-start-policy.ts index 8712cf579..3ed5315b4 100644 --- a/apps/gateway/src/local-start/local-start-policy.ts +++ b/apps/gateway/src/local-start/local-start-policy.ts @@ -25,6 +25,13 @@ export type LocalStartRefusalCode = | 'LOCAL_START_STATE_ROOT_MISSING' | 'LOCAL_START_STATE_ROOT_NOT_ABSOLUTE' | 'LOCAL_START_STATE_ROOT_ESCAPES' + | 'LOCAL_START_STATE_ROOT_ABSENT' + | 'LOCAL_START_STATE_ROOT_NOT_DIRECTORY' + | 'LOCAL_START_STATE_ROOT_IS_SYMLINK' + | 'LOCAL_START_STATE_ROOT_NOT_PRIVATE' + | 'LOCAL_START_STATE_ROOT_NOT_OWNED' + | 'LOCAL_START_SOURCE_UNREADABLE' + | 'LOCAL_START_SOURCE_MALFORMED' | 'LOCAL_START_CROSS_FIELD_MISMATCH' | 'LOCAL_START_ALREADY_ESTABLISHED' | 'LOCAL_START_POLICY_NOT_ESTABLISHED'; @@ -48,6 +55,34 @@ export class LocalStartRefusal extends Error { } } +/** + * The disposable state-root contract, stated so it is testable. + * + * "Disposable" is not a property of the path string; it is a property of the + * directory the string names. A declared root is admitted only when ALL of the + * following hold, each with its own refusal code so a removed guard turns + * exactly one case red: + * + * 1. absolute (`LOCAL_START_STATE_ROOT_NOT_ABSOLUTE`); + * 2. already its own canonical form, lexically (`LOCAL_START_STATE_ROOT_ESCAPES`); + * 3. it exists (`LOCAL_START_STATE_ROOT_ABSENT`) — the local channel does not + * create the root, because a channel that creates its own root cannot + * distinguish "session-scoped" from "silently reused"; + * 4. it is a directory (`LOCAL_START_STATE_ROOT_NOT_DIRECTORY`); + * 5. the root itself is not a symlink (`LOCAL_START_STATE_ROOT_IS_SYMLINK`) — + * checked with lstat, since a stat on a symlinked root reports the target and + * every subsequent check would describe a directory the operator did not name; + * 6. it is owned by the current user (`LOCAL_START_STATE_ROOT_NOT_OWNED`); + * 7. it is private — no group or other permission bits + * (`LOCAL_START_STATE_ROOT_NOT_PRIVATE`); + * 8. no derived child escapes it through a symlink (`LOCAL_START_STATE_ROOT_ESCAPES`). + * + * Only 1, 2 and 8 were covered by the first RED pass. A derived-child symlink + * check alone admits a root that is missing, is a file, is a symlink into + * someone else's directory, or is world-writable — none of which is disposable + * in any sense that protects the operator's data. + */ + /** Explicit declarations supplied for THIS invocation. Nothing is defaulted. */ export interface LocalStartDeclaration { readonly tier?: string; @@ -81,8 +116,10 @@ let established: FrozenLocalPolicy | undefined; * - refuses any PostgreSQL DSN or non-local tier found in ANY source, shadowed * or not; * - refuses cross-field mismatches (local+postgres, local+files, non-local+pglite); - * - canonicalizes DB/storage/queue children under the state root and refuses - * traversal or symlink escape; never uses HOME/CWD defaults; + * - refuses a source it cannot read or parse, EVEN when a clean source would win + * precedence — an unknown source has not been shown to be safe; + * - enforces the full disposable state-root contract above; never uses HOME/CWD + * defaults; * - deep-clones and deep-freezes an allowed-only object; * - a second call refuses with LOCAL_START_ALREADY_ESTABLISHED; there is no reset. */ diff --git a/apps/gateway/src/local-start/source-inventory.spec.ts b/apps/gateway/src/local-start/source-inventory.spec.ts index 188e6b731..e9d249870 100644 --- a/apps/gateway/src/local-start/source-inventory.spec.ts +++ b/apps/gateway/src/local-start/source-inventory.spec.ts @@ -14,7 +14,7 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { LOCAL_START_SOURCE_LABELS, @@ -291,3 +291,151 @@ describe('KBN-101-02a inventoryLocalStartSources', () => { expect(JSON.stringify(sources)).not.toContain('mosaic:mosaic'); }); }); + +/** + * Tier and DSN are separate forbidden classes and must be separately detected. + * + * The DSN cases above would all still pass if the tier check were deleted + * outright, because every DSN fixture also declares a non-local tier. These + * cases plant a non-local tier and NO DSN, in exactly one source at a time, so + * deleting the tier guard for one source turns exactly one case red and the DSN + * cases stay green. + */ +const NON_LOCAL_TIER_JSON = JSON.stringify({ + tier: 'standalone', + storage: { type: 'pglite' }, + queue: { type: 'local' }, + memory: { type: 'none' }, +}); + +async function plantNonLocalTierIn( + layout: Layout, + target: Exclude, +): Promise { + for (const [label, path] of Object.entries(layout.paths)) { + const isTarget = label === target; + if (label.endsWith('json')) { + await writeFixture(path, isTarget ? NON_LOCAL_TIER_JSON : CLEAN_LOCAL_JSON); + continue; + } + await writeFixture( + path, + isTarget ? 'MOSAIC_STORAGE_TIER=standalone\n' : 'MOSAIC_LOG_LEVEL=info\n', + ); + } +} + +describe('KBN-101-02a inventoryLocalStartSources — non-local tier, per source, without any DSN', () => { + const fileLabels: Exclude[] = [ + 'daemon-dotenv', + 'monorepo-root-dotenv', + 'gateway-local-dotenv', + 'daemon-json', + 'app-json', + 'monorepo-root-json', + ]; + + for (const label of fileLabels) { + it(`classifies a non-local tier declared only in ${label}`, async () => { + const layout = await makeLayout(); + await plantNonLocalTierIn(layout, label); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + expect(find(sources, label).declaredTier).toBe('non-local'); + expect(find(sources, label).carriesPostgresDsn).toBe(false); + for (const other of fileLabels.filter((candidate) => candidate !== label)) { + expect(find(sources, other).declaredTier).not.toBe('non-local'); + } + }); + } + + it('classifies a non-local tier inherited from the ambient process environment', async () => { + const layout = await makeLayout(); + for (const [label, path] of Object.entries(layout.paths)) { + await writeFixture( + path, + label.endsWith('json') ? CLEAN_LOCAL_JSON : 'MOSAIC_LOG_LEVEL=info\n', + ); + } + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: { MOSAIC_STORAGE_TIER: 'federated' }, + }); + + expect(find(sources, 'process-environment').declaredTier).toBe('non-local'); + expect(find(sources, 'process-environment').carriesPostgresDsn).toBe(false); + }); +}); + +/** + * Classification, not quotation. + * + * `declaredTier: string` is value-free only by convention — it holds whatever + * the file said. These cases plant a canary in the value position of every + * source and assert it appears nowhere: not in the result, and not in anything + * written to the console on the way there. The result is serialized whole rather + * than field-by-field, so a field added later is covered without anyone + * remembering to extend this case. + */ +describe('KBN-101-02a inventoryLocalStartSources — no source value is retained or logged', () => { + const CANARY = 'kbn-101-02a-canary-2f7d1c'; + + it('retains no planted canary anywhere in the result', async () => { + const layout = await makeLayout(); + for (const [label, path] of Object.entries(layout.paths)) { + await writeFixture( + path, + label.endsWith('json') + ? JSON.stringify({ + tier: CANARY, + storage: { type: CANARY, url: `postgresql://${CANARY}@localhost:5432/db` }, + queue: { type: CANARY }, + }) + : `MOSAIC_STORAGE_TIER=${CANARY}\nDATABASE_URL=postgresql://${CANARY}@localhost:5432/db\n`, + ); + } + + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: { MOSAIC_STORAGE_TIER: CANARY }, + }); + + expect(JSON.stringify(sources)).not.toContain(CANARY); + }); + + it('writes no planted canary to the console while inspecting sources', async () => { + const layout = await makeLayout(); + await writeFixture( + layout.paths['monorepo-root-json'], + JSON.stringify({ tier: CANARY, storage: { type: CANARY }, queue: { type: CANARY } }), + ); + + const written: string[] = []; + const methods = ['log', 'info', 'warn', 'error', 'debug', 'trace'] as const; + const spies = methods.map((method) => + vi.spyOn(console, method).mockImplementation((...args: unknown[]): void => { + written.push(args.map((arg) => String(arg)).join(' ')); + }), + ); + + try { + inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: { DATABASE_URL: `postgresql://${CANARY}@localhost:5432/db` }, + }); + } finally { + for (const spy of spies) { + spy.mockRestore(); + } + } + + expect(written.join('\n')).not.toContain(CANARY); + }); +}); diff --git a/apps/gateway/src/local-start/source-inventory.ts b/apps/gateway/src/local-start/source-inventory.ts index ccb4cf147..e73370fea 100644 --- a/apps/gateway/src/local-start/source-inventory.ts +++ b/apps/gateway/src/local-start/source-inventory.ts @@ -31,6 +31,28 @@ export const LOCAL_START_SOURCE_LABELS: readonly LocalStartSourceLabel[] = [ 'monorepo-root-json', ]; +/** + * Finite classifications, not values. + * + * These are unions rather than `string` on purpose. A `declaredTier?: string` + * field is value-free only by convention: it will hold whatever the source said, + * so a canary planted in a config file survives into the inventory and from + * there into anything that logs it. A union of two members cannot carry a + * canary — the type makes retention unrepresentable rather than discouraged. + */ +export type DeclaredTierClass = 'local' | 'non-local'; +export type DeclaredStorageClass = 'pglite' | 'non-pglite'; + +/** + * Whether the source could be read and parsed at all. + * + * A source that cannot be read is not the same as a source that is absent. An + * unreadable or malformed source is a source whose contents are UNKNOWN, and an + * unknown source cannot be shown to be free of a forbidden value — so it refuses + * rather than being skipped. + */ +export type SourceReadability = 'readable' | 'unreadable' | 'malformed'; + /** * What one source was observed to carry. * @@ -45,10 +67,12 @@ export interface InventoriedSource { readonly present: boolean; /** True when this source carries a PostgreSQL DSN in any recognized key. */ readonly carriesPostgresDsn: boolean; - /** Tier value declared by this source, if it declares one. */ - readonly declaredTier?: string; - /** Storage type declared by this source, if it declares one. */ - readonly declaredStorageType?: string; + /** Whether this source was readable and parseable. */ + readonly readability: SourceReadability; + /** Classification of the tier this source declares, if it declares one. */ + readonly declaredTier?: DeclaredTierClass; + /** Classification of the storage type this source declares, if it declares one. */ + readonly declaredStorageType?: DeclaredStorageClass; } export interface InventoryInput { @@ -67,6 +91,8 @@ export interface InventoryInput { * - every label in LOCAL_START_SOURCE_LABELS appears in the result, present or not; * - MOSAIC_GATEWAY_HOME redirects `daemon-json` ONLY — `daemon-dotenv` stays at * `$HOME/.config/mosaic/gateway/.env` regardless of the redirect; + * - a source that cannot be read or parsed is reported as such, never skipped + * and never treated as clean; * - no precedence is applied and no value is retained. */ export function inventoryLocalStartSources(_input: InventoryInput): InventoriedSource[] { diff --git a/apps/gateway/src/main.spec.ts b/apps/gateway/src/main.spec.ts index 10999917d..2da2b5f95 100644 --- a/apps/gateway/src/main.spec.ts +++ b/apps/gateway/src/main.spec.ts @@ -3,6 +3,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import * as nodeOs from 'node:os'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import * as nodeUrl from 'node:url'; +import type * as NestCore from '@nestjs/core'; +import type * as MosaicConfigModule from '@mosaicstack/config'; import type { MosaicConfig } from '@mosaicstack/config'; import type * as MosaicStorage from '@mosaicstack/storage'; import { describe, expect, it, vi, type MockInstance } from 'vitest'; @@ -162,3 +164,265 @@ describe('main bootstrap preflight config anchoring', (): void => { MODULE_IMPORT_TIMEOUT_MS, ); }); + +/** + * KBN-101-02a — the real consumers, executed. + * + * The local-dev REDs mock `./main.js` wholesale, which proves the entrypoint + * refuses before it calls bootstrap and proves nothing at all about what + * bootstrap, AppModule and ConfigModule do when they DO run. Those three each + * perform their own independent `loadConfig(resolveGatewayConfigPath())` — + * `main.ts:28`, `app.module.ts:39`, `config.module.ts:12` — and the frozen + * policy is worth nothing if the modules that actually start the process ignore + * it. Every case below drives the real module. + * + * `@nestjs/core` is substituted so a case that wrongly proceeds cannot listen, + * and `@mosaicstack/storage` so no tier probe can reach a real service. Those + * substitutions are also the instrument: the assertions are that they were never + * called. + * + * While red, this block also emits unhandled rejections ('process.exit + * unexpectedly called with "1"'). They are not flake and must not be silenced: + * they are the self-invocation at `main.ts:71` failing asynchronously after the + * case that imported the module has already returned. An entrypoint that starts + * nothing on import cannot produce them, so they disappear when the defect is + * fixed. If they survive GREEN, the entrypoint is still self-invoking. + * + * NOTE — the case above this block ('main bootstrap preflight config anchoring') + * asserts the CURRENT contract, in which importing `main.ts` self-invokes + * `bootstrap()` and exits the process on failure: + * + * await import('./main.js'); + * await vi.waitFor(() => { expect(exitSpy).toHaveBeenCalled(); }); + * + * That expectation is the direct opposite of the contract this item introduces, + * so it cannot survive GREEN unchanged. It is deliberately left intact here: it + * is the current behavior's regression guard, it must keep passing until the + * behavior actually changes, and the GREEN author has to migrate it to drive the + * exported `bootstrap()` explicitly rather than delete it. A red left where the + * change has to happen is cheaper than a note in a document. + */ +describe('KBN-101-02a main.ts — importing the entrypoint starts nothing', () => { + // The application double has to be complete enough for bootstrap to run all + // the way through, not merely far enough to observe the config read. A thinner + // double fails inside itself (`app.getHttpAdapter is not a function`), which + // would leave these cases unable to go green for the reason they state even + // after the defect is fixed. The HTTP instance is a permissive proxy so the + // double does not have to track every route the controllers mount. + const nestCreateMock = vi.fn(async () => ({ + enableCors: vi.fn(), + register: vi.fn(async () => {}), + useGlobalPipes: vi.fn(), + get: vi.fn(() => ({})), + getHttpAdapter: vi.fn(() => ({ + getInstance: () => new Proxy({}, { get: () => vi.fn() }), + })), + listen: vi.fn(async () => {}), + })); + const detectAndAssertTierMock = vi.fn(async () => {}); + const loadConfigMock = vi.fn(() => ({ + tier: 'local' as const, + storage: { type: 'pglite' as const, dataDir: '.mosaic/storage-pglite' }, + queue: { type: 'local' as const, dataDir: '.mosaic/queue' }, + memory: { type: 'keyword' as const }, + })); + + async function establishLocalChannel(): Promise { + const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'kbn-02a-main-')); + const anchor = join(tempRoot, 'repo', 'apps', 'gateway', 'src'); + const homeBase = join(tempRoot, 'home'); + const stateRoot = join(tempRoot, 'session-state'); + await mkdir(anchor, { recursive: true }); + await mkdir(homeBase, { recursive: true }); + await mkdir(stateRoot, { recursive: true }); + + const { establishFrozenLocalPolicy } = await import('./local-start/local-start-policy.js'); + establishFrozenLocalPolicy({ + declaration: { tier: 'local', storageType: 'pglite', queueType: 'local', stateRoot }, + anchor, + homeBase, + env: {}, + }); + return stateRoot; + } + + function substituteStartupSurface(): void { + vi.doMock('./tracing.js', () => ({})); + // Partial, never wholesale. A wholesale substitution drops APP_GUARD and + // every other export app.module.ts evaluates at import time, so the module + // throws before reaching the behavior under test — the case would then be + // red for a reason it does not state, and could never go green by fixing + // the defect it names. + vi.doMock('@nestjs/core', async () => { + const actual = await vi.importActual('@nestjs/core'); + return { ...actual, NestFactory: { ...actual.NestFactory, create: nestCreateMock } }; + }); + vi.doMock('@mosaicstack/storage', async () => { + const actual = await vi.importActual('@mosaicstack/storage'); + return { ...actual, detectAndAssertTier: detectAndAssertTierMock }; + }); + } + + function undoSubstitutions(): void { + vi.doUnmock('@mosaicstack/storage'); + vi.doUnmock('@nestjs/core'); + vi.doUnmock('./tracing.js'); + vi.doUnmock('@mosaicstack/config'); + } + + it( + 'does not bootstrap, probe, or exit merely by being imported', + async (): Promise => { + const originalEnv = snapshotProcessEnv(); + const exitMock = vi.fn(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock); + try { + delete process.env['BETTER_AUTH_SECRET']; + vi.resetModules(); + vi.clearAllMocks(); + substituteStartupSurface(); + + await import('./main.js'); + // A self-invoking entrypoint fails asynchronously, so give the rejection + // a turn to land before concluding that nothing happened. + await new Promise((resolve) => setImmediate(resolve)); + + expect(exitSpy, 'importing main.ts exited the process').not.toHaveBeenCalled(); + expect(detectAndAssertTierMock, 'importing main.ts probed the tier').not.toHaveBeenCalled(); + expect(nestCreateMock, 'importing main.ts created the Nest app').not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + undoSubstitutions(); + vi.resetModules(); + restoreProcessEnv(originalEnv); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'exports bootstrap so the local entrypoint can call it after establishing the policy', + async (): Promise => { + const originalEnv = snapshotProcessEnv(); + try { + vi.resetModules(); + substituteStartupSurface(); + const mod = (await import('./main.js')) as { bootstrap?: unknown }; + expect(typeof mod.bootstrap, 'main.ts does not export bootstrap').toBe('function'); + } finally { + undoSubstitutions(); + vi.resetModules(); + restoreProcessEnv(originalEnv); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'performs no independent config read or tier probe when the frozen policy is established', + async (): Promise => { + const originalEnv = snapshotProcessEnv(); + try { + process.env['BETTER_AUTH_SECRET'] = 'fixture-secret'; + vi.resetModules(); + vi.clearAllMocks(); + substituteStartupSurface(); + vi.doMock('@mosaicstack/config', async () => { + const actual = await vi.importActual('@mosaicstack/config'); + return { ...actual, loadConfig: loadConfigMock }; + }); + await establishLocalChannel(); + + const mod = (await import('./main.js')) as { bootstrap?: () => Promise }; + await mod.bootstrap?.(); + + expect( + loadConfigMock, + 'bootstrap re-read configuration independently', + ).not.toHaveBeenCalled(); + expect(detectAndAssertTierMock, 'bootstrap probed the tier').not.toHaveBeenCalled(); + } finally { + undoSubstitutions(); + vi.resetModules(); + restoreProcessEnv(originalEnv); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'evaluates AppModule without an independent config read under the frozen policy', + async (): Promise => { + const originalEnv = snapshotProcessEnv(); + try { + vi.resetModules(); + vi.clearAllMocks(); + substituteStartupSurface(); + vi.doMock('@mosaicstack/config', async () => { + const actual = await vi.importActual('@mosaicstack/config'); + return { ...actual, loadConfig: loadConfigMock }; + }); + await establishLocalChannel(); + + // app.module.ts decides federation at module-evaluation time, so the read + // happens on import — before any provider is constructed. + await import('./app.module.js'); + + expect( + loadConfigMock, + 'AppModule re-read configuration independently', + ).not.toHaveBeenCalled(); + } finally { + undoSubstitutions(); + vi.resetModules(); + restoreProcessEnv(originalEnv); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'serves MOSAIC_CONFIG from the frozen policy rather than a fresh load', + async (): Promise => { + const originalEnv = snapshotProcessEnv(); + try { + vi.resetModules(); + vi.clearAllMocks(); + substituteStartupSurface(); + vi.doMock('@mosaicstack/config', async () => { + const actual = await vi.importActual('@mosaicstack/config'); + return { ...actual, loadConfig: loadConfigMock }; + }); + const stateRoot = await establishLocalChannel(); + + const configModule = await import('./config/config.module.js'); + const providers = (Reflect.getMetadata('providers', configModule.ConfigModule) ?? []) as { + provide?: unknown; + useFactory?: () => unknown; + }[]; + const provider = providers.find( + (candidate) => candidate.provide === configModule.MOSAIC_CONFIG, + ); + expect(provider?.useFactory, 'MOSAIC_CONFIG provider factory not found').toBeDefined(); + + const served = provider!.useFactory!() as { + tier?: string; + storage?: { type?: string; dataDir?: string }; + }; + + expect( + loadConfigMock, + 'the MOSAIC_CONFIG factory called loadConfig', + ).not.toHaveBeenCalled(); + expect(served.tier).toBe('local'); + expect(served.storage?.type).toBe('pglite'); + expect(served.storage?.dataDir).toContain(stateRoot); + } finally { + undoSubstitutions(); + vi.resetModules(); + restoreProcessEnv(originalEnv); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); +}); diff --git a/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md b/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md index c22553fbd..91a05ecea 100644 --- a/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md +++ b/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md @@ -379,9 +379,14 @@ performs no dotenv load and no re-read. This ordering is what makes "refused before connection" true rather than asserted: the modules that can connect are not even loaded until the policy has been established. -### Path ownership — the 15-path fence for `-02a` +### Path ownership — the 16-path fence for `-02a` -These paths are assigned to `-02a`. A 16th path is a STOP. +These paths are assigned to `-02a`. A 17th path is a STOP. + +Revision 15 → 16 was granted by the verifier on the AMEND verdict, solely for +`apps/gateway/src/main.spec.ts`, so the frozen policy can be proven against the actual entrypoint +and module graph rather than against a wholesale mock of them. No other path was added, and +`app.module.spec.ts` was explicitly not authorized. | # | Path | | --- | --------------------------------------------------------- | @@ -400,6 +405,7 @@ These paths are assigned to `-02a`. A 16th path is a STOP. | 13 | `apps/gateway/src/local-start/local-start-policy.spec.ts` | | 14 | `apps/gateway/src/local-dev.ts` | | 15 | `apps/gateway/src/local-dev.spec.ts` | +| 16 | `apps/gateway/src/main.spec.ts` | **Not owned by `-02a`, and not touched by it:** `packages/db/src/client.ts` (`-01`), `packages/db/src/migrate.ts` (`-03`), `packages/db/src/credential-handoff/**` (`-10`), @@ -436,32 +442,62 @@ its stated reason is the same defect class as the span gap that created this ite ### RED phase result -Focused matrix, gateway package: **50 failing / 10 passing (60 cases, 5 files)**. Typecheck and +Focused matrix, gateway package: **77 failing / 15 passing (92 cases, 6 files)**. Typecheck and lint clean. Zero non-assertion failures — no module-resolution, mock-setup or masking errors, so every red is a behavioral assertion that names its own guard. -Nine of the fifteen fenced paths are touched in RED. The six GREEN targets (`package.json`, +Ten of the sixteen fenced paths are touched in RED. The six GREEN targets (`package.json`, `env.ts`, `app.module.ts`, `config/config.module.ts`, `database/database.module.ts`, `main.ts`) are deliberately unmodified, so their specs are red against the production files as they stand. -The ten passing cases are itemized here rather than counted as coverage, because a green that +**Correction to the first RED report.** That report implied the fenced REDs covered the GREEN +targets behaviorally. They did not. Every frozen-policy case mocked `./main.js` wholesale, so +nothing executed `main.ts`, `AppModule`, or the `ConfigModule` provider, and the three independent +reads at `main.ts:28`, `app.module.ts:39` and `config.module.ts:12` could all have survived a +fully green matrix. The report also claimed the zero-side-effect bars as proven; they were proven +only against scaffolding. This pass closes the first gap and narrows the second: + +- `main.ts`, `app.module.ts` and `config/config.module.ts` now have behavior-first REDs that + import the real modules with no `./main.js` mock, and assert no independent `loadConfig`, no + `detectAndAssertTier`, and no `NestFactory.create` (bars Z1 and Z5 through the actual path). +- `database.module.ts` now has REDs that drive the real `onModuleInit()`, asserting that migration + and adapter-migrate never run (bars Z3 and Z4) — still through constructor substitution, not + through a full application boot. +- `package.json` now has a RED that executes the packaged `dev:local` route as a child process, + requiring a `LOCAL_START_*` code in its output and the absence of the `BETTER_AUTH_SECRET` + throw, so a crash or an unrelated failure cannot satisfy it. +- `env.ts` remains covered only by legacy-continuity regression guards, by design. + +The bars are therefore proven through the actual entrypoint for the probe and application-create +side effects, and through the real database lifecycle for the migration side effects. They are not +yet proven through a full boot, and this document does not claim they are. + +The fifteen passing cases are itemized rather than counted as coverage, because a green that cannot fail for its stated reason is the same defect class as the span gap that created this item: -| Passing case | Why it is green | -| --------------------------------------------- | -------------------------------------------------------------------- | -| env: config read on import, no policy | legacy regression guard — expected green | -| env: planted dotenv loads, no policy | legacy regression guard — expected green | -| local-dev: production `dev` script untouched | regression guard — expected green | -| local-dev: no static import of `main.js` | structural; the scaffolding already complies, and it must stay green | -| local-dev: importing starts nothing | structural; same | -| policy: refuses to serve when unestablished | scaffolding implements this correctly already | -| policy: no fallback read when unestablished | scaffolding implements this correctly already | -| inventory: retains no raw DSN text | **vacuous today** — the stub returns an empty inventory | -| policy: children beneath the state root | **vacuous today** — the stub concatenates the declared root | -| policy: unaffected by post-establish mutation | **vacuous today** — the stub captured strings, it did not clone | +| Passing case | Why it is green | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | +| env: config read on import, no policy | legacy regression guard — expected green | +| env: planted dotenv loads, no policy | legacy regression guard — expected green | +| main: anchored config reaches `detectAndAssertTier` | current-contract regression guard; its import-time-exit premise is superseded and must migrate at GREEN | +| local-dev: production `dev` script untouched | regression guard — expected green | +| local-dev: no static import of `main.js` | structural, three forms (binding, side-effect, `require`); must stay green | +| local-dev: importing starts nothing | structural; green today only because nothing invokes `runLocalDev` | +| policy: refuses to serve when unestablished | scaffolding implements this correctly already | +| policy: no fallback read when unestablished | scaffolding implements this correctly already | +| inventory: retains no raw DSN text | **vacuous today** — the stub returns an empty inventory | +| inventory: retains no planted canary | **vacuous today** — same empty inventory | +| inventory: writes no planted canary to the console | **vacuous today** — the stub reads no source, so it can log none | +| policy: children beneath the state root | **vacuous today** — the stub concatenates the declared root | +| policy: unaffected by post-establish input mutation | **vacuous today** — the stub captured strings, it did not clone | +| policy: frozen policy survives a source file gaining a DSN | **vacuous today** — the stub re-serves the same object it built | +| policy: frozen policy survives the environment gaining a DSN | **vacuous today** — same | -The three vacuous greens are meaningful only once the implementation exists. They are re-checked -at GREEN, not carried forward as evidence. +Seven greens are vacuous until the implementation exists, up from three, because this pass added +retention and post-freeze-mutation cases that a stub satisfies trivially. They are evidence of +nothing today and are re-checked at GREEN. The count rising is the honest outcome of asserting +more about a module that does not exist yet — it is recorded here so it cannot later be read as +coverage. ### Scope discipline