diff --git a/apps/gateway/src/database/database.module.spec.ts b/apps/gateway/src/database/database.module.spec.ts new file mode 100644 index 000000000..1cb1b7ce4 --- /dev/null +++ b/apps/gateway/src/database/database.module.spec.ts @@ -0,0 +1,198 @@ +/** + * KBN-101-02a — DatabaseModule REDs. + * + * This module has no spec today, and it is where the cross-field hazard lands: + * `detectAndAssertTier` early-returns on `tier === 'local'` without inspecting + * `storage.type`, and DatabaseModule then builds a storage adapter from + * `config.storage` independently and migrates it in `onModuleInit`. So + * `tier: local` + `storage.type: postgres` skips the probe and still reaches + * real Postgres DDL. + * + * `@mosaicstack/db` and `@mosaicstack/storage` are mocked, so no real + * PostgreSQL connection can be constructed here even if a guard is missing. + * The mocks are also the instrument. + * + * The providers are exercised through the module's own metadata rather than a + * full Nest bootstrap: the factories are the unit under test, and booting Nest + * would itself be a side effect these cases must prove does not happen. + */ + +import 'reflect-metadata'; +import { mkdir, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const createDbMock = vi.fn(() => ({ db: {}, close: vi.fn() })); +const createPgliteDbMock = vi.fn((dataDir: string) => ({ db: { dataDir }, close: vi.fn() })); +const runPgliteMigrationsMock = vi.fn(async () => {}); +const createStorageAdapterMock = vi.fn((_storage?: unknown) => ({ + name: 'mock', + migrate: vi.fn(async () => {}), + close: vi.fn(async () => {}), +})); + +vi.mock('@mosaicstack/db', () => ({ + createDb: createDbMock, + createPgliteDb: createPgliteDbMock, + runPgliteMigrations: runPgliteMigrationsMock, + runMigrations: vi.fn(async () => {}), +})); + +vi.mock('@mosaicstack/storage', () => ({ + createStorageAdapter: createStorageAdapterMock, + detectAndAssertTier: vi.fn(async () => {}), + TierDetectionError: class TierDetectionError extends Error {}, +})); + +// Keep the transitive `../env.js` import inert: it calls loadGatewayEnv() as a +// top-level side effect today. +vi.mock('dotenv', () => { + const config = vi.fn(() => ({ parsed: {} })); + return { config, default: { config } }; +}); +vi.mock('@mosaicstack/config', () => ({ + loadConfig: vi.fn(() => ({ tier: 'local', storage: { type: 'pglite' } })), + // env.ts reads `detectFromEnv().tier` at import time, so this must be an + // object — returning undefined turns every case in this file into a setup + // error rather than a behavioral red. + detectFromEnv: vi.fn(() => ({ tier: undefined })), +})); + +type AnyProvider = { provide?: unknown; useFactory?: (...args: unknown[]) => unknown }; + +let envSnapshot: NodeJS.ProcessEnv; + +beforeEach(() => { + envSnapshot = { ...process.env }; + vi.clearAllMocks(); +}); + +afterEach(() => { + process.env = { ...envSnapshot }; + vi.resetModules(); +}); + +async function loadModuleUnderTest(): Promise<{ + factoryFor: (token: unknown) => (...args: unknown[]) => unknown; + tokens: { DB_HANDLE: unknown; STORAGE_ADAPTER: unknown }; +}> { + const mod = await import('./database.module.js'); + const providers = (Reflect.getMetadata('providers', mod.DatabaseModule) ?? []) as AnyProvider[]; + + return { + tokens: { DB_HANDLE: mod.DB_HANDLE, STORAGE_ADAPTER: mod.STORAGE_ADAPTER }, + factoryFor: (token: unknown) => { + const provider = providers.find((candidate) => candidate.provide === token); + expect(provider?.useFactory, 'provider factory not found').toBeDefined(); + return provider!.useFactory!; + }, + }; +} + +/** Establish a valid frozen local policy and return its state root. */ +async function establishLocalChannel(): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), 'kbn-02a-dbmod-')); + 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; +} + +describe('KBN-101-02a DatabaseModule — the cross-field hazard', () => { + it('refuses tier local + storage postgres instead of constructing a Postgres adapter', async () => { + vi.resetModules(); + await establishLocalChannel(); + const { factoryFor, tokens } = await loadModuleUnderTest(); + + const hazardConfig = { + tier: 'local', + storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5433/mosaic' }, + }; + + // detectAndAssertTier never sees this shape: it early-returns on tier alone. + expect(() => factoryFor(tokens.STORAGE_ADAPTER)(hazardConfig)).toThrow(); + expect(createStorageAdapterMock).not.toHaveBeenCalled(); + }); + + it('refuses tier local + storage postgres in the DB handle factory as well', async () => { + vi.resetModules(); + await establishLocalChannel(); + const { factoryFor, tokens } = await loadModuleUnderTest(); + + const hazardConfig = { + tier: 'local', + storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5433/mosaic' }, + }; + + expect(() => factoryFor(tokens.DB_HANDLE)(hazardConfig)).toThrow(); + expect(createDbMock).not.toHaveBeenCalled(); + }); + + it('never calls createDb while the local policy is established', async () => { + vi.resetModules(); + await establishLocalChannel(); + const { factoryFor, tokens } = await loadModuleUnderTest(); + + const nonLocal = { tier: 'standalone', storage: { type: 'postgres', url: 'postgresql://x/y' } }; + + expect(() => factoryFor(tokens.DB_HANDLE)(nonLocal)).toThrow(); + expect(createDbMock).not.toHaveBeenCalled(); + }); +}); + +describe('KBN-101-02a DatabaseModule — state lives under the declared root', () => { + it('derives the PGlite data dir from the frozen policy, not from homedir()', async () => { + vi.resetModules(); + const stateRoot = await establishLocalChannel(); + const { factoryFor, tokens } = await loadModuleUnderTest(); + + factoryFor(tokens.DB_HANDLE)({ tier: 'local', storage: { type: 'pglite' } }); + + expect(createPgliteDbMock).toHaveBeenCalledTimes(1); + const dataDir = createPgliteDbMock.mock.calls[0]?.[0] as string; + // A homedir()-derived path is neither declared, session-created, nor + // disposable, and it is shared with the daemon install. + expect(dataDir.startsWith(`${stateRoot}/`)).toBe(true); + expect(dataDir).not.toContain('.config/mosaic/gateway/pglite'); + }); + + it('derives the storage adapter data dir from the frozen policy', async () => { + vi.resetModules(); + const stateRoot = await establishLocalChannel(); + const { factoryFor, tokens } = await loadModuleUnderTest(); + + factoryFor(tokens.STORAGE_ADAPTER)({ tier: 'local', storage: { type: 'pglite' } }); + + expect(createStorageAdapterMock).toHaveBeenCalledTimes(1); + const passed = JSON.stringify(createStorageAdapterMock.mock.calls[0]?.[0] ?? {}); + expect(passed).toContain(stateRoot); + }); + + it('reads the frozen policy rather than the injected config in the local channel', async () => { + vi.resetModules(); + const stateRoot = await establishLocalChannel(); + const { factoryFor, tokens } = await loadModuleUnderTest(); + + // A config object that disagrees with the frozen policy must not win: the + // policy is the single read, and there is no fallback to an independent one. + factoryFor(tokens.DB_HANDLE)({ + tier: 'local', + storage: { type: 'pglite', dataDir: '/somewhere/else' }, + }); + + const dataDir = createPgliteDbMock.mock.calls[0]?.[0] as string; + expect(dataDir.startsWith(`${stateRoot}/`)).toBe(true); + }); +}); diff --git a/apps/gateway/src/env.spec.ts b/apps/gateway/src/env.spec.ts new file mode 100644 index 000000000..ede1cea1e --- /dev/null +++ b/apps/gateway/src/env.spec.ts @@ -0,0 +1,166 @@ +/** + * KBN-101-02a — env.ts REDs. + * + * `loadGatewayEnv()` runs today as an unconditional top-level module side + * effect (`env.ts` last line), so merely importing this module loads dotenv + * files and re-reads configuration. Inside the local channel that is a second, + * independent read behind the frozen policy's back. + * + * Two signals are distinguished deliberately, because they fire under different + * conditions and a case that asserts the wrong one cannot fail for its stated + * reason: + * - `dotenv.config()` fires ONLY when the dotenv file exists, so any case + * about it plants a real fixture under a temp anchor; + * - `loadConfig()` fires unconditionally, so it is the signal for the + * import-time side effect at the real anchors. + * + * The final describe block is a legacy-channel regression guard and passes + * before implementation. It is not claimed as a RED. + */ + +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const FORBIDDEN_DSN = 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; + +const dotenvConfigMock = vi.fn((_options?: unknown) => ({ parsed: {} })); +const loadConfigMock = vi.fn(() => ({ + tier: 'local', + storage: { type: 'pglite' }, + queue: { type: 'local' }, +})); +const detectFromEnvMock = vi.fn(() => ({ tier: undefined })); + +vi.mock('dotenv', () => ({ config: dotenvConfigMock, default: { config: dotenvConfigMock } })); + +vi.mock('@mosaicstack/config', () => ({ + loadConfig: loadConfigMock, + detectFromEnv: detectFromEnvMock, +})); + +let envSnapshot: NodeJS.ProcessEnv; + +beforeEach(() => { + envSnapshot = { ...process.env }; + vi.clearAllMocks(); +}); + +afterEach(() => { + process.env = { ...envSnapshot }; + vi.resetModules(); +}); + +interface Bed { + anchor: string; + homeBase: string; + stateRoot: string; +} + +async function makeBed(): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), 'kbn-02a-env-')); + 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 }); + return { anchor, homeBase, stateRoot }; +} + +/** Plant a dotenv the loader would find, so `config()` can actually fire. */ +async function plantDotenv(bed: Bed): Promise { + await writeFile( + join(bed.anchor, '..', '.env'), + `MOSAIC_STORAGE_TIER=standalone\nDATABASE_URL=${FORBIDDEN_DSN}\n`, + 'utf8', + ); +} + +async function establishLocalChannel(bed: Bed): Promise { + const { establishFrozenLocalPolicy } = await import('./local-start/local-start-policy.js'); + establishFrozenLocalPolicy({ + declaration: { + tier: 'local', + storageType: 'pglite', + queueType: 'local', + stateRoot: bed.stateRoot, + }, + anchor: bed.anchor, + homeBase: bed.homeBase, + env: {}, + }); +} + +describe('KBN-101-02a env.ts — inert inside the local channel', () => { + it('performs no configuration re-read on import when the frozen local policy is established', async () => { + vi.resetModules(); + const bed = await makeBed(); + await establishLocalChannel(bed); + + await import('./env.js'); + + // The frozen policy is the only read in the local channel. This one fires + // unconditionally today, from the module's top-level side effect. + expect(loadConfigMock).not.toHaveBeenCalled(); + }); + + it('loads no dotenv file when called under the frozen local policy', async () => { + vi.resetModules(); + const bed = await makeBed(); + await plantDotenv(bed); + await establishLocalChannel(bed); + + const mod = await import('./env.js'); + dotenvConfigMock.mockClear(); + loadConfigMock.mockClear(); + + mod.loadGatewayEnv(bed.anchor, bed.homeBase); + + // The guard lives inside loadGatewayEnv, not at its call site: a caller + // anywhere in the local channel must not be able to reintroduce the load. + expect(dotenvConfigMock).not.toHaveBeenCalled(); + expect(loadConfigMock).not.toHaveBeenCalled(); + }); + + it('does not repopulate the ambient environment from a planted dotenv under the local policy', async () => { + vi.resetModules(); + const bed = await makeBed(); + await plantDotenv(bed); + await establishLocalChannel(bed); + + const mod = await import('./env.js'); + dotenvConfigMock.mockClear(); + + mod.loadGatewayEnv(bed.anchor, bed.homeBase); + + // A dotenv load here would repopulate process.env from files the policy + // already inspected and refused to be governed by. + const loadedPaths = dotenvConfigMock.mock.calls.map((call) => JSON.stringify(call[0] ?? null)); + expect(loadedPaths).toEqual([]); + }); +}); + +describe('KBN-101-02a env.ts — legacy channel unchanged (regression guard, not a RED)', () => { + it('still performs its configuration read on import when no local policy is established', async () => { + vi.resetModules(); + + await import('./env.js'); + + expect(loadConfigMock).toHaveBeenCalled(); + }); + + it('still loads a planted dotenv when no local policy is established', async () => { + vi.resetModules(); + const bed = await makeBed(); + await plantDotenv(bed); + + const mod = await import('./env.js'); + dotenvConfigMock.mockClear(); + + mod.loadGatewayEnv(bed.anchor, bed.homeBase); + + expect(dotenvConfigMock).toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/local-dev.spec.ts b/apps/gateway/src/local-dev.spec.ts new file mode 100644 index 000000000..40ae7ab26 --- /dev/null +++ b/apps/gateway/src/local-dev.spec.ts @@ -0,0 +1,228 @@ +/** + * KBN-101-02a — local-dev entrypoint REDs. + * + * Covers mutation->guard row 9 (one-shot freeze + dynamic import) and the + * zero-side-effect bars Z1-Z5. + * + * `@mosaicstack/db`, `@mosaicstack/storage` and `@nestjs/core` are mocked here, + * so no real PostgreSQL connection can be constructed by this file even if a + * guard is missing. The mocks are also the instrument: a refusing case proves + * zero calls to each. + */ + +import { readFile, mkdtemp, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as LocalDevNamespace from './local-dev.js'; + +const FORBIDDEN_DSN = 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); + +// --- Z1-Z5 instruments. Nothing real is reachable through these. ------------- +const bootstrapMock = vi.fn(async () => {}); +const detectAndAssertTierMock = vi.fn(async () => {}); +const createDbMock = vi.fn(() => { + throw new Error('createDb must not be reached from the local channel'); +}); +const createStorageAdapterMock = vi.fn(() => { + throw new Error('createStorageAdapter must not be reached from the local channel'); +}); +const runMigrationsMock = vi.fn(async () => {}); +const nestFactoryCreateMock = vi.fn(async () => ({ listen: vi.fn() })); + +vi.mock('./main.js', () => ({ bootstrap: bootstrapMock })); + +vi.mock('@mosaicstack/db', () => ({ + createDb: createDbMock, + createPgliteDb: vi.fn(() => ({ db: {}, close: vi.fn() })), + runMigrations: runMigrationsMock, + runPgliteMigrations: vi.fn(async () => {}), +})); + +vi.mock('@mosaicstack/storage', () => ({ + createStorageAdapter: createStorageAdapterMock, + detectAndAssertTier: detectAndAssertTierMock, + TierDetectionError: class TierDetectionError extends Error {}, +})); + +vi.mock('@nestjs/core', () => ({ + NestFactory: { create: nestFactoryCreateMock }, +})); + +const SIDE_EFFECT_MOCKS = { + 'detectAndAssertTier (tier probe)': detectAndAssertTierMock, + createDb: createDbMock, + 'createStorageAdapter (PostgresAdapter construction)': createStorageAdapterMock, + runMigrations: runMigrationsMock, + 'NestFactory.create': nestFactoryCreateMock, +}; + +/** Z1-Z5: a refusing case reaches none of these. */ +function expectZeroSideEffects(): void { + for (const [name, mock] of Object.entries(SIDE_EFFECT_MOCKS)) { + expect(mock, `${name} was reached on a refusing start`).not.toHaveBeenCalled(); + } +} + +let envSnapshot: NodeJS.ProcessEnv; + +beforeEach(() => { + envSnapshot = { ...process.env }; + vi.clearAllMocks(); +}); + +afterEach(() => { + process.env = { ...envSnapshot }; + vi.resetModules(); +}); + +type LocalDevModule = typeof LocalDevNamespace; + +async function freshLocalDev(): Promise { + vi.resetModules(); + return import('./local-dev.js'); +} + +/** A well-formed local declaration, planted in the ambient environment. */ +async function declareAdmissibleLocalStart(mod: LocalDevModule): Promise { + const stateRoot = await realpath(await mkdtemp(join(tmpdir(), 'kbn-02a-localdev-'))); + process.env[mod.LOCAL_DEV_TIER_VAR] = 'local'; + process.env[mod.LOCAL_DEV_STORAGE_VAR] = 'pglite'; + process.env[mod.LOCAL_DEV_QUEUE_VAR] = 'local'; + process.env[mod.LOCAL_DEV_STATE_ROOT_VAR] = stateRoot; + return stateRoot; +} + +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; + + // 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); + }); + + it('dynamically imports main.js', async () => { + const source = await readFile(join(HERE, 'local-dev.ts'), 'utf8'); + expect(/await\s+import\(\s*['"]\.\/main\.js['"]\s*\)/.test(source)).toBe(true); + }); + + it('starts nothing merely by being imported', async () => { + await freshLocalDev(); + expect(bootstrapMock).not.toHaveBeenCalled(); + expectZeroSideEffects(); + }); + + it('establishes the frozen policy BEFORE bootstrap is imported and called', async () => { + const mod = await freshLocalDev(); + await declareAdmissibleLocalStart(mod); + + const { getFrozenLocalPolicy } = await import('./local-start/local-start-policy.js'); + let policyAtBootstrap: unknown; + bootstrapMock.mockImplementationOnce(async () => { + policyAtBootstrap = getFrozenLocalPolicy(); + }); + + await mod.runLocalDev(); + + expect(bootstrapMock).toHaveBeenCalledTimes(1); + expect(policyAtBootstrap, 'bootstrap ran before the policy was established').toBeDefined(); + }); +}); + +describe('KBN-101-02a local-dev — refusal reaches nothing', () => { + it('refuses an inherited PostgreSQL DSN and never calls bootstrap', async () => { + const mod = await freshLocalDev(); + await declareAdmissibleLocalStart(mod); + process.env['DATABASE_URL'] = FORBIDDEN_DSN; + + await expect(mod.runLocalDev()).rejects.toThrow(/LOCAL_START_POSTGRES_DSN_PRESENT/); + expect(bootstrapMock).not.toHaveBeenCalled(); + expectZeroSideEffects(); + }); + + it('refuses a non-local tier declared to the local entrypoint', async () => { + const mod = await freshLocalDev(); + await declareAdmissibleLocalStart(mod); + process.env[mod.LOCAL_DEV_TIER_VAR] = 'standalone'; + + await expect(mod.runLocalDev()).rejects.toThrow(/LOCAL_START_NON_LOCAL_TIER/); + expect(bootstrapMock).not.toHaveBeenCalled(); + expectZeroSideEffects(); + }); + + it('refuses a non-pglite storage declared to the local entrypoint', async () => { + const mod = await freshLocalDev(); + await declareAdmissibleLocalStart(mod); + process.env[mod.LOCAL_DEV_STORAGE_VAR] = 'postgres'; + + await expect(mod.runLocalDev()).rejects.toThrow(/LOCAL_START_STORAGE_NOT_PGLITE/); + expect(bootstrapMock).not.toHaveBeenCalled(); + expectZeroSideEffects(); + }); + + it('refuses when no state root is declared rather than defaulting to HOME', async () => { + const mod = await freshLocalDev(); + await declareAdmissibleLocalStart(mod); + delete process.env[mod.LOCAL_DEV_STATE_ROOT_VAR]; + + await expect(mod.runLocalDev()).rejects.toThrow(/LOCAL_START_STATE_ROOT_MISSING/); + expect(bootstrapMock).not.toHaveBeenCalled(); + expectZeroSideEffects(); + }); + + it('refuses a second run in the same process: the freeze is one-shot', async () => { + const mod = await freshLocalDev(); + await declareAdmissibleLocalStart(mod); + + await mod.runLocalDev(); + await expect(mod.runLocalDev()).rejects.toThrow(/LOCAL_START_ALREADY_ESTABLISHED/); + expect(bootstrapMock).toHaveBeenCalledTimes(1); + }); + + it('does not leak the offending value in the refusal', async () => { + const mod = await freshLocalDev(); + await declareAdmissibleLocalStart(mod); + process.env['DATABASE_URL'] = FORBIDDEN_DSN; + + let thrown: unknown; + try { + await mod.runLocalDev(); + } catch (error) { + thrown = error; + } + + expect(thrown, 'the start was admitted with a DSN present').toBeInstanceOf(Error); + expect((thrown as Error).message).not.toContain(FORBIDDEN_DSN); + expect((thrown as Error).message).not.toContain('mosaic:mosaic'); + }); +}); + +describe('KBN-101-02a local-dev — the channel has its own command', () => { + it('exposes a dev:local package script pointed at the local entrypoint', async () => { + const manifest = JSON.parse(await readFile(join(HERE, '..', 'package.json'), 'utf8')) as { + scripts?: Record; + }; + + // The discriminator is the entrypoint itself, so it needs its own command. + // Without one, "start locally" remains a flag on the shared command, which + // is the shape this item exists to remove. + const devLocal = manifest.scripts?.['dev:local']; + expect(devLocal, 'no dev:local script').toBeDefined(); + expect(devLocal).toContain('src/local-dev.ts'); + }); + + it('leaves the production-like dev script untouched', async () => { + const manifest = JSON.parse(await readFile(join(HERE, '..', 'package.json'), 'utf8')) as { + scripts?: Record; + }; + + expect(manifest.scripts?.['dev']).toBe('tsx watch src/main.ts'); + }); +}); diff --git a/apps/gateway/src/local-dev.ts b/apps/gateway/src/local-dev.ts new file mode 100644 index 000000000..1e0bba36c --- /dev/null +++ b/apps/gateway/src/local-dev.ts @@ -0,0 +1,43 @@ +/** + * Local-development entrypoint — KBN-101-02a. + * + * THE channel discriminator. Being this entrypoint is what makes a start a + * local-development start; it is never inferred from tier value or from + * configuration provenance. Its sole contract is local + PGlite, in an + * explicitly declared, session-created, disposable state root. + * + * Import order here is load-bearing. This module statically imports ONLY the + * policy and inventory modules, establishes the frozen policy, and only then + * dynamically imports `bootstrap()` from `main.js`. The modules that are able + * to open a connection are not loaded until the policy has been established — + * which is what makes "refused before connection" a structural property rather + * than an assertion. + * + * RED PHASE SCAFFOLDING. This stub establishes nothing correctly and performs + * no dynamic import, so the behavioral and structural specs fail. Do not read + * this file as the implementation — there is none yet. + */ + +import { establishFrozenLocalPolicy } from './local-start/local-start-policy.js'; + +/** Explicit, session-scoped declarations for this invocation. Nothing defaults. */ +export const LOCAL_DEV_TIER_VAR = 'MOSAIC_LOCAL_DEV_TIER'; +export const LOCAL_DEV_STORAGE_VAR = 'MOSAIC_LOCAL_DEV_STORAGE'; +export const LOCAL_DEV_QUEUE_VAR = 'MOSAIC_LOCAL_DEV_QUEUE'; +export const LOCAL_DEV_STATE_ROOT_VAR = 'MOSAIC_LOCAL_DEV_STATE_ROOT'; + +export async function runLocalDev(): Promise { + // RED-phase stub: establishes via the permissive policy stub and stops. It + // performs no dynamic import of bootstrap, so nothing starts. See header. + establishFrozenLocalPolicy({ + declaration: { + tier: process.env[LOCAL_DEV_TIER_VAR], + storageType: process.env[LOCAL_DEV_STORAGE_VAR], + queueType: process.env[LOCAL_DEV_QUEUE_VAR], + stateRoot: process.env[LOCAL_DEV_STATE_ROOT_VAR], + }, + anchor: import.meta.dirname, + homeBase: '', + env: process.env, + }); +} diff --git a/apps/gateway/src/local-start/local-start-policy.spec.ts b/apps/gateway/src/local-start/local-start-policy.spec.ts new file mode 100644 index 000000000..127534232 --- /dev/null +++ b/apps/gateway/src/local-start/local-start-policy.spec.ts @@ -0,0 +1,386 @@ +/** + * KBN-101-02a — local-start policy REDs. + * + * Covers mutation->guard rows 7 (exact local tier), 8 (exact PGlite + disposable + * state root), and the freeze half of row 9 (one-shot freeze). + * + * The policy module holds one-shot module state, so every case re-imports it + * through `vi.resetModules()` rather than sharing an established policy. + * + * Nothing here connects to anything. DSN strings are fixture text. + */ + +import { 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 type * as PolicyNamespace from './local-start-policy.js'; +import type { + EstablishInput, + LocalStartDeclaration, + LocalStartRefusalCode, +} from './local-start-policy.js'; + +const FORBIDDEN_DSN = 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; + +type PolicyModule = typeof PolicyNamespace; + +/** Fresh module state per case — the freeze is one-shot and has no reset API. */ +async function freshPolicy(): Promise { + vi.resetModules(); + return import('./local-start-policy.js'); +} + +interface Bed { + tempRoot: string; + anchor: string; + homeBase: string; + stateRoot: string; +} + +/** A clean layout: no source carries anything forbidden. */ +async function makeBed(): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), 'kbn-02a-policy-')); + 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 }); + return { tempRoot, anchor, homeBase, stateRoot }; +} + +function validDeclaration(stateRoot: string): LocalStartDeclaration { + return { tier: 'local', storageType: 'pglite', queueType: 'local', stateRoot }; +} + +function establishInput(bed: Bed, overrides: Partial = {}): EstablishInput { + return { + declaration: validDeclaration(bed.stateRoot), + anchor: bed.anchor, + homeBase: bed.homeBase, + env: {}, + ...overrides, + }; +} + +/** Assert the call refused, with the exact code, carrying no value. */ +async function expectRefusal( + policy: PolicyModule, + input: EstablishInput, + code: LocalStartRefusalCode, +): Promise { + let thrown: unknown; + try { + policy.establishFrozenLocalPolicy(input); + } catch (error) { + thrown = error; + } + + expect(thrown, `expected a refusal with code ${code}, got none`).toBeInstanceOf( + policy.LocalStartRefusal, + ); + const refusal = thrown as InstanceType; + expect(refusal.code).toBe(code); + // A refusal that echoed the offending value would move the secret into the + // log, which is the failure this item exists to prevent. + const serialized = `${refusal.message} ${JSON.stringify(refusal.sourceLabels)}`; + expect(serialized).not.toContain(FORBIDDEN_DSN); + expect(serialized).not.toContain('mosaic:mosaic'); +} + +describe('KBN-101-02a establishFrozenLocalPolicy — row 7, exact local tier', () => { + it('refuses when no tier is declared: absent is not local', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), tier: undefined }, + }), + 'LOCAL_START_TIER_NOT_EXPLICIT', + ); + }); + + it('refuses an empty tier: empty is not local', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), tier: '' }, + }), + 'LOCAL_START_TIER_NOT_EXPLICIT', + ); + }); + + it('refuses a declared non-local tier inside the local channel', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), tier: 'standalone' }, + }), + 'LOCAL_START_NON_LOCAL_TIER', + ); + }); + + it('refuses a near-miss tier: matching is exact, not normalized', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), tier: ' Local ' }, + }), + 'LOCAL_START_NON_LOCAL_TIER', + ); + }); + + it('refuses a non-local tier found in a source even when the declaration is local', async () => { + const bed = await makeBed(); + await writeFile(join(bed.anchor, '..', '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8'); + const policy = await freshPolicy(); + await expectRefusal(policy, establishInput(bed), 'LOCAL_START_NON_LOCAL_TIER'); + }); + + it('refuses a PostgreSQL DSN found in a source, naming the source label but not the value', async () => { + const bed = await makeBed(); + await writeFile(join(bed.anchor, '..', '.env'), `DATABASE_URL=${FORBIDDEN_DSN}\n`, 'utf8'); + const policy = await freshPolicy(); + + let thrown: unknown; + try { + policy.establishFrozenLocalPolicy(establishInput(bed)); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(policy.LocalStartRefusal); + const refusal = thrown as InstanceType; + expect(refusal.code).toBe('LOCAL_START_POSTGRES_DSN_PRESENT'); + expect(refusal.sourceLabels).toContain('gateway-local-dotenv'); + expect(refusal.message).not.toContain(FORBIDDEN_DSN); + }); + + it('refuses an inherited-environment DSN that precedence would have discarded', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { env: { DATABASE_URL: FORBIDDEN_DSN } }), + 'LOCAL_START_POSTGRES_DSN_PRESENT', + ); + }); + + it('refuses the default DSN constant, which is itself a PostgreSQL DSN', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + // No role classification is attempted: proving a DSN non-superuser requires + // connecting, and connecting is the thing being prevented. + await expectRefusal( + policy, + establishInput(bed, { + env: { DATABASE_URL: 'postgresql://mosaic:mosaic@localhost:5433/mosaic' }, + }), + 'LOCAL_START_POSTGRES_DSN_PRESENT', + ); + }); +}); + +describe('KBN-101-02a establishFrozenLocalPolicy — row 8, PGlite and disposable state root', () => { + it('refuses a non-pglite storage type', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), storageType: 'postgres' }, + }), + 'LOCAL_START_STORAGE_NOT_PGLITE', + ); + }); + + it('refuses a files storage type: only pglite is admitted, not merely "not postgres"', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), storageType: 'files' }, + }), + 'LOCAL_START_STORAGE_NOT_PGLITE', + ); + }); + + it('refuses an undeclared storage type rather than defaulting it', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), storageType: undefined }, + }), + 'LOCAL_START_STORAGE_NOT_PGLITE', + ); + }); + + it('refuses a non-local queue', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), queueType: 'bullmq' }, + }), + 'LOCAL_START_QUEUE_NOT_LOCAL', + ); + }); + + it('refuses an undeclared state root rather than falling back to HOME or CWD', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), stateRoot: undefined }, + }), + 'LOCAL_START_STATE_ROOT_MISSING', + ); + }); + + it('refuses an empty state root', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), stateRoot: '' }, + }), + 'LOCAL_START_STATE_ROOT_MISSING', + ); + }); + + it('refuses a relative state root', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { ...validDeclaration(bed.stateRoot), stateRoot: './local-state' }, + }), + 'LOCAL_START_STATE_ROOT_NOT_ABSOLUTE', + ); + }); + + it('refuses a state root that is not its own canonical form', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + await expectRefusal( + policy, + establishInput(bed, { + declaration: { + ...validDeclaration(bed.stateRoot), + stateRoot: join(bed.tempRoot, 'session-state', '..', 'session-state'), + }, + }), + 'LOCAL_START_STATE_ROOT_ESCAPES', + ); + }); + + it('refuses when a derived child escapes the state root through a symlink', async () => { + const bed = await makeBed(); + const escapeTarget = join(bed.tempRoot, 'outside'); + await mkdir(escapeTarget, { recursive: true }); + // The DB child is a symlink pointing out of the disposable root: state + // written "under" the root would land outside it. + await symlink(escapeTarget, join(bed.stateRoot, 'db'), 'dir'); + + const policy = await freshPolicy(); + await expectRefusal(policy, establishInput(bed), 'LOCAL_START_STATE_ROOT_ESCAPES'); + }); + + it('refuses the cross-field local+postgres shape that the tier probe does not catch', async () => { + const bed = await makeBed(); + await writeFile( + join(bed.anchor, '..', 'mosaic.config.json'), + JSON.stringify({ tier: 'local', storage: { type: 'postgres', url: FORBIDDEN_DSN } }), + 'utf8', + ); + const policy = await freshPolicy(); + // detectAndAssertTier early-returns on tier === 'local' without inspecting + // storage.type, so this shape reaches real Postgres DDL today. + await expectRefusal(policy, establishInput(bed), 'LOCAL_START_CROSS_FIELD_MISMATCH'); + }); +}); + +describe('KBN-101-02a establishFrozenLocalPolicy — row 9, one-shot freeze', () => { + it('returns a deep-frozen allowed-only policy', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + const frozen = policy.establishFrozenLocalPolicy(establishInput(bed)); + + expect(Object.isFrozen(frozen)).toBe(true); + expect(Object.isFrozen(frozen.storage)).toBe(true); + expect(Object.isFrozen(frozen.queue)).toBe(true); + expect(frozen.tier).toBe('local'); + expect(frozen.storage.type).toBe('pglite'); + expect(frozen.queue.type).toBe('local'); + }); + + it('resolves every derived child beneath the declared state root', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + const frozen = policy.establishFrozenLocalPolicy(establishInput(bed)); + + expect(frozen.stateRoot).toBe(bed.stateRoot); + expect(frozen.storage.dataDir.startsWith(`${bed.stateRoot}/`)).toBe(true); + expect(frozen.queue.dataDir.startsWith(`${bed.stateRoot}/`)).toBe(true); + }); + + it('refuses a second establish: the policy is one-shot and there is no reset API', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + policy.establishFrozenLocalPolicy(establishInput(bed)); + + await expectRefusal(policy, establishInput(bed), 'LOCAL_START_ALREADY_ESTABLISHED'); + expect(Object.keys(policy)).not.toContain('resetFrozenLocalPolicy'); + }); + + it('is unaffected by mutating the inputs after establish: the policy is a deep clone', async () => { + const bed = await makeBed(); + const policy = await freshPolicy(); + const env: Record = {}; + const frozen = policy.establishFrozenLocalPolicy(establishInput(bed, { env })); + const dataDirBefore = frozen.storage.dataDir; + + env['DATABASE_URL'] = FORBIDDEN_DSN; + env['MOSAIC_STORAGE_TIER'] = 'standalone'; + + const observed = policy.getFrozenLocalPolicy(); + expect(observed?.storage.dataDir).toBe(dataDirBefore); + expect(observed?.tier).toBe('local'); + expect(observed?.storage.type).toBe('pglite'); + }); + + it('refuses to serve a policy when the local channel was never established', async () => { + const policy = await freshPolicy(); + expect(policy.getFrozenLocalPolicy()).toBeUndefined(); + expect(() => policy.requireFrozenLocalPolicy()).toThrow(policy.LocalStartRefusal); + }); + + it('does not fall back to an independent config read when unestablished', async () => { + const policy = await freshPolicy(); + // No `getFrozen() ?? loadConfig()` masking: the local and legacy channels + // must not fall through to one another. + let thrown: unknown; + try { + policy.requireFrozenLocalPolicy(); + } catch (error) { + thrown = error; + } + expect((thrown as { code?: string }).code).toBe('LOCAL_START_POLICY_NOT_ESTABLISHED'); + }); +}); diff --git a/apps/gateway/src/local-start/local-start-policy.ts b/apps/gateway/src/local-start/local-start-policy.ts new file mode 100644 index 000000000..8712cf579 --- /dev/null +++ b/apps/gateway/src/local-start/local-start-policy.ts @@ -0,0 +1,119 @@ +/** + * Local-start policy — KBN-101-02a. + * + * The fail-closed refusal, and the single frozen policy the whole local channel + * consumes. Refusal happens before any connection is opened and before any DDL + * is executed; the modules that are able to connect are not loaded until this + * has run. + * + * RED PHASE SCAFFOLDING. The signatures and codes below are the contract; the + * bodies are deliberately permissive stubs that admit everything and freeze + * nothing, so the behavioral specs fail on their assertions rather than on a + * missing module. Do not read this file as the implementation — there is none + * yet. + */ + +import type { LocalStartSourceLabel } from './source-inventory.js'; + +/** Stable refusal codes. Errors expose labels and codes only — never values. */ +export type LocalStartRefusalCode = + | 'LOCAL_START_POSTGRES_DSN_PRESENT' + | 'LOCAL_START_NON_LOCAL_TIER' + | 'LOCAL_START_TIER_NOT_EXPLICIT' + | 'LOCAL_START_STORAGE_NOT_PGLITE' + | 'LOCAL_START_QUEUE_NOT_LOCAL' + | 'LOCAL_START_STATE_ROOT_MISSING' + | 'LOCAL_START_STATE_ROOT_NOT_ABSOLUTE' + | 'LOCAL_START_STATE_ROOT_ESCAPES' + | 'LOCAL_START_CROSS_FIELD_MISMATCH' + | 'LOCAL_START_ALREADY_ESTABLISHED' + | 'LOCAL_START_POLICY_NOT_ESTABLISHED'; + +/** + * A refusal. Carries the stable code and the source labels that produced it. + * + * It carries no value: no DSN, no dotenv line, no JSON body. A refusal that + * echoed the offending value would move the secret into the log, which is the + * failure this item exists to prevent. + */ +export class LocalStartRefusal extends Error { + public readonly code: LocalStartRefusalCode; + public readonly sourceLabels: readonly LocalStartSourceLabel[]; + + constructor(code: LocalStartRefusalCode, sourceLabels: readonly LocalStartSourceLabel[] = []) { + super(`[local-start] refused: ${code}`); + this.name = 'LocalStartRefusal'; + this.code = code; + this.sourceLabels = sourceLabels; + } +} + +/** Explicit declarations supplied for THIS invocation. Nothing is defaulted. */ +export interface LocalStartDeclaration { + readonly tier?: string; + readonly storageType?: string; + readonly queueType?: string; + readonly stateRoot?: string; +} + +export interface EstablishInput { + readonly declaration: LocalStartDeclaration; + readonly anchor: string; + readonly homeBase: string; + readonly env: Readonly>; +} + +/** The one resolved policy. Allowed-only, deep-cloned, deep-frozen. */ +export interface FrozenLocalPolicy { + readonly tier: 'local'; + readonly storage: { readonly type: 'pglite'; readonly dataDir: string }; + readonly queue: { readonly type: 'local'; readonly dataDir: string }; + readonly stateRoot: string; +} + +let established: FrozenLocalPolicy | undefined; + +/** + * Establish the frozen local policy, once. + * + * Contract (not yet implemented): + * - refuses unless tier/storage/queue/state-root are all explicitly declared; + * - 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; + * - deep-clones and deep-freezes an allowed-only object; + * - a second call refuses with LOCAL_START_ALREADY_ESTABLISHED; there is no reset. + */ +export function establishFrozenLocalPolicy(input: EstablishInput): FrozenLocalPolicy { + // RED-phase stub: admits everything, validates nothing, freezes nothing, and + // permits re-establish. See module header. + const stateRoot = input.declaration.stateRoot ?? ''; + established = { + tier: 'local', + storage: { type: 'pglite', dataDir: `${stateRoot}/db` }, + queue: { type: 'local', dataDir: `${stateRoot}/queue` }, + stateRoot, + }; + return established; +} + +/** The established policy, or undefined when the local channel is not active. */ +export function getFrozenLocalPolicy(): FrozenLocalPolicy | undefined { + return established; +} + +/** + * The established policy, refusing when the local channel is not active. + * + * Local-channel consumers use this rather than falling back to an independent + * read: there is deliberately no `getFrozen() ?? loadConfig()` masking, because + * a fallback would silently restore the split-config path this item removes. + */ +export function requireFrozenLocalPolicy(): FrozenLocalPolicy { + if (established === undefined) { + throw new LocalStartRefusal('LOCAL_START_POLICY_NOT_ESTABLISHED'); + } + return established; +} diff --git a/apps/gateway/src/local-start/source-inventory.spec.ts b/apps/gateway/src/local-start/source-inventory.spec.ts new file mode 100644 index 000000000..188e6b731 --- /dev/null +++ b/apps/gateway/src/local-start/source-inventory.spec.ts @@ -0,0 +1,293 @@ +/** + * KBN-101-02a — source inventory REDs. + * + * Covers mutation->guard rows 1-6: inherited env, shared dotenv, daemon JSON, + * app JSON, root JSON, redirect. + * + * Every case here plants a forbidden value in exactly ONE source and leaves the + * others clean, so a guard that is removed for that source alone turns exactly + * one case red. Aggregate coverage would not distinguish them. + * + * These read fixture files under a temp root. Nothing here connects to anything. + */ + +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 { + LOCAL_START_SOURCE_LABELS, + inventoryLocalStartSources, + type InventoriedSource, + type LocalStartSourceLabel, +} from './source-inventory.js'; + +/** A Postgres DSN used only as fixture text. Never connected to. */ +const FORBIDDEN_DSN = 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; + +interface Layout { + tempRoot: string; + anchor: string; + homeBase: string; + gatewayHome: string; + paths: Record, string>; +} + +const cleanups: (() => Promise)[] = []; + +afterEach(async () => { + while (cleanups.length > 0) { + const fn = cleanups.pop(); + if (fn) { + await fn(); + } + } +}); + +async function writeFixture(path: string, contents: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, 'utf8'); +} + +async function makeLayout(): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), 'kbn-02a-inventory-')); + const repo = join(tempRoot, 'repo'); + const anchor = join(repo, 'apps', 'gateway', 'src'); + const homeBase = join(tempRoot, 'home'); + // Redirect target lives OUTSIDE the default home location on purpose: a + // daemon-json hit here can only mean the redirect was followed. + const gatewayHome = join(tempRoot, 'redirected-gateway-home'); + + return { + tempRoot, + anchor, + homeBase, + gatewayHome, + paths: { + 'daemon-dotenv': join(homeBase, '.config', 'mosaic', 'gateway', '.env'), + 'monorepo-root-dotenv': join(repo, '.env'), + 'gateway-local-dotenv': join(repo, 'apps', 'gateway', '.env'), + 'daemon-json': join(gatewayHome, 'mosaic.config.json'), + 'app-json': join(repo, 'apps', 'gateway', 'mosaic.config.json'), + 'monorepo-root-json': join(repo, 'mosaic.config.json'), + }, + }; +} + +const CLEAN_LOCAL_JSON = JSON.stringify({ + tier: 'local', + storage: { type: 'pglite' }, + queue: { type: 'local' }, + memory: { type: 'none' }, +}); + +function postgresJson(): string { + return JSON.stringify({ + tier: 'standalone', + storage: { type: 'postgres', url: FORBIDDEN_DSN }, + queue: { type: 'bullmq' }, + memory: { type: 'none' }, + }); +} + +/** Plant a forbidden DSN in exactly one file source; all others written clean. */ +async function plantIn( + layout: Layout, + target: Exclude, +): Promise { + for (const [label, path] of Object.entries(layout.paths)) { + const isTarget = label === target; + const isJson = label.endsWith('json'); + if (isJson) { + await writeFixture(path, isTarget ? postgresJson() : CLEAN_LOCAL_JSON); + continue; + } + await writeFixture( + path, + isTarget ? `DATABASE_URL=${FORBIDDEN_DSN}\n` : 'MOSAIC_LOG_LEVEL=info\n', + ); + } +} + +function find(sources: InventoriedSource[], label: LocalStartSourceLabel): InventoriedSource { + const found = sources.find((source) => source.label === label); + expect(found, `no source reported for label "${label}"`).toBeDefined(); + return found as InventoriedSource; +} + +describe('KBN-101-02a inventoryLocalStartSources', () => { + it('reports every source label, present or not, so no source can be silently skipped', async () => { + const layout = await makeLayout(); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + expect(sources.map((source) => source.label).sort()).toEqual( + [...LOCAL_START_SOURCE_LABELS].sort(), + ); + }); + + // Row 1 — inherited env. + it('row 1: detects a PostgreSQL DSN inherited from the ambient process environment', async () => { + const layout = await makeLayout(); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: { DATABASE_URL: FORBIDDEN_DSN }, + }); + + expect(find(sources, 'process-environment').carriesPostgresDsn).toBe(true); + }); + + it('row 1: detects a non-local tier inherited from the ambient process environment', async () => { + const layout = await makeLayout(); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: { MOSAIC_STORAGE_TIER: 'standalone' }, + }); + + expect(find(sources, 'process-environment').declaredTier).toBe('standalone'); + }); + + // Row 2 — shared dotenv, one case per file so they fail independently. + it('row 2: detects a DSN in the anchored monorepo-root .env', async () => { + const layout = await makeLayout(); + await plantIn(layout, 'monorepo-root-dotenv'); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + expect(find(sources, 'monorepo-root-dotenv').carriesPostgresDsn).toBe(true); + }); + + it('row 2: detects a DSN in the anchored gateway-local .env', async () => { + const layout = await makeLayout(); + await plantIn(layout, 'gateway-local-dotenv'); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + expect(find(sources, 'gateway-local-dotenv').carriesPostgresDsn).toBe(true); + }); + + it('row 2: detects a DSN in the daemon .env at $HOME, which the redirect does not move', async () => { + const layout = await makeLayout(); + await plantIn(layout, 'daemon-dotenv'); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + // Redirect set and pointed elsewhere: the daemon DOTENV must still be + // found at $HOME. Widening the redirect to cover dotenv would break this. + env: { MOSAIC_GATEWAY_HOME: layout.gatewayHome }, + }); + + expect(find(sources, 'daemon-dotenv').carriesPostgresDsn).toBe(true); + }); + + // Rows 3-5 — the three JSON sources. + it('row 3: detects postgres storage declared in the daemon mosaic.config.json', async () => { + const layout = await makeLayout(); + await plantIn(layout, 'daemon-json'); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: { MOSAIC_GATEWAY_HOME: layout.gatewayHome }, + }); + + const daemonJson = find(sources, 'daemon-json'); + expect(daemonJson.carriesPostgresDsn).toBe(true); + expect(daemonJson.declaredStorageType).toBe('postgres'); + }); + + it('row 4: detects postgres storage declared in the anchored app mosaic.config.json', async () => { + const layout = await makeLayout(); + await plantIn(layout, 'app-json'); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + expect(find(sources, 'app-json').carriesPostgresDsn).toBe(true); + }); + + it('row 5: detects postgres storage declared in the anchored monorepo-root mosaic.config.json', async () => { + const layout = await makeLayout(); + await plantIn(layout, 'monorepo-root-json'); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + expect(find(sources, 'monorepo-root-json').carriesPostgresDsn).toBe(true); + }); + + // Row 6 — redirect followed for daemon JSON only. + it('row 6: follows MOSAIC_GATEWAY_HOME when locating the daemon JSON', async () => { + const layout = await makeLayout(); + await plantIn(layout, 'daemon-json'); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: { MOSAIC_GATEWAY_HOME: layout.gatewayHome }, + }); + + const daemonJson = find(sources, 'daemon-json'); + expect(daemonJson.present).toBe(true); + expect(daemonJson.path).toBe(layout.paths['daemon-json']); + }); + + it('row 6: without the redirect, the daemon JSON resolves under $HOME and the redirected file is not read', async () => { + const layout = await makeLayout(); + await plantIn(layout, 'daemon-json'); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: {}, + }); + + const daemonJson = find(sources, 'daemon-json'); + expect(daemonJson.path).toBe( + join(layout.homeBase, '.config', 'mosaic', 'gateway', 'mosaic.config.json'), + ); + expect(daemonJson.carriesPostgresDsn).toBe(false); + }); + + // Shadowing — the property that makes independent inspection necessary. + it('reports a forbidden value in a LOSING source even when a winning source is clean', async () => { + const layout = await makeLayout(); + await plantIn(layout, 'gateway-local-dotenv'); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + // Ambient env would win precedence and is clean; the shadowed file value + // must still be reported. + env: { MOSAIC_STORAGE_TIER: 'local' }, + }); + + expect(find(sources, 'gateway-local-dotenv').carriesPostgresDsn).toBe(true); + }); + + // Non-egress: classification only, never the value. + it('retains no raw DSN text anywhere in the inventory result', async () => { + const layout = await makeLayout(); + await plantIn(layout, 'monorepo-root-dotenv'); + const sources = inventoryLocalStartSources({ + anchor: layout.anchor, + homeBase: layout.homeBase, + env: { DATABASE_URL: FORBIDDEN_DSN }, + }); + + expect(JSON.stringify(sources)).not.toContain(FORBIDDEN_DSN); + expect(JSON.stringify(sources)).not.toContain('mosaic:mosaic'); + }); +}); diff --git a/apps/gateway/src/local-start/source-inventory.ts b/apps/gateway/src/local-start/source-inventory.ts new file mode 100644 index 000000000..ccb4cf147 --- /dev/null +++ b/apps/gateway/src/local-start/source-inventory.ts @@ -0,0 +1,75 @@ +/** + * Local-start source inventory — KBN-101-02a. + * + * Enumerates and reads EVERY configuration source independently, BEFORE any + * precedence is applied. A forbidden value refuses even when it is shadowed by + * a higher-precedence source, so precedence must not be consulted here. + * + * RED PHASE SCAFFOLDING. The signatures below are the contract; the body is a + * deliberately permissive stub that discovers nothing, so the behavioral specs + * fail on their assertions rather than on a missing module. Do not read this + * file as the implementation — there is none yet. + */ + +/** Every configuration source the local channel must inspect. */ +export type LocalStartSourceLabel = + | 'process-environment' + | 'daemon-dotenv' + | 'monorepo-root-dotenv' + | 'gateway-local-dotenv' + | 'daemon-json' + | 'app-json' + | 'monorepo-root-json'; + +export const LOCAL_START_SOURCE_LABELS: readonly LocalStartSourceLabel[] = [ + 'process-environment', + 'daemon-dotenv', + 'monorepo-root-dotenv', + 'gateway-local-dotenv', + 'daemon-json', + 'app-json', + 'monorepo-root-json', +]; + +/** + * What one source was observed to carry. + * + * Deliberately value-free beyond classification: raw DSNs, dotenv contents and + * JSON bodies are never retained here, because anything retained can later be + * logged. Callers get labels and classifications, never secrets. + */ +export interface InventoriedSource { + readonly label: LocalStartSourceLabel; + /** Filesystem path inspected; absent for the ambient process environment. */ + readonly path?: string; + 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; +} + +export interface InventoryInput { + /** Module anchor used to resolve the anchored dotenv/JSON locations. */ + readonly anchor: string; + /** Home base used to resolve the daemon dotenv location. */ + readonly homeBase: string; + /** Ambient environment snapshot. */ + readonly env: Readonly>; +} + +/** + * Inspect every source independently and report what each one carries. + * + * Contract (not yet implemented): + * - 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; + * - no precedence is applied and no value is retained. + */ +export function inventoryLocalStartSources(_input: InventoryInput): InventoriedSource[] { + // RED-phase stub: discovers nothing. See module header. + return []; +} 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 2bb620990..77ec23f40 100644 --- a/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md +++ b/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md @@ -1,281 +1,186 @@ -# KBN-101 — Database Runtime/Migration Role Split +# KBN-101 DB role split — path ownership amendment for `-02a` -**Status:** frozen implementation contract; rc.16 closes HIGH-1 current generic storage-wrapper authority findings, awaiting independent exact-head re-review for [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771) +Status: RED phase. Item `KBN-101-02a` (Stack issue #1178). +Base: `next` @ `216cd72226cd9ee17eea461cfe7cd0e010a22f02`. -**Version:** 1.0.0-rc.16 +This file exists because the defect that produced `-02a` was a document naming an owner that did +not **span** the deliverable. Five guides said `KBN-101-02` delivers fail-closed local-tier/DSN +isolation; `-02`'s scope row and acceptance criteria contain no startup-time refusal at all. So the +first thing `-02a` does is write its own span down, explicitly, before any code. -**Dependency:** KBN-010 → **KBN-101 foundation** → KBN-100 → **KBN-101 deployed-role certification** → KBN-105 -**Scope:** PostgreSQL standalone/federated runtime identity, the sole application DDL runner, TLS bootstrap, readiness, deployment handoff, and evidence. This documentation card changes no database, secret, deployment, CI, runtime, migration, or compose artifact. +## What `-02a` delivers -## 1. Decision, modes, and non-negotiable boundaries +A **dedicated local-development start channel** that refuses, fail-closed, before any connection +and before any DDL, when the start is not an explicitly-declared, isolated, local PGlite start. -Current `main` has one `DATABASE_URL` path in `packages/db/src/client.ts`, `migrate.ts`, `drizzle.config.ts`, storage adapters/CLI, Gateway startup, fleet-backlog, CI, installer output, compose, and Portainer. It also has PostgreSQL DDL outside a controlled migration phase: direct Drizzle scripts, `CREATE EXTENSION` probes, a direct-DDL federated integration test, and platform init SQL. None of those current paths is certified by this contract; every implementation card below must close its listed path. +The channel discriminator is **the entrypoint itself** — a distinct command whose sole contract is +local + PGlite. It is never inferred from tier value or from configuration provenance. Inside the +channel, a non-local tier is refused from _any_ source; there is no explicit or session-scoped +admission of a non-local tier in `-02a`. (Explicit session-scoped admission belongs solely to the +separately sanctioned disposable-demo path, which is not this item and certifies nothing.) + +## Admission contract — everything below is required, and empty/default refuses + +A local start is admitted only when all of the following are explicitly declared for this +invocation: + +| Requirement | Rule | +| ----------- | ----------------------------------------------- | +| tier | explicitly `local` | +| storage | explicitly `pglite` | +| queue | explicitly local | +| state root | explicit, absolute, session-created, disposable | -| Item | Exact name / shape | Rule | -| ------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Runtime URL | `DATABASE_URL` | PostgreSQL **non-owner runtime** connection. Required only by runtime services in `standalone`/`federated`; those services never receive `DATABASE_MIGRATION_URL`. | -| Migration URL | `DATABASE_MIGRATION_URL` | Required only by `mosaic-db-migrator`, the dedicated one-shot runner/Job. It is forbidden in Gateway, storage runtime, fleet, and ordinary CLI environments. | -| Runtime DTO | `DatabaseRuntimeConnectionConfigDto` | `tier`, `databaseUrl`, `runtimeMode`; maps only `DATABASE_URL`. It is not constructible from migration configuration. | -| Migration DTO | `DatabaseMigrationConnectionConfigDto` | `tier`, `migrationDatabaseUrl`, `migrationMode`; maps only `DATABASE_MIGRATION_URL`, is accepted only by `mosaic-db-migrator`, and has no runtime-bootstrap import path. | -| TLS DTO | `DatabaseTlsConfigDto` | `caCertificatePath`, `rejectUnauthorized: true`, `serverName`; validates the mounted CA without serializing its bytes. `serverName` is derived only from the validated connection host. | -| Readiness DTO | `DatabaseSchemaReadinessDto` | `state`, `expectedSchemaVersion`, `observedSchemaVersion`, `migrationRequired`, `roleCheck`, `checkedAt`; no URL, user, host, database name, or secret. | -| Modes | `local`, `standalone`, `federated` | `local` is the explicit PGlite-only exception. `standalone` and `federated` are production-like PostgreSQL modes. | +Derived state is canonicalized under the declared state root: the DB, storage, and queue children +all resolve beneath it. A child that escapes the root — by `..` traversal or by symlink — is +refused. `HOME`- and CWD-derived defaults are never used to locate state. -**No fallback or plaintext:** in production-like modes, missing `DATABASE_URL`, `DATABASE_MIGRATION_URL`, or `DATABASE_TLS_CA_CERT_PATH` is a phase-specific error. No command may substitute a runtime URL, config-file URL, default URL, inferred URL, or hard-coded URL. PostgreSQL URLs must use `sslmode=verify-full` with `rejectUnauthorized: true`; `disable`, `allow`, `prefer`, `require`, `no-verify`, an absent CA, a wrong CA, an unverified certificate, or host/SAN mismatch fails before readiness. PGlite uses only its configured local data directory and explicit PGlite routine; it neither reads nor interprets PostgreSQL URL/TLS variables. +Cross-field consistency is part of the contract, not a separate nicety: `local` + `postgres`, +`local` + `files`, and non-local + `pglite` are all refused, as is any mismatch between the +declared and resolved shape. -**ASSUMPTION K101-A1:** `standalone` and `federated` are the complete current PostgreSQL production-like modes. A future PostgreSQL tier inherits this contract until a versioned amendment names its DNS, secrets, bootstrap, and evidence. +## Forbidden in the local channel, from any source -## 2. The sole PostgreSQL DDL control plane - -`mosaic-db-migrator` is the **only application/CI/test command that may connect with DDL authority to PostgreSQL**. It owns, in one `max: 1` PostgreSQL session: migration-DTO parse, TLS/identity/search-path preflight, advisory-lock acquisition, manifest/ledger reconciliation, migration execution, postflight/readiness verification, lock release, and session close. It rejects `DATABASE_URL`-only invocation **before opening a connection or emitting DDL**. The only exception is the external privileged platform/IaC bootstrap actor (§4/§7), which is not an application command, never receives either application URL, and executes only the fixed bootstrap artifact. - -**Executable contract (KBN-101-03 exclusive):** `packages/db/package.json` publishes the exact mapping `"mosaic-db-migrator": "./dist/cli.js"`; `packages/db/src/cli.ts` compiles to that target. `docker/db-migrator.Dockerfile` builds that package and has the exact image entrypoint `ENTRYPOINT ["mosaic-db-migrator"]`. KBN-101-03 alone owns package/bin/build/pack/discovery tests that execute this compiled bin and image as `mosaic-db-migrator --help|--run|--verify`. The CLI imports only private implementation modules in `packages/db/src/migrator/`; package root exports do **not** expose `runMigrations`, and no other programmatic migration API is public. The sole typed programmatic runner is private to the package/CLI boundary and accepts the typed migration DTO, never a URL/string/SQL/schema/role argument. - -The exact user-facing interface is `mosaic-db-migrator --run` and `mosaic-db-migrator --verify`. `--help` is required and is the only discovery mode. Both commands use the fixed packaged migration folder and accept only `DATABASE_MIGRATION_URL`, `DATABASE_TLS_CA_CERT_PATH`, and non-secret `MOSAIC_DATABASE_TIER`/correlation input from the environment. They reject `DATABASE_URL` as fallback and reject URL, SQL, schema, role, database, migration-folder, and identifier arguments in argv. Stable sanitized process exits are: `0` success; `64` configuration; `65` TLS; `66` unsafe identity; `67` ledger/schema; `68` lock contention; `69` migration failure. CI, Compose, Swarm, and each two-gateway migration Job invoke the immutable db-migrator image with exactly `mosaic-db-migrator --run`; their readiness verification invokes exactly `mosaic-db-migrator --verify`. Required tests cover `--help`, argv rejection, runtime-only refusal before connect, migration-only success, each sanitized error/lock exit, and ordered `postgres-a → mosaic-db-migrator-a → gateway-a` plus `postgres-b → mosaic-db-migrator-b → gateway-b` execution. - -The implementation must inventory and close every present and future entrypoint as follows: - -| Current entrypoint | Required rc.13 disposition | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `packages/db/src/migrate.ts:runMigrations()` | Remove its optional URL, `DATABASE_URL`, and default fallback API from public/runtime exports. Its PostgreSQL behavior moves behind `mosaic-db-migrator`; callers cannot invoke it with an arbitrary URL. | -| `packages/db/src/index.ts` | `KBN-101-03` removes the public `runMigrations` re-export; only the explicit PGlite-local API remains as separately typed local behavior. A compile/import-negative proves `@mosaicstack/db` cannot directly import `runMigrations`; a `DATABASE_URL`-only direct-library attempt fails before connect. | -| `packages/db/drizzle.config.ts` and direct `drizzle-kit migrate` | A database-connecting Drizzle configuration reads only `DATABASE_MIGRATION_URL` through the migration DTO and rejects its absence before connection. Replace direct `drizzle-kit migrate` exposure with `mosaic-db-migrator`. `db:generate` is an offline schema artifact command and must neither resolve nor connect to a URL. | -| `pnpm --filter @mosaicstack/db db:migrate` and CI invocation | Make it a thin `mosaic-db-migrator` wrapper; no direct Drizzle migrator invocation remains. CI supplies an isolated disposable migration URL only to that job. | -| `db:push` / direct `drizzle-kit push` | Forbidden for standalone, federated, CI production-like, Portainer, and any URL outside a disposable developer database. If retained for local experimentation, a wrapper requires `MOSAIC_DISPOSABLE_DEVELOPER_DB=1`, a locally allowlisted disposable target, `DATABASE_MIGRATION_URL`, verified TLS when PostgreSQL is used, and rejects `DATABASE_URL`, any production-like tier, and every non-allowlisted host/database before connection. It is never a release, repair, or migration procedure. | -| current `mosaic storage migrate --run` wrapper | **Current source is legacy N-1, uncertified, and non-operative:** `packages/storage/src/cli.ts` shells directly to `pnpm --filter @mosaicstack/db db:migrate` through `execSync`; no `mosaic-db-migrator` executable exists. It MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation. KBN-101-02 must retire or redirect the wrapper only after KBN-101-03 produces the runner; the wrapper cannot be documented as runner delegation. PGlite remains only its explicitly local routine. | -| `PostgresAdapter.migrate()`, Gateway `DatabaseModule`/startup, and PostgreSQL adapter factories | Runtime PostgreSQL migration is removed: no `runMigrations`, DDL, `CREATE EXTENSION`, or migration-compatible handle is reachable from startup. Gateway performs read-only identity, TLS, `search_path`, and manifest-ledger readiness checks only. | -| `packages/mosaic/src/commands/fleet-backlog.ts` | PostgreSQL fleet backlog never migrates or creates tables. It consumes a ready runtime connection; PGlite may use only its explicit local migration routine. | -| `packages/storage/src/{adapters/postgres,tier-detection}.ts` extension work and `infra/pg-init/01-extensions.sql` | Runtime probes become read-only catalog/extension-presence checks. Extension provisioning is a fixed external bootstrap prerequisite or a reviewed runner migration where the migrator has the required scoped authority; it is never a probe side effect or a reason to grant runtime database CREATE. | -| `packages/db/src/federation.integration.test.ts` direct type/table/index DDL | `KBN-101-02` replaces it with a pre-migrated disposable database created by `mosaic-db-migrator`, or makes the test invoke that runner. The test itself has runtime credentials and no direct DDL. | -| `apps/gateway/src/__tests__/integration/federated-pgvector.integration.test.ts` `CREATE TEMP TABLE` | `KBN-101-02` replaces the temporary runtime DDL with a runner-prepared disposable **persistent** `mosaic.federated_pgvector_fixture` database/table. The test receives only its runtime URL/CA and performs read/query-only qualified-vector assertions (including the selected vector operator); it has no setup hook, temporary privilege, or DDL. Runtime `TEMPORARY` remains denied. | -| `docker/init-db.sql` and `infra/pg-init/01-extensions.sql` | `KBN-101-02` retires these duplicate tracked init artifacts; neither may remain as a hidden extension authority. The sole extension action is the fixed external bootstrap artifact described in §4/§5, or the runner only when its reviewed implementation card explicitly grants that authority. | -| `packages/storage/src/{cli,migrate-tier}.ts` operator/runtime command closure | `KBN-101-02` alone replaces raw SQL/DDL behavior with delegation to `mosaic-db-migrator --run` (or the explicit PGlite-local path). Data copy requires the exact paired non-secret argv references `--target-url-file /run/secrets/mosaic-migrate-target-url` and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`, with the paired non-argv authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`. The trusted `mosaic-db-migrator --verify` producer reads those exact importer materials only to bind and attest them; it never uses importer credentials for DDL/DML. Before target connection the importer verifies strict files, signed target attestation, credential-file digest/version, canonical TLS/database binding, role, manifest, expiry, and replay; after verified TLS but before transaction/DML it verifies server/database identity and schema. It opens only a dedicated non-DDL `mosaic_data_importer` target connection. Its DML registry is limited to declared mutable application tables and excludes schemas, roles, memberships, extensions, extension/catalog objects, Drizzle ledger, and immutable KBN relations. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner/importer role confusion, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and any DDL attempt fail before target connection/DDL or copy. KBN-101-07 documents that produced interface but owns neither source file. | -| `tools/federation-harness/docker-compose.two-gateways.yml` | Active topology; `KBN-101-05` migrates it rather than retires it. It must contain `postgres-a`, `postgres-b`, `mosaic-db-migrator-a`, `mosaic-db-migrator-b`, `gateway-a`, and `gateway-b`; each database has separate runtime/migrator URL and CA consumers, TLS server material, verified-TLS readiness, and runner-before-Gateway ordering. Its existing plaintext URLs/init mount are forbidden. | -| `docs/fleet/backlog-conventions.md` | `KBN-101-07` removes the current automatic first-use PostgreSQL `runMigrations()` claim and makes current behavior PGlite-only. PostgreSQL CLI/runner/readiness authority is held until activation; a documentation-route `DATABASE_URL`-only test asserts no first-use/migration instruction is executable before connect. | -| `docs/PERFORMANCE.md` | `KBN-101-07` removes direct `drizzle-kit migrate` and Gateway-startup `runMigrations()` instructions. Its runner sequence is explicit non-operative future evidence, not a current procedure; a documentation-route `DATABASE_URL`-only test asserts neither direct command nor Gateway fallback remains. | -| `README.md`, `CLAUDE.md`, `docs/guides/{admin-guide,dev-guide,deployment,migrate-tier,user-guide}.md`, `docs/federation/{MILESTONES,SETUP,TASKS}.md`, `docs/fleet/backlog-conventions.md`, `docs/PERFORMANCE.md`, `docs/design/storage-abstraction-middleware.md`, and `docs/plans/{2026-03-15-agent-platform-architecture,2026-03-15-wave2-tui-layout-navigation}.md` documentation hits | `KBN-101-07` owns the non-normative disposition inventory in §2.2. It replaces, retires, or labels status-only every direct `db:migrate`, `db:push`, `CREATE EXTENSION`, first-use migration, Gateway-startup migration, generic `mosaic storage migrate`, Compose-before-runner, and production plaintext/environment credential instruction. `README.md` and `docs/guides/dev-guide.md` retain only PGlite data-layer/no-PostgreSQL work plus optional non-PostgreSQL Compose services; Gateway/Web local start is held until -02 rejects daemon, inherited, root, and app-local PostgreSQL DSNs plus non-local tier input before connection or DDL. `docs/guides/deployment.md` is non-operative for PostgreSQL/federated/bare-metal production until KBN-101-00/-03/-05 land; it may describe only the future external-bootstrap → TLS/roles → runner → verified-readiness → Gateway/Compose order and the renderer/Vault generation-pinned process-exec or `LoadCredential` consumer boundary. No current document advertises the runner as executable, a broad Compose PostgreSQL start, a production `.env`/monorepo auto-load/environment-file route, a credential shell export or argv, or restart-as-secret activation. A future runner mention is valid only as the explicit held, non-operative procedure that names KBN-101-00/-03/-05 and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. The current Gateway loader’s unguarded local `.env` behavior is explicitly local-only and itself makes bare-metal production non-operative until KBN-101-02/-05 replace it with the renderer-backed consumer boundary. Historical shipped-status language is adjacent to an exact non-authorizing KBN-101 note; `docs/federation/MILESTONES.md` is status-only, its former startup-extension wording is superseded and forbidden, and it authorizes no current DDL, Compose/init, or startup path. Documentation is a DDL entrypoint and cannot advertise a bypass. | -| New package scripts, test helpers, setup hooks, CLI commands, installers, CI steps, adapters, or operator docs | A repository check rejects any new PostgreSQL DDL-capable entrypoint or instruction unless it is the dedicated runner or the named external bootstrap artifact. No future script may accept a URL parameter or `DATABASE_URL` as a DDL escape hatch. | - -The runner must run the original migration bytes, including shipped `0009`; no migration command may repair a ledger by manual insertion, adoption, `db:push`, or schema diff. Tests requiring PostgreSQL schema consume a pre-migrated disposable database or invoke this exact runner. - -`KBN-101-06` alone owns `.woodpecker/ci.yml`, `tools/ci/kbn101-ddl-inventory.ts`, `tools/ci/kbn101-ddl-inventory.spec.ts`, `tools/ci/fixtures/kbn101-ddl-inventory.json`, `tools/ci/kbn101-entrypoint-matrix.ts`, and `tools/ci/kbn101-entrypoint-matrix.spec.ts`. The scanner inventory has exactly the common fields `path`, `class`, `ownerCard`, `disposition`, `allowedTokens`, `rationale`, `expiry`, and `reviewRevision`; each disposition then has its separately required fields. `class` is one of `executable-source`, `script-or-package-bin`, `operator-document`, `deploy-manifest`, or `normative-contract`; `ownerCard` is one KBN-101 card; `disposition` is one of `runner`, `delegate`, `deny`, `retire`, `superseded-document`, `active-secure-data-migration`, `status-only`, or `allowlisted`; and `allowedTokens` is a nonempty subset of the fixed rules below only for `allowlisted` or `superseded-document`. `normative-contract` is excluded from **operator execution**, never from scanning: it is valid only for the exact KBN PRD/contract/shared/task canonical paths, must carry declarative requirements rather than an executable shell instruction, and a fenced executable instruction or an imperative bypass in that class fails rather than being masked. An `active-secure-data-migration` record has empty `allowedTokens` and additionally requires `route`, `targetCredentialOption`, `targetCredentialFile`, `targetCredentialVersionFile`, `targetAttestationOption`, `targetAttestationFile`, `attestationProducer`, `attestationKeyIds`, `attestationBindings`, `targetSchemaPrerequisite`, `targetConnectionRole`, `forbiddenInputs`, and `routeTest`; its parser rejects a missing common or disposition-specific field, a route that accepts a credential value on argv, an attestation missing any canonical binding, or a target that is not runner-prepared/verified. The latter `superseded-document` disposition is allowed only for the exact architecture-plan path when adjacent text says the direct command is superseded/MUST NOT run and names `mosaic-db-migrator --run`. The scanner rejects duplicate path owners, ownerless non-allowlisted rows, missing paths, malformed fields, an inventory path outside its declared class, and every unrecognized active command. The classifier's exact case-insensitive token/rule set is: `runMigrations\\s*\\(`; `drizzle-kit\\s+(?:migrate|push)`; `\\bdb:(?:migrate|push)\\b`; `mosaic\\s+storage\\s+migrate`; `mosaic-db-migrator\\s+--(?:run|verify)\\b`; `CREATE\\s+(?:TEMP(?:ORARY)?\\s+)?(?:EXTENSION|TABLE|TYPE|INDEX|SCHEMA)`; `ALTER\\s+(?:EXTENSION|TABLE|TYPE|SCHEMA)`; `DROP\\s+(?:EXTENSION|TABLE|TYPE|INDEX|SCHEMA)`; and `DATABASE_URL` when it occurs in the same file as any preceding rule. Independently of those lexical rules, every operator document and deploy manifest receives a path-level semantic assertion that rejects an operative `extension`, `schema`, or `migration` described as `created`, `installed`, or `applied automatically` on `first boot` or `startup`; a `docker compose ... up`/Compose-up/start-first sequence before `mosaic-db-migrator --run` and `--verify`; any init-script/init-SQL authority; and any production `.env`, monorepo auto-load, `EnvironmentFile=`, credential shell-export/argv, or restart-as-secret-activation route. The semantic assertion runs before inventory disposition: a named path, `status-only`, `normative-contract`, or any other record can never suppress it. Every operator-document `mosaic-db-migrator --run` or `mosaic-db-migrator --verify` hit first fails as unqualified unless it is inside one Markdown section headed exactly `Held future procedure` (case-insensitive, bounded through the next heading of equal-or-higher level). That one section must explicitly say non-operative/no-current-command-authority, name **all** KBN-101-00/-03/-05 prerequisites, and contain the complete ordered external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness sequence. The scanner rejects a runner hit outside that section, a second held-procedure section, an adjacent/descendant command after the section, or any missing marker, prerequisite, stage, order, or readiness endpoint before ownership/status/normative masking. The failing fixtures include the former `docs/federation/SETUP.md` wording, the exact former `docs/federation/MILESTONES.md` wording `pgvector extension installed + verified on startup`, and these exact former Compose-first sequences: `README.md`: `docker compose up -d` → `pnpm install` → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify`; `docs/guides/dev-guide.md`: `docker compose up -d` → root `.env` PostgreSQL `DATABASE_URL` → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify`; and `docs/guides/deployment.md`: `cp .env.example .env` → `docker compose up -d` → `pnpm install` → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify`. The production credential fixtures include a production `.env`, `EnvironmentFile=`, credential shell export or credential-bearing argv, and setting a credential followed by a restart. The runner fixtures include the exact former architecture-plan imperative `mosaic-db-migrator --run`, the former PERFORMANCE executable `--run`/`--verify` block, the former backlog PostgreSQL-CLI runner claim, and an otherwise-valid runner line adjacent to but outside a `Held future procedure` section; each fails before ownership/status/normative classification can mask it. The README fixture passes only when the checked-in direct CI `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` is explicitly asserted as **legacy N-1**, **active**, **uncertified**, **non-authorizing as an operator route**, **isolated disposable CI database**, and **pending KBN-101-06 removal**, with no operator-command block or ordinary-behavior claim; omitting any status term or presenting it as approved authority fails before masking. The rc.15 `Held future procedure` fixture—local PGlite or non-PostgreSQL Compose only; then, after KBN-101-00/-03/-05 land, explicit non-operative/no-current-command-authority external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness—is the one passing runner form. It scans exact-path current executable source/scripts/package bins, operator documents, deploy manifests, and named normative contracts; any lexical or semantic hit not represented by a permitted record fails. The matrix harness invokes the compiled runner/package/image and produced deployment artifacts; it edits no producer file. - -The canonical inventory includes this active—not historical—operator route record: `path=docs/guides/migrate-tier.md`, `class=operator-document`, `ownerCard=KBN-101-07`, `disposition=active-secure-data-migration`, `allowedTokens=[]`, `route=mosaic storage migrate-tier`, `targetCredentialOption=--target-url-file`, `targetCredentialFile=/run/secrets/mosaic-migrate-target-url`, `targetCredentialVersionFile=/run/secrets/mosaic-migrate-target-version`, `targetAttestationOption=--target-attestation-file`, `targetAttestationFile=/run/mosaic-attestations/migrate-target.v1.json`, `attestationProducer=mosaic-db-migrator --verify`, `attestationKeyIds=[migrate-target-v1-active,migrate-target-v1-overlap]`, `attestationBindings=[credentialSecretVersion,credentialFileSha256,tlsHostPortDatabase,caSpkiFingerprint,postgresSystemIdentifier,databaseOid,expectedImporterRole,manifestFingerprint,schemaFingerprint]`, `targetSchemaPrerequisite=mosaic-db-migrator --verify`, `targetConnectionRole=mosaic_data_importer`, `forbiddenInputs=[--target-url,DATABASE_URL,runtime-owner,signing-key]`, and `routeTest=packages/storage/src/migrate-tier.spec.ts::secureTargetRouteAndAttestation`. The -06 scanner/matrix must reject a changed/missing field, any `--target-url` or credential-bearing argv, a `DATABASE_URL` target fallback, a missing/unsafe/substituted credential or authenticated-version file, mixed URL/version generation, missing/wrong importer CA mount, missing/stale/replayed/tampered/wrong-key attestation, wrong binding, wrong mode/owner/link count, runtime-owner/importer confusion, target connection before attestation verification, or any DDL attempt before target connection/DDL. - -### 2.1 Target-bound importer attestation v1 - -`mosaic-db-migrator --verify` is the trusted producer only after its verified-TLS identity, manifest/ledger, and schema checks pass. Its root-owned launch wrapper is the sole reader of the fixed `MOSAIC_DB_ATTESTATION_SIGNING_KEY_FILE=/run/secrets/mosaic-db-migrate-target-ed25519` reference: that regular, non-symlink `root:root` `0400` Ed25519 private-key file is mounted only to the one-shot runner. It opens the key once, passes signing capability only in memory to `mosaic-db-migrator`, and never mounts, forwards, logs, prints, or otherwise exposes the private key to the importer, runtime, or an application container. KBN-101-05 renders both that runner-only secret and the importer-only pinned public-key/key-ring mount; an alternative env path, unsafe key file, or key material in importer/runtime is a hard failure. - -After verification the `10003:10003` migrator atomically emits the credential-free, non-secret producer artifact to its **producer-only** handoff mount `/run/mosaic-attestations-producer/migrate-target.v1.json`: write a same-directory `0600` temporary file, write canonical bytes, `fsync` file and directory, set final `10003:10003` `0400` owner/mode, and rename atomically. A privileged deployment handoff controller runs only after runner success and before importer creation. It receives neither importer URL bytes nor private key: only a root-owned non-secret `0400` renderer generation descriptor (expected provider version, URL SHA-256, generation ID) and pinned public verifier key. It safe-opens/verifies that descriptor and the signed artifact against expected bindings, copies exact bytes into a new importer-only mount, fsyncs file/directory, sets `10002:10002` `0400`, and atomically renames `/run/mosaic-attestations/migrate-target.v1.json`. The controller then seals that importer mount read-only and starts the importer. It never alters payload bytes, forwards URL/version/key material, or exposes either mount to Gateway/runtime/unrelated containers; a controller failure leaves no importer process. The producer and importer never share a writable file or mount. The single JSON envelope carries an RFC 8785 JCS canonical UTF-8 `attestation` payload plus a detached Ed25519 `signature` envelope (`algorithm`, `keyId`, base64 signature); the signature covers only the canonical payload bytes, never a reformatted document. The v1 payload fields are exactly `format`, `version`, `keyId`, `issuedAt`, `expiresAt`, `nonce`, `targetCredentialSecretVersion`, `targetCredentialFileSha256`, `tlsHost`, `tlsPort`, `database`, `caSpkiFingerprint`, `postgresSystemIdentifier`, `databaseOid`, `expectedImporterRole`, `manifestFingerprint`, `schemaFingerprint`, `producerInvocation`, `producerBuildDigest`, `producerImageDigest`, and `correlationId`. It contains no URL/DSN, username, password, private key, or credential bytes. The target credential must be a high-entropy DSN secret because its SHA-256 is non-secret binding evidence, never a substitute secret. - -For binding, the renderer obtains the importer URL `url` and its authenticated provider version from the **same successful Vault KV-v2 response** at canonical path `secret-{env}/mosaic-stack/database/importer`: `data.data.url` and `data.metadata.version`. The version is never inferred from DSN bytes, a hash, a filename, or an unverified side channel. The renderer treats exact URL bytes plus this metadata version as one generation and produces separately mounted immutable copies for the migrator-attestation producer and importer consumer. The migrator opens each copy only with `O_RDONLY|O_CLOEXEC|O_NOFOLLOW`, validates from the opened fd with `fstat` that it is a regular file with its exact fixed owner, `0400` mode, and link count `1`, then digests the URL from that fd and reads the authenticated version from its paired fd. It reads these importer-only materials solely to bind/sign; it never connects, performs DDL/DML, forwards, exports, logs, or passes either URL/version to a child environment. It zeroizes protected URL memory and closes both fds after signing. The runner uses `DATABASE_MIGRATION_URL` for its verification connection; it does not use importer credentials for DDL or DML. - -`mosaic storage migrate-tier` accepts exactly `--target-url-file /run/secrets/mosaic-migrate-target-url` and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; the paired authenticated provider-version file is fixed, non-argv `/run/secrets/mosaic-migrate-target-version`. Before **any** target connection it safe-opens the URL, version, attestation, and pinned public-key files with `O_RDONLY|O_CLOEXEC|O_NOFOLLOW`, verifies post-open `fstat` regular-file/exact-owner/`0400`/link-count-one invariants, and validates public key/key ID, JCS signature/canonicality, expiry, nonce replay cache, authenticated provider version, URL-fd digest, canonical host/port/database, the CA file at exact `DATABASE_TLS_CA_CERT_PATH`, expected importer role, and manifest/schema fields. It reads URL bytes once into protected memory, digests/parses/connects from those exact bytes with no reread, then zeroizes memory and closes fds. Missing, substituted, symlinked, hard-linked, wrong-owner, wrong-mode, changed-after-open, mixed-generation, stale, replayed, tampered, wrong-key, revoked, missing-CA, wrong-mount, or wrong-binding inputs return stable sanitized errors with **zero target connection and zero DDL**. The nonce replay cache is durable through the artifact expiry window and atomically claims `(keyId, nonce, credentialFileSha256)` before connecting; a replay is terminal. A connecting dry-run consumes this nonce and MUST obtain fresh `mosaic-db-migrator --verify`, a new artifact handoff, and a new nonce before any `--yes` copy; tests prove the old artifact is rejected. New active and overlap public keys are pinned by key ID; rotation accepts only the bounded overlap, revocation removes a key immediately, and a provider version rotation invalidates the old attestation and requires a freshly rendered generation and runner verification/signature. - -Only after verified TLS connection and before transaction/DML, the importer compares server system identifier, database OID, `current_user`, CA/SPKI, and manifest/schema fingerprint with the attestation. A mismatch closes the connection with **zero DML/DDL**. Its grants and statement classifier make DDL impossible. KBN-101-02 tests consumer isolation, absent/wrong CA mount, missing/wrong/stale/replayed/tampered attestation, wrong/revoked key, substituted URL/version file, symlink/hardlink/mode/owner/TOCTOU rejection, mixed URL/version generation, provider rotation/revocation, wrong host/database/CA/role/manifest/system ID, digest/version mismatch, no URL/version/attestation/key logging or error oracle, and both `zero target connection` versus `connection then zero DML` outcomes. KBN-101-03 tests JCS/signing/atomic producer and fd-zeroization/close semantics; KBN-101-05 tests generation-pinned private/public key, URL/version, CA, and artifact mounts/rendering; KBN-101-06 invokes the complete matrix; KBN-101-07 owns the operator guide only. - -### 2.2 Complete current documentation disposition inventory - -The KBN-101-06 fixture records these exact non-normative paths: `README.md`, `CLAUDE.md`, `docs/federation/MILESTONES.md`, `docs/federation/SETUP.md`, `docs/guides/admin-guide.md`, `docs/guides/dev-guide.md`, `docs/guides/deployment.md`, `docs/guides/migrate-tier.md`, `docs/guides/user-guide.md`, `docs/fleet/backlog-conventions.md`, `docs/PERFORMANCE.md`, `docs/plans/2026-03-15-agent-platform-architecture.md`, `docs/plans/2026-03-15-wave2-tui-layout-navigation.md`, and `docs/design/storage-abstraction-middleware.md` as `operator-document`; each is runner/delegate/deny/superseded/active-secure as adjacent text specifies. `README.md` and `docs/guides/dev-guide.md` are `deny` for current PostgreSQL Compose/startup authority and Gateway/Web local startup; they permit only PGlite data-layer work plus explicitly selected non-PostgreSQL Compose services until -02 proves daemon/inherited/root/app-local DSN and non-local-tier rejection before connection or DDL. `docs/guides/deployment.md` is `deny` for current PostgreSQL/federated/bare-metal production activation and plaintext/environment credential delivery; its future renderer/Vault process-exec or `LoadCredential` schematic is non-executable until -00/-03/-05. `docs/federation/MILESTONES.md` and `docs/federation/TASKS.md` are exact `operator-document` `status-only` historical records: their shipped M1 language is superseded and cannot authorize current DDL, extension creation, importer execution, Compose/init, startup, or credential authority. Before inventory, ownership, or status masking, the semantic suite injects each exact former generic-wrapper form below; each MUST fail. - -```text -README.md (commented code fence) -# `mosaic storage migrate --run` is a schema-wrapper compatibility command only; -# it delegates solely to mosaic-db-migrator and is not tier data copy. - -docs/guides/user-guide.md (executable code fence) -# Schema migration (compatibility wrapper only; not a tier data copy) -mosaic storage migrate --run # delegates only to mosaic-db-migrator -``` - -A source-consistency test reads `packages/storage/src/cli.ts` and proves that the current command description plus `execSync` target is direct `pnpm --filter @mosaicstack/db db:migrate`, that no current `mosaic-db-migrator` bin exists, and that any documentation claiming runner delegation fails. The fixture also uses the exact former MILESTONES wording, the exact former README/dev/deployment Compose-first sequences, the former architecture-plan/PERFORMANCE/backlog runner-authority routes, and the README legacy-CI status assertion as semantic cases; every failing route fails before an inventory status-only record can mask it, and the legacy-CI case passes only with its exact non-authorizing status terms.`docs/PRD.md`, this contract, `SHARED-CONTRACT.md`, and `native-kanban-sot/TASKS.md`are exact`normative-contract` records; their declarative KBN requirements are scan-visible but excluded from operator execution and cannot mask an instruction. The full-doc scanner must fail an unknown active command, a missing record, or a status-only/normative path containing executable bypass or production credential guidance. - -The only allowlist categories are `historical-sql` (`packages/db/drizzle/**`, byte-immutable runner input only), `pglite-local` (an explicitly PGlite-only source/test path), `negative-test-literal` (a focused negative test), `vendored-generated` (a generated or vendored artifact), and `historical-review-report` (`docs/reports/**` only). Every allowed record names its exact path, token(s), rationale, expiry, and review revision. No allowlist category is valid for an executable current source/script/package bin, operator document, deploy manifest, `README.md`, `CLAUDE.md`, `docs/plans/**`, or `docs/guides/**`; `historical-review-report` cannot contain an operative command. Scanner self-tests place each token in an unowned current path, prove duplicate-owner/ownerless/path-existence failure, prove an operative `db:migrate` instruction in a path labeled historical fails rather than being masked, and prove every unqualified runner `--run|--verify` operator-document hit fails before ownership/status masking. The scanner is a classifier plus path inventory and review—not a naive token scan alone—and never by itself proves DDL authority. For every non-allowlisted inventory row and `gateway-a`/`gateway-b`, the matrix proves `DATABASE_URL`-only, missing migration URL, direct library import, direct Drizzle, `db:push`, init artifact, operator route, runtime-only fixture/test, and each harness migration Job/Gateway fail before connection/DDL as applicable. The `db:push` negatives also cover production-like tier and production-like URL rejection. - -## 3. Exact migration manifest, ledger, and lock - -### 3.1 Manifest v1 - -The runner generates and verifies a source-controlled **migration manifest v1** from the shipped Drizzle journal and SQL files. A record has exactly: - -```text -logicalIndex: non-negative integer from journal array position -journalTag: exact journal `tag` string -migrationSha256: lowercase SHA-256 of the exact migration `.sql` file bytes -``` - -The canonical SQL bytes are the raw Git blob bytes at the signed source-release commit, not workstation checkout bytes. KBN-101-03 adds/validates an LF-pinning `.gitattributes` rule for `packages/db/drizzle/**/*.sql`, generates the source-controlled manifest from those canonical blobs in CI, and makes the runner verify deployed file bytes against the manifest before DDL. No newline, Unicode, whitespace, SQL, or line-ending normalization is applied. Manifest canonical serialization is UTF-8 bytes of: - -```text -mosaic-drizzle-manifest-v1\n -\t\t\n -... in ascending logicalIndex with no omitted index -``` - -`manifestSha256` is SHA-256 of those canonical bytes. The manifest has no timestamp-derived ordering; `folderMillis`, legacy ledger `id`, and `created_at` are diagnostic only. KBN-101-03 corrects journal **logical** order metadata (including the current `0008`/`0009` anomaly) without changing shipped `0009` bytes. - -The runner stores certification in `drizzle.__mosaic_migration_manifest` with exactly one active v1 row (`manifest_version=1`, `manifest_sha256`, `certified_at`). It is owned by `mosaic_schema_owner`; only the migrator after `SET ROLE mosaic_schema_owner` may insert/update it; runtime gets `SELECT` only, and `PUBLIC` gets no schema/table privilege. Existing databases receive this table through the runner after backup/preflight; it is not created by Gateway, a test, or manual SQL. - -### 3.2 Reconciliation and 0009 transition - -The expected ledger is the ordered list of v1 manifest tuples. The runner reads every observed `drizzle.__drizzle_migrations.hash`, maps **each observed hash to exactly one** manifest tuple, and rejects a missing, unknown, duplicate, ambiguous, corrupt, or stale-replica mapping. Equality is tuple-complete: every expected tuple occurs once, no additional tuple occurs, and the manifest digest matches. A count comparison or hash-set comparison is forbidden. Physical legacy insertion `id`, insertion timestamp, and order are expressly non-normative. - -For an existing database: - -1. take the KBN-101-07 approved backup and capture a read-only inventory before changing anything; -2. if `0009` is missing **and** its effects are absent, run the original shipped `0009` through `mosaic-db-migrator` and record it normally; -3. if the `0009` hash is present but physically late, accept it when its one-to-one tuple mapping is exact; -4. if an expected hash is missing while its effects are partial or complete, or catalog/ledger evidence conflicts, fail closed as `DATABASE_MIGRATION_RECONCILIATION_AMBIGUOUS`. Recovery is backup restoration or an explicit separately reviewed repair artifact with its own owner, tests, backup, rollback, and approval—not manual ledger insertion/adoption; and -5. write/update the v1 certification row only after exact reconciliation and final catalog/readiness verification. - -Required runner tests cover clean, pre-0009, skipped-0009/effects-absent, applied-late, duplicate, unknown, missing, corrupt tuple-pair, partial/full-effect ambiguity, stale replica, backup/restore, and rerun idempotence. The tests prove the `0009` SQL bytes are unchanged and logical journal ordering—not physical ledger order—controls reconciliation. - -### 3.3 Fixed advisory-lock namespace - -Before any preflight that can decide migration state, `mosaic-db-migrator` acquires `pg_try_advisory_lock(1297044289, 1262636593)`. The constants are signed-int4-safe fixed namespace values: class `1297044289` (`MOSA`) and object `1262636593` (`KBN1`). The same `max: 1` session retains it for preflight, reconcile, migrate, verify, release, and close. Failure returns `DATABASE_MIGRATION_LOCKED` immediately; a process crash releases it when the PostgreSQL connection closes. Runtime readiness remains unready while a holder is active and never waits by running migrations. Tests prove concurrent contention, crash/connection-loss release, readiness while held, and non-interference from an unrelated advisory key. - -## 4. PostgreSQL roles, identifiers, and trusted sessions - -Role creation, passwords, membership, database ownership, certificates, and Vault values are platform/IaC/operator work—not Drizzle/application migrations. Application SQL must not issue credential/role management statements or embed credentials. - -| Role | Attributes and ownership | Membership / session use | -| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mosaic_platform_database_owner` | `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; platform-only database owner after bootstrap. | Never granted to application roles. | -| external platform bootstrap actor | Provider/operator/IaC-controlled, externally audited **superuser** identity outside the Mosaic role graph and Vault/application configuration. | Creates/transitions the database and roles, then retires from application use. It alone `SET ROLE`s the extension owner for `CREATE EXTENSION`, `ALTER EXTENSION ... UPDATE`, or `ALTER EXTENSION ... SET SCHEMA`, records the action, and `RESET ROLE`s. | -| `mosaic_schema_owner` | `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; owns only `mosaic`/`drizzle` schemas and application/ledger objects. It has only `USAGE` on `mosaic_extensions` for fixed legacy type resolution. | Never an application login; no ownership, `CREATE`, `ALTER`, `DROP`, extension/member-change, or default-privilege authority in `mosaic_extensions`; its migrator subphase never receives temporary `CREATE` there. | -| `mosaic_extension_owner` | Dedicated `NOLOGIN SUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS` extension owner, distinct from platform/schema/migrator/runtime. It is used solely to own/maintain untrusted `vector`, `mosaic_extensions`, and every owner-bearing extension member there. | `rolcanlogin=false`, `rolsuper=true`, and **zero members** are catalog-proven. No application role has membership, `SET ROLE`, credential, or inheritable grant. An externally controlled, audited platform-bootstrap **superuser** session alone executes `SET ROLE mosaic_extension_owner` for fresh creation, approved-owner update/relocation, or shadow bootstrap, then `RESET ROLE`; no persistent membership is ever granted. | -| `mosaic_migrator` | `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`. | Only `mosaic_schema_owner`; runner verifies `session_user=mosaic_migrator`, `SET ROLE mosaic_schema_owner`, then `current_user=mosaic_schema_owner`. | -| `mosaic_data_importer` | `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; dedicated data-copy identity, not a DDL or schema owner. | It is used only after runner preparation/verification through the KBN-101-02 file-reference interface. It has no owner/migrator/extension membership and cannot `SET ROLE mosaic_extension_owner`, or `ALTER`/catalog-or-extension-member `UPDATE`/`DROP`/change extension membership. Its bounded data-copy DML is not extension authority. | -| `mosaic_runtime_capability` | `NOLOGIN` and no ownership/administrative attributes. | Holds only named runtime grants. | -| `mosaic_runtime` | `LOGIN INHERIT` with no ownership/administrative attributes. | Only `mosaic_runtime_capability WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`; never owner/migrator member. | -| `mosaic_runtime_user_capability` | `NOLOGIN`, no ownership/admin attrs. | Holds the **User (god)** rung's named grants incl. `INSERT(status)`, `UPDATE(status)` + the status-transition RLS `WITH CHECK` policy on `tasks`; **plus the SOLE `INSERT/UPDATE/DELETE` grant on `task_status_write_override`** (B-1); never granted to Orchestrator/base. | -| `mosaic_runtime_orchestrator_capability` | `NOLOGIN`, no ownership/admin attrs. | Holds the **Orchestrator (near-god)** rung's grants; identical to User **except** (i) every `tasks` write policy **subqueries** the override table and is REJECTED at the sink when an active User deny row exists for that `(workspace_id, task_id, orchestrator)` (B-1); (ii) `INSERT(status)` is pinned by `WITH CHECK` to a **non-terminal initial status** (N-3) — only User may INSERT an arbitrary/terminal status; (iii) it has only `SELECT` (never write) on the override table. Scope is tier/task-identity only (F6). | -| `mosaic_runtime_user` | **`LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`**, no ownership; **non-owner of every sink table + the override table**. | Member of `mosaic_runtime_user_capability` only (`WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`); the connection-selection **User-rung** credential. **[AD-1]** Requires `CONNECT`, denied `TEMPORARY`. | -| `mosaic_runtime_orchestrator` | **`LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`**, no ownership; **non-owner of every sink table + the override table**. | Member of `mosaic_runtime_orchestrator_capability` only (same INHERIT/SET/ADMIN flags); the **Orchestrator-rung** credential. **[AD-1]** Requires `CONNECT`, denied `TEMPORARY`. | - -> The existing `mosaic_runtime` is confirmed already `LOGIN INHERIT` with no admin attrs (frozen L150) and non-owner; it becomes the **others / deny-by-default** base rung. On the sink it retains `SELECT` (workspace-scoped `USING`, B-2), `INSERT (explicit non-status column list)` (F1), and column-level `UPDATE` on **non-status, non-invariant, non-key, non-tenancy** columns only (F7), each `USING` workspace-scoped; it has **no** `INSERT(status)`, **no** `UPDATE(status)`, **no** `DELETE`, **no** `TRUNCATE` on `tasks`, **no** write on the override table, and RLS denies its status/override writes by default. The rung set is **fixed at three**. -> -> **F2 note:** the three rung LOGIN roles' `NOBYPASSRLS` + `NOSUPERUSER` + **non-ownership** are load-bearing — `FORCE ROW LEVEL SECURITY` collapses for any SUPERUSER/BYPASSRLS role and RLS is silently bypassed by a table **owner**. These attributes are declared in -00 bootstrap, catalog-proven in -00 tests, and re-verified at every checkout by -01 (extended L222 assertion). - -The fixed application/runtime schema is **`mosaic`**. Every application/runtime pooled connection executes and verifies exactly `SET search_path TO pg_catalog, mosaic` before its first application query; connection checkout repeats this after reset/reconnect. Transactional application operations use `SET LOCAL search_path TO pg_catalog, mosaic` and verify it before query execution. `public` and `$user` are forbidden in all runtime paths. - -The sole runner has one narrowly bounded **legacy-history bootstrap subphase** for the immutable current journal: before it runs, external bootstrap revokes `CREATE` on `public` from `PUBLIC`, temporarily assigns its ownership to `mosaic_schema_owner`, and denies all runtime connections. The externally controlled audited bootstrap-superuser session first executes `SET ROLE mosaic_extension_owner` to create/own `mosaic_extensions` and fresh `vector`, or to perform approved-owner relocation/update, records that control-plane action, and executes `RESET ROLE`; it receives no membership because a superuser can assume the role without one. In its locked `max:1` migration session only, after `SET ROLE mosaic_schema_owner`, the runner has only `USAGE` (not ownership or `CREATE`) on `mosaic_extensions` and uses the fixed legacy-only `SET LOCAL search_path TO pg_catalog, public, mosaic_extensions` solely so immutable `0001` resolves its unqualified existing `vector` type. It cannot create, alter, drop, reassign, or change a member there. The preflight catalogs `pg_namespace.nspowner`, schema ACL/default ACLs, `pg_extension.extowner`, and member owners, then directly proves `CREATE`/`ALTER`/`DROP`/member-change denial for runtime, migrator, and schema owner. After relocation/catalog verification and before manifest certification or any runtime readiness, the runner transfers `public` ownership to `mosaic_platform_database_owner`, revokes application `USAGE`/`CREATE`, and restores `pg_catalog,mosaic`. This compatibility subphase is not a runtime/operator option, accepts no configuration identifier, has no fallback, and is removed/disabled before KBN-101-08. - -`KBN-101-03` exclusively owns `packages/db/src/schema.ts`, `packages/db/drizzle/meta/*`, `packages/db/drizzle/meta/_journal.json`, the generated relocation migration, and exact database/Drizzle tests. It freezes one exported `export const mosaic = pgSchema('mosaic')`; every application `pgTable` and every application `pgEnum` must be declared through that export. The generated snapshot/journal and future `db:generate` output must target `mosaic` only; a static declaration/SQL test rejects a default-schema application `pgTable`/`pgEnum`, `public` application declaration, or future generated application DDL outside `mosaic`. Historical `0000` through current SQL and the shipped journal provenance are byte-immutable and execute only in the trusted legacy-`public` subphase; they are never rewritten to claim they originally targeted `mosaic`. - -### 4.1 pgvector ownership and supported transition - -The selected extension policy is fixed for PostgreSQL 17 + pgvector 0.8.2. Its actual target-image `vector.control` evidence must show `relocatable = true` and no `trusted = true` or `superuser = false` setting: `vector` is therefore untrusted and creation requires superuser authority. Fresh databases: the external audited bootstrap-superuser session executes `SET ROLE mosaic_extension_owner`, creates and owns `mosaic_extensions`, then creates `vector` as `CREATE EXTENSION vector WITH SCHEMA mosaic_extensions`; it records the control-plane action and executes `RESET ROLE`. Under that role it revokes default `TABLES`, `SEQUENCES`, `FUNCTIONS`, `TYPES`, and `SCHEMAS` privileges from `PUBLIC`, `mosaic_schema_owner`, `mosaic_migrator`, `mosaic_data_importer`, and `mosaic_runtime`, then grants only the explicitly required read/type/function privileges. It validates `rolcanlogin=false`, `rolsuper=true`, zero role members, `pg_namespace.nspowner`, `pg_extension.extowner`, and owner-bearing extension-member ownership/schema/version (ownerless PostgreSQL catalog member classes are verified as ownerless, never falsely assigned), schema/default privileges, `RESET ROLE`, and the external audit record. Approved-owner update/relocation is likewise performed only by that external session while set to `mosaic_extension_owner`; the existing path is eligible only when `extowner` is already exact. `mosaic_extensions` remains non-writable by runtime, migrator, schema owner, importer, and every service role. They have no membership in `mosaic_extension_owner` and must fail catalog assertions plus `SET ROLE`, `CREATE`/`ALTER`/`DROP EXTENSION`, extension-member `UPDATE`/DDL, and role-membership change denials. Shadow migration and rollback repeat these owner/default-privilege/preflight assertions before copying, before atomic switch, after resume, and before read-only rollback. - -PostgreSQL has **no supported** `ALTER EXTENSION ... OWNER TO`. No card may invent it, mutate system catalogs, use `DROP ... CASCADE`, or adopt legacy extension ownership. An existing `vector` is eligible for the clean in-place path only when `pg_extension.extowner` already resolves to the approved `mosaic_extension_owner`; after exact supported-version, `extrelocatable=true`, dependency, and complete expected member-set checks, the bootstrap actor may perform the tested `ALTER EXTENSION vector SET SCHEMA mosaic_extensions`. The same postflight verifies `extowner`, each member owner/schema, version, dependency inventory, and denial for runtime/migrator/schema owner. - -A `vector` extension owned by a legacy runtime/single login is **not activated in place**. It fails closed before activation and requires this controlled shadow-database migration: (1) approved backup and read-only inventory; (2) create new database/roles; (3) the audited external bootstrap-superuser session `SET ROLE mosaic_extension_owner`, creates `mosaic_extensions`/`vector`, records the audit event, and `RESET ROLE`; (4) sole runner applies migrations and relocation; (5) copy data with vector dimensions, row counts, checksums, FK, and sequence evidence; (6) quiesce/drain writers; (7) apply and verify final delta; (8) prove role/TLS/readiness; (9) atomically switch connections; and (10) retain the old database read-only for the approved rollback window. Partial/cancel/resume and rollback preserve the old read-only source until the switch; no target unable to shadow-migrate is eligible until separately reviewed. Required tests cover fresh, approved-owner clean existing relocation, legacy-owner shadow path, partial/resume/rollback, N-1, exact `extowner`/member/schema/version assertions, and `SET ROLE`/ALTER/DROP/member-update denials for runtime, migrator, schema owner, importer, and all service roles. - -**Superuser exception and threat boundary:** PostgreSQL superuser authority cannot be privilege-limited by `GRANT`/`REVOKE`; this is deliberately **not** a least-privilege claim for `mosaic_extension_owner`. The containment is its dedicated identity, `NOLOGIN`, zero membership, absence from runtime credentials/Vault/application containers, external control-plane-only audited use, and independent review. Any extension create/update/schema operation is a control-plane change requiring independent review, approved backup/rollback, maintenance window, and audit evidence. A managed target that cannot establish this exact `NOLOGIN SUPERUSER`, zero-member, externally controlled role is ineligible until an independently approved, versioned provider-owned extension-owner profile exists; it must not silently retain app/migrator ownership. - -`schema.ts` must emit `mosaic_extensions.vector(...)`, and all vector casts/operators/functions in runner, application queries, fixtures, and generated SQL must explicitly qualify `mosaic_extensions` (for example `OPERATOR(mosaic_extensions.<->)`); `mosaic_extensions` is deliberately **not** added to runtime `search_path`. - -Before relocation, the runner records a parameterized catalog inventory and dependency graph. It must classify, in dependency order, application enum/domain/base types; owned and identity sequences; application tables; table columns/defaults; functions/procedures; views/materialized views; extension-owned objects; then triggers, constraints/FKs, indexes, and dependent rules/policies. It moves base types, sequences, tables, and functions as required; OID-bound constraints/indexes follow their owning objects and are catalog-verified rather than recreated blindly. Every object must be expected exactly once and be in either the immutable legacy/bootstrap allowlist, the `mosaic` application allowlist, or the selected `mosaic_extensions` extension membership; unknown, extra, duplicate, cross-schema, dependency-cycle, or partial-resume state fails closed. No raw client-side identifier interpolation is allowed. - -`KBN-101-00` owns bootstrap-role/schema/extension/default-privilege and direct-denial tests. `KBN-101-03` owns the runner integration tests that consume those bootstrap fixtures: approved-owner existing relocation, legacy-owner shadow migration, interrupted/partial relocation resume, partial/cancel shadow resume, and pre-activation reverse rollback-before-activation. Reverse rollback is allowed only before KBN-101-08 and restores the approved backup or the reviewed inverse relocation, never a runtime `search_path` bypass. Its N-1 order is: current legacy release → inactive bootstrap/runner and `mosaic` declarations → catalog relocation/generated-artifact verification or approved shadow migration → verified non-owner runtime activation. The combined evidence includes byte-immutable historical execution, no public application objects/declarations/future SQL, vector type/cast/operator query success under fixed `pg_catalog,mosaic`, exact extension-owner/member/schema/version assertions, catalog and direct ALTER/DROP/member-change denials for runtime/migrator/schema owner, and all extension eligibility negatives. - -No SQL identifier may come from URL/config/environment/operator input. Catalog comparisons use parameter values. The fixed identifiers above are constants; the external bootstrap artifact alone may use server-side `format('%I', fixed_allowlisted_identifier)` after allowlist validation. Raw client-side interpolation for identifiers, `SET search_path`, database, schema, role, table, or extension names is forbidden. Tests include injection-shaped values, a poisoned pooled-session reset, and transaction `SET LOCAL` restoration negatives. - -External bootstrap executes `REVOKE CONNECT, TEMPORARY ON DATABASE FROM PUBLIC`, then grants `CONNECT` only to `mosaic_runtime`, `mosaic_runtime_user`, `mosaic_runtime_orchestrator`, `mosaic_migrator`, and the time-bounded external bootstrap actor while it is required. Certification fails if an unrelated login retains `CONNECT` or any of the three application logins retains `TEMPORARY`. Runtime receives `USAGE` on `mosaic`, named table/sequence grants through `mosaic_runtime_capability`, and `USAGE` on `drizzle` plus `SELECT` only on `drizzle.__drizzle_migrations` and `drizzle.__mosaic_migration_manifest`. **[NB-5]** The two new capability roles `mosaic_runtime_user_capability` and `mosaic_runtime_orchestrator_capability` additionally receive the identical baseline grants: `USAGE` on `mosaic`, `USAGE` on `drizzle` plus `SELECT` only on `drizzle.__drizzle_migrations` and `drizzle.__mosaic_migration_manifest`, and the relevant sequence `USAGE`/`SELECT` grants — mirroring `mosaic_runtime_capability` — so the frozen §6 L222 runtime verify does not fail closed on missing inherited/sequence/ledger grants for the two new rungs. `INSERT`, `UPDATE`, `DELETE`, `TRUNCATE`, and DDL rights on ledger/manifest are revoked. Revoke public CREATE and function EXECUTE; `SECURITY DEFINER` is forbidden unless a separately reviewed exception pins trusted path and grants only the capability role. Database TEMPORARY, role management, extension, schema, and object ownership are denied. - -Immutable KBN relations, after KBN-100 creates them, grant runtime only `SELECT, INSERT` and explicitly deny `UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER`: `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts`. KBN-100 retains RESTRICT/no-cascade semantics. Foundation certification verifies the role/schema boundary only; post-KBN-100 certification verifies this real deployed-role matrix. - -> **rc.20 (Envelope A) — authorized by Jason's declarative-RBAC B1 ruling + Mos OPTION A + Mos Q1 (TIER-LEVEL) + Mos Q2 (RLS):** adds (i) the fixed **User/Orchestrator/others** runtime rung-roles (per-ROLE, deny-by-default); each rung **LOGIN** role is `NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`, **NON-OWNER** of every sink/override table, **added to the §4 L176 `CONNECT` allowlist**, and **asserted `TEMPORARY`-denied**. **No per-federated-user role or credential is created** — federated identity maps to a rung **at authorization time only**, and per-writer **attribution lives in metadata columns** (app-supplied `actor_id` on `task_events`), **never** in DB credentials/roles. (ii) Two new fixed rung DSN secrets **`mosaic-db-runtime-user-url`** and **`mosaic-db-runtime-orchestrator-url`** (Gateway-only, `0600 10001:10001`), extending the frozen runtime-URL secret row. (iii) **RLS `WITH CHECK` write-source policies (including the B2-INSERT per-rung INSERT tenant binding `workspace_id = current_setting('mosaic.workspace_id', true)::uuid`, composed as a SINGLE compound `WITH CHECK` or `AS RESTRICTIVE` — never multi-permissive-intended-to-AND, per the v5 composition mandate) + per-rung `SELECT`/`UPDATE` `USING` tenant policies + `FORCE ROW LEVEL SECURITY`** on the sink table `tasks` **and on the new sink-resident `task_status_write_override` table** (tighten-only, deny-by-default) — a §10 schema-v1 **mechanism addition** beyond the prior grant/revoke-only model, authorized by Mos Q2 as coordinator co-authorization with Jason's B1. **The two new rung capability roles additionally receive the baseline frozen §4 L176 runtime grants (`USAGE ON mosaic`, `drizzle` ledger `SELECT`, sequence grants) [NB-5] so they satisfy the frozen §6 L222 runtime verify.** (iv) The **`task_status_write_override` table** (keyed `(workspace_id, task_id, tier)`, no per-writer key), written only by the User rung, read by the Orchestrator/base rung `tasks` write-policy subquery, homes the User-override **at the sink**. The sink DDL (grants + RLS `USING`/`WITH CHECK` + override table + FORCE + unique key + trigger/view/rule invariants) is homed in the **KBN-100 producer** after it creates `tasks`, with `FORCE RLS` as the **terminal** migration step after all backfill/repair DML, per the §4 L178 producer/consumer precedent. -> -> **Frozen-invariant note (authorization basis):** touching the frozen role graph, the §4 L176 CONNECT allowlist, and adding RLS + the override table are §10 contract-change classes (MISSION-MANIFEST §10; SHARED-CONTRACT L82 (rc.6 non-effect) / L88-90 (rc.5 non-effect), both amended by this rc.20; frozen §4 L176). They are authorized here by **Jason B1 + Mos OPTION A + Mos Q1 + Mos Q2**. The addition is strictly a **fixed per-ROLE** set + a **tighten-only** RLS mechanism + a **task/tier-scoped** override table. It stops exactly at the HALT boundary: **no** per-federated-user topology is introduced. - -## 5. Deployable verified-TLS bootstrap - -`mosaicstack/stack` is the named repository/control plane. Ownership is intentionally non-overlapping: **KBN-101-00 exclusively owns** the versioned external bootstrap interface `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, and `infra/pg-bootstrap/README.md`, plus its bootstrap tests—roles, extension-owner transition, and no renderer/deployment manifests. **KBN-101-05 exclusively owns** `tools/db/render-postgres-secrets.ts`, its tests, the current `docker-compose.yml`, `docker-compose.federated.yml`, `deploy/portainer/federated-test.stack.yml`, `tools/federation-harness/docker-compose.two-gateways.yml`, and `apps/gateway/Dockerfile`; it consumes the versioned KBN-101-00 bootstrap interface and owns no bootstrap SQL. The named **Mosaic deployment control plane / Jason** is activation authority; the environment-specific IaC/Vault owner supplies only approved input secret versions and may not substitute an unreviewed current-repository artifact. The KBN-101-05 renderer is the only deployment handoff: it reads secret-provider references, validates owners/modes/digests/SANs, writes each output atomically (`mkstemp` on the target tmpfs, `fsync`, `chmod`/`chown`, atomic rename), and records only secret-version identifiers and hashes. - -`KBN-101-05` changes the Gateway image to fixed non-root `USER 10001:10001`, the importer image to fixed non-root `USER 10002:10002`, and the migrator process to fixed non-root `USER 10003:10003`; image and renderer tests freeze every UID:GID. A root-only migrator launch wrapper may open the private signing key before dropping to `10003:10003`, but the migrator process never regains root. Gateway CA and Gateway leaf-certificate mounts, and its own Gateway private key only when it terminates its HTTPS listener, must be readable by `10001:10001`; PostgreSQL private keys and migration-only material are never mounted there, and no secret is world-readable. PostgreSQL is not assigned a guessed UID/GID: its image must first be pinned by digest, and an image-inspection plus rendered Compose/Swarm test freezes the image's effective PostgreSQL UID:GID before the renderer selects mount owner/group. A digest, service UID/GID, rendered secret `uid`/`gid`/`mode`, or container `USER` mismatch is a KBN-101-05 failure. Mosaic applications never generate, self-sign, copy, or persist production certificates; the external bootstrap actor receives them only through the deployment secret mechanism and no plaintext development exception exists for production-like modes. - -| Material | Vault target / deployment secret | Mount, injection, and authorized consumer | -| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | -| Runtime URL | `secret-{env}/mosaic-stack/database/runtime` (`url`) → `mosaic-db-runtime-url-v1` | Gateway only: `/run/secrets/mosaic-db-runtime-url`, `0600`, `10001:10001`; entrypoint maps it to `DATABASE_URL` only at process exec. It is denied to every migrator, storage runtime, fleet, ordinary CLI, and test except an explicit runtime-negative fixture. | -| Runtime URL (User rung) | `secret-{env}/mosaic-stack/database/runtime-user` (`url`) → `mosaic-db-runtime-user-url-v1` **(PROVISIONAL-CONVENTION — the exact Vault subpath is inferred from the Runtime URL row's naming pattern and is TO-CONFIRM against the live Vault layout at KBN-101-03/-05 implementation; the secret NAME `mosaic-db-runtime-user-url` is envelope-fixed, the `.../database/runtime-user` subpath is not.)** | Gateway only: `/run/secrets/mosaic-db-runtime-user-url`, `0600`, `10001:10001`; entrypoint maps it to the User-rung pool DSN only at process exec. Denied to every migrator, storage runtime, fleet, ordinary CLI, and test except an explicit runtime-negative fixture. | -| Runtime URL (Orchestrator rung) | `secret-{env}/mosaic-stack/database/runtime-orchestrator` (`url`) → `mosaic-db-runtime-orchestrator-url-v1` **(PROVISIONAL-CONVENTION — the exact Vault subpath is inferred from the Runtime URL row's naming pattern and is TO-CONFIRM against the live Vault layout at KBN-101-03/-05 implementation; the secret NAME `mosaic-db-runtime-orchestrator-url` is envelope-fixed, the `.../database/runtime-orchestrator` subpath is not.)** | Gateway only: `/run/secrets/mosaic-db-runtime-orchestrator-url`, `0600`, `10001:10001`; entrypoint maps it to the Orchestrator-rung pool DSN only at process exec. Denied to every migrator, storage runtime, fleet, ordinary CLI, and test except an explicit runtime-negative fixture. | -| Migration URL | `secret-{env}/mosaic-stack/database/migrator` (`url`) → `mosaic-db-migrator-url-v1` | Each one-shot migrator only: `/run/secrets/mosaic-db-migrator-url`, `0600`, fixed migrator UID:GID asserted by the image/render test; entrypoint maps it only to `DATABASE_MIGRATION_URL`. It is denied to Gateway, storage runtime, fleet, and ordinary CLI. | -| Importer target URL + provider version | **Vault KV-v2** `secret-{env}/mosaic-stack/database/importer` (`url`) with the same successful response `data.metadata.version` → generation-pinned `mosaic-db-importer-url-v` / `mosaic-db-importer-version-v` | The renderer consumes value and authenticated provider version as one generation, never derives version from DSN bytes. It renders separate immutable copies: migrator-attestation producer only gets `/run/secrets/mosaic-migrate-target-url` and `/run/secrets/mosaic-migrate-target-version`, each `10003:10003` `0400`; importer only gets the same two fixed paths, each `10002:10002` `0400`. Each consumer receives its own read-only mount, never a shared writable file. Runtime, Gateway, ordinary CLI, fleet, and unrelated containers receive neither file nor version. | -| Attestation signing key | `secret-{env}/mosaic-stack/database/migrate-target-attestation` (`private_key`) → `mosaic-db-migrate-target-ed25519-v1` | Runner root-wrapper only: fixed `/run/secrets/mosaic-db-migrate-target-ed25519`, `root:root` `0400`, referenced only by `MOSAIC_DB_ATTESTATION_SIGNING_KEY_FILE`; it opens once then drops to migrator `10003:10003`. No importer/runtime/Gateway mount or log/export is permitted. | -| Attestation public key ring | versioned deployment public-key bundle → `mosaic-db-migrate-target-ed25519-public-v1` | Importer only: pinned `/run/mosaic-attestations/migrate-target.ed25519.pub`, `10002:10002` `0400`; active/overlap key IDs are explicit and revoked IDs fail closed. It contains no private key. | -| Handoff verifier inputs | root-owned non-secret generation descriptor (expected provider version, URL SHA-256, generation ID) plus pinned public verifier key | Privileged controller only: `0400`; no importer URL bytes or private key. It verifies artifact bindings before copy/start. | -| Target attestation artifact | runner-produced non-secret file | Migrator writes only `/run/mosaic-attestations-producer/migrate-target.v1.json`, `10003:10003` `0400`, on a producer-only mount. After runner success, privileged deployment handoff controller verifies and atomically copies the exact signed bytes to a distinct importer-only `/run/mosaic-attestations/migrate-target.v1.json` mount, `10002:10002` `0400`, seals it read-only, then creates importer. No importer write access, shared writable mount, Gateway/runtime access, or unrelated-container mount exists. | | -| CA bundle | `secret-{env}/mosaic-stack/database/tls-ca` (`certificate`) → generation-pinned `mosaic-db-ca-v` | Explicit DB-client consumers only: Gateway `/run/secrets/mosaic-db-ca.crt` `10001:10001` `0444`; migrator same path `10003:10003` `0444`; importer at the exact `DATABASE_TLS_CA_CERT_PATH=/run/secrets/mosaic-db-ca.crt`, `10002:10002` `0444`. PostgreSQL receives a distinct read-only CA copy only when client-cert validation is enabled; no unrelated container receives it. | -| Gateway leaf certificate | `secret-{env}/mosaic-stack/federation/gateway-server-tls` (`certificate`) → `mosaic-gateway-server-cert-v1` | Gateway only: `/run/secrets/mosaic-gateway-server.crt`, `0444`, `10001:10001`; renderer emits this exact Compose and Swarm target and validates it before start. | -| Gateway private key | same Vault record (`private_key`) → `mosaic-gateway-server-key-v1` | Gateway only: `/run/secrets/mosaic-gateway-server.key`, `0400`, `10001:10001`; not mounted to migrator or PostgreSQL and never world-readable. | -| PostgreSQL leaf certificate | `secret-{env}/mosaic-stack/database/postgres-server-tls` (`certificate`) → `mosaic-postgres-server-cert-v1` | PostgreSQL only: `/run/secrets/mosaic-postgres-server.crt`, `0444`, frozen verified postgres UID:GID. | -| PostgreSQL private key | same Vault record (`private_key`) → `mosaic-postgres-server-key-v1` | PostgreSQL only: `/run/secrets/mosaic-postgres-server.key`, `0400`, frozen verified postgres UID:GID; never mounted to Gateway or migrator. | - -Compose renders each value-plus-provider-version generation into a non-repository temporary generation directory, `fsync`s every file and directory, atomically renames the complete generation, and mounts immutable per-consumer copies only after the pair is complete. Swarm declares distinct versioned secret/config references for each migrator/importer consumer and generation. A deployment may not start, reload, or combine any URL/version/CA/attestation/public-key material across generations. The privileged handoff controller is the only bridge from migrator producer mount to importer mount: it verifies the signed producer artifact plus generation, copies atomically, seals importer read-only, and only then starts importer; it is tested for failed/partial copy, wrong generation, wrong owner/mode, and no importer start. KBN-101-05 rejects bind-mounted committed cert/key files, environment-encoded PEM, missing secrets, non-atomic renderer output, mixed generations, shared writable files, private-key access outside its named consumer (Gateway for Gateway key; PostgreSQL for PostgreSQL key), importer URL/version/attestation/key access outside their named consumer, CA outside explicit DB clients, and any world-readable URL/key. The existing target Vault names are planned canonical paths and must be verified/provisioned by the deployment-owner input; the planning card does not claim they exist. - -The server leaf SANs are frozen to actual connection DNS names, not a configurable alias: - -| topology | PostgreSQL service DNS names that must be SANs | Gateway leaf SANs / consumers | -| ------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| standalone compose | `DNS:postgres`, `DNS:localhost` (only for the documented host-port disposable test path) | `DNS:gateway`; its Gateway process consumes runtime URL + CA + Gateway leaf/key only | -| federated compose | `DNS:postgres-federated`, `DNS:localhost` (only for the documented host-port disposable test path) | `DNS:gateway-federated`; runtime/migrator consume only their database-specific material | -| Portainer/Swarm federated test stack | `DNS:postgres` (the in-stack service endpoint used by Gateway and migrator) | `DNS:gateway`; same consumer isolation | -| two-gateway harness | `DNS:postgres-a`, `DNS:postgres-b` | `DNS:gateway-a`, `DNS:gateway-b`; each Gateway gets only its own runtime URL, CA, and Gateway leaf/key; each `mosaic-db-migrator-{a,b}` gets only the matching migration URL and CA | - -A new topology requires a versioned amendment before issuance. The PostgreSQL container activation artifact sets `ssl=on`, `ssl_cert_file`, and `ssl_key_file` to those paths, uses a locked-down `postgresql.conf` include, and verifies file ownership/mode before start. The server health/readiness gate makes a `verify-full` CA/SAN-validated connection as the approved runtime/migrator identity; `pg_isready` alone is insufficient. Migration Job starts only after server TLS readiness. Gateway replicas start only after a successful runner result and independently pass verified-TLS, identity, search-path, and ledger readiness. In the two-gateway harness this ordering occurs independently as `postgres-a → mosaic-db-migrator-a → gateway-a` and `postgres-b → mosaic-db-migrator-b → gateway-b`; no Gateway starts against its database before its own runner certificate succeeds. - -**Fresh DB:** provision CA/leaf/secrets and server TLS configuration before initial database bootstrap; bootstrap/extension prerequisites run, then the runner migrates over verified TLS, then runtime deploys. **Existing DB:** take the approved backup, provision/mount TLS material, and **drain/scale to zero every N-1 runtime, worker, CLI maintenance process, and replica before TLS enforcement**. Enable server TLS, terminate any residual non-TLS PostgreSQL backend sessions, set `pg_hba.conf` to `hostssl` for all application/migrator CIDRs with no matching `host` rule, reload/restart as required, and prove the non-TLS session count is zero. Only then prove `verify-full` through the existing endpoint, run reconciliation/migration, and roll the non-owner TLS runtime. There is no plaintext transition interval. - -**Rotation/rollback:** stage a CA bundle containing old+new trust roots to runtime/migrator, validate a new server leaf with exact SANs, restart PostgreSQL and validate it, roll consumers, then remove the old root only after evidence. Credential rotation remains independent and never mounts migration material into Gateway. Before expiry, rollback restores the prior known-valid leaf/key and overlapping CA bundle, restarts PostgreSQL, enforces `hostssl`, terminates residual non-TLS sessions, and verifies `verify-full`; it never downgrades `sslmode`, restores a plaintext-only N-1 runtime after enforcement, or accepts plaintext. A pre-enforcement abort may restore the backed-up N-1 state only before `hostssl` is enabled and is recorded as an aborted—not activated—release. The runbook records expiry windows, secret versions, backup ID, drained-service/session evidence, activation actor, and validation result—not secret values. - -Required disposable tests cover standalone compose, federated/Swarm, and the two-gateway harness positives using verified TLS, plus for **both** gateway/database pairs: missing CA, wrong CA, wrong PostgreSQL SAN, wrong Gateway SAN, `sslmode` downgrade, missing/mispermissioned server or Gateway key, runtime-with-migrator-secret, cross-pair secret leakage, rendered secret-consumer/UID/GID/mode isolation, legacy plaintext drain/termination/`hostssl` enforcement, and readiness-before-migration negatives. PGlite local tests are explicitly classified as non-PostgreSQL and do not satisfy a PostgreSQL TLS test. - -## 6. Runtime verification and sanitized failures - -Before accepting traffic, runtime queries only parameterized/sanitized identity and privilege metadata on its already-open verified-TLS connection. It fails closed for owner/migrator identity or assumability; superuser/CREATEROLE/CREATEDB/BYPASSRLS; object/schema/database ownership; TEMPORARY; unexpected function execute; missing inherited capability/sequence/ledger grants; any non-exact search path; wrong schema/database target; bad TLS; or manifest/ledger mismatch. After KBN-100 it also checks every immutable grant/denial and relation presence. - -The runner performs reciprocal preflight under the same session/lock: dedicated migration DTO only, verified TLS, exact allowlisted target, migrator `session_user`, schema-owner `current_user`, exact trusted search path, and no unsafe attributes. It fails before DDL otherwise. - -Stable sanitized codes are `DATABASE_RUNTIME_URL_REQUIRED`, `DATABASE_MIGRATION_URL_REQUIRED`, `DATABASE_TLS_REQUIRED`, `DATABASE_TLS_VERIFICATION_FAILED`, `DATABASE_ROLE_UNSAFE`, `DATABASE_ROLE_GRANT_MISMATCH`, `DATABASE_SEARCH_PATH_UNSAFE`, `DATABASE_SCHEMA_MISMATCH`, `DATABASE_MIGRATION_RECONCILIATION_AMBIGUOUS`, `DATABASE_MIGRATION_LOCKED`, `DATABASE_MIGRATION_IDENTITY_UNSAFE`, `MIGRATE_TARGET_ATTESTATION_REQUIRED`, `MIGRATE_TARGET_ATTESTATION_INVALID`, `MIGRATE_TARGET_ATTESTATION_EXPIRED`, `MIGRATE_TARGET_ATTESTATION_REPLAYED`, `MIGRATE_TARGET_BINDING_MISMATCH`, `MIGRATE_TARGET_FILE_UNSAFE`, `MIGRATE_TARGET_GENERATION_MISMATCH`, `MIGRATE_TARGET_CA_REQUIRED`, and `MIGRATE_TARGET_CONSUMER_ISOLATION`. Logs/metrics may contain code, tier, manifest fingerprint, role class, and correlation ID only; never DSN, username, host, database name, SQL parameter, secret, or raw catalog result. External health exposes only unavailable/not-ready. - -## 7. Safe DAG, activation, and rollback authority - -Every KBN-101 card remains one PR with exclusive ownership. Cards `00`–`07` may merge only as **prepared, inactive capability**: no current owner-runtime deployment consumes their image/config, and no compatibility switch is exposed to a runtime operator. They must not retain `ALLOW_LEGACY_*`, runtime DDL, `DATABASE_URL` migration fallback, plaintext TLS, direct Drizzle, or test-only bypass flags. Current owner-runtime deployments remain on their known N-1 release until final activation. - -| Card | Depends on | Complete, disjoint file/glob manifest and required test/evidence paths | -| --------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `KBN-101-00` platform bootstrap / IaC | contract | **Only:** `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, `infra/pg-bootstrap/tests/**`. It creates the extension-owner role/schema/extension interface and proves fresh, approved-owner, legacy-shadow, catalog/default-privilege, and direct-denial bootstrap cases. No renderer, runner, Compose, CI, or deployment path. | -| `KBN-101-01` typed runtime config and verifier | 00 | **Only:** `packages/config/src/index.ts`, `packages/config/src/mosaic-config.ts`, `packages/config/src/mosaic-config.spec.ts`; `packages/db/src/client.ts`, `packages/db/src/defaults.ts`, `packages/db/src/connection-identity.ts`, `packages/db/src/client.spec.ts`, `packages/db/src/defaults.spec.ts`, `packages/db/src/connection-identity.spec.ts`; `apps/gateway/src/database/database.module.ts`, `apps/gateway/src/database/database.module.spec.ts`. It supplies runtime/migration/TLS DTO parsing plus runtime identity/search-path/readiness verification. No migrator, storage, installer, deploy, or CI path. | -| `KBN-101-10` declarative sink-RBAC + per-role connection-selection + credential-handoff | 00,01 | **Only:** `packages/db/src/sink-rbac/**`, `packages/db/src/credential-handoff/**`. It homes: the source-controlled declarative RBAC policy artifact + schema + evaluator; the fixed **rung ladder** + federation-aware identity→rung resolver; the rung→DB-credential **connection-selection** module (consumes -01 pools; import direction `10→01`); the **status-preserving-UPSERT** contract + **invariant field-set** module + **no-status view/rule/trigger enumeration**; and the **SCM_RIGHTS** credential-handoff + **non-dumpable** assert/verify library. It creates no roles, no DDL, no Compose/CI/deploy path; it is consumed by -00 (role names), -01 (connection wiring + identity/attribute verify), -02/-03/-05 (write path + fd handoff), specified-against by **KBN-100** (sink DDL: grants/RLS/`USING`+`WITH CHECK` policies/override-table/FORCE/unique-key/trigger-view-rule-invariant on `tasks`), and certified deployed by KBN-101-09. | -| `KBN-101-03` sole runner, manifest, and schema foundation | 00,01,10 | **Only:** `.gitattributes`; `packages/db/package.json`; `packages/db/drizzle.config.ts`; `packages/db/src/cli.ts`, `packages/db/src/cli.spec.ts`, `packages/db/src/index.ts`, `packages/db/src/index.import-negative.spec.ts`, `packages/db/src/migrate.ts`, `packages/db/src/migrate.test.ts`, `packages/db/src/schema.ts`, `packages/db/src/schema.spec.ts`; `packages/db/src/migrator/**`; `packages/db/drizzle/*.sql`, `packages/db/drizzle/meta/*.json`; `docker/db-migrator.Dockerfile`, `docker/db-migrator.Dockerfile.spec.ts`; `packages/db/package-bin.spec.ts`. It alone publishes `"mosaic-db-migrator": "./dist/cli.js"`, verifies source/build/pack/discovery, and sets `ENTRYPOINT ["mosaic-db-migrator"]`; it exclusively owns `packages/db/src/migrator/target-attestation.dto.ts`, `target-attestation-signer.ts`, and their specs: fixed-key reference validation, JCS canonical payload, Ed25519 signing, producer TLS/identity/manifest binding, atomic artifact emission, and producer tests. It owns journal/manifest/ledger/lock/relocation/shadow tests. Shipped `0009` bytes stay unchanged. | -| `KBN-101-02` runtime DDL closure | 01,03,10 | **Only:** `docker/init-db.sql`, `infra/pg-init/01-extensions.sql`; `packages/storage/src/adapters/postgres.ts`, `packages/storage/src/adapters/postgres.spec.ts`, `packages/storage/src/factory.ts`, `packages/storage/src/factory.spec.ts`, `packages/storage/src/types.ts`, `packages/storage/src/tier-detection.ts`, `packages/storage/src/tier-detection.spec.ts`, `packages/storage/src/cli.ts`, `packages/storage/src/cli.spec.ts`, `packages/storage/src/migrate-tier.ts`, `packages/storage/src/migrate-tier.spec.ts`, `packages/storage/src/migrate-tier.integration.test.ts`; `apps/gateway/src/main.ts`, `apps/gateway/src/__tests__/integration/federated-boot.pg-unreachable.integration.test.ts`, `apps/gateway/src/__tests__/integration/federated-boot.success.integration.test.ts`, `apps/gateway/src/__tests__/integration/federated-pgvector.integration.test.ts`; `packages/db/src/federation.integration.test.ts`; `packages/mosaic/src/commands/fleet-backlog.ts`, `packages/mosaic/src/commands/fleet-backlog.spec.ts`. It consumes the -03 runner and exclusively owns importer verification/interface in `packages/storage/src/{cli,migrate-tier}.ts` and the named unit/integration specs: fixed URL/version/attestation/public-key safe-open fds (`O_RDONLY | O_CLOEXEC | O_NOFOLLOW`plus post-open regular-owner-mode-link-count validation), one-read protected-memory URL connection, fd zeroization/close, signature/key/expiry/replay/authenticated-provider-version/digest/generation/CA/binding validation, no forwarding/logging/oracle, post-TLS zero-DML comparison, consumer isolation, and DDL classifier. It closes runtime/retired-init DDL only; removes the current Gateway production`.env`/monorepo auto-load path in favor of the -05 renderer-backed process-exec or `LoadCredential` consumer boundary; and excludes every -03 signer/runner, index, migrate, and Drizzle-config asset, and every deployment/CI/doc path. | -| `KBN-101-04` installer/wizard | 01 | **Only:** `packages/mosaic/src/stages/gateway-config.ts`, `packages/mosaic/src/stages/gateway-config.spec.ts`, `packages/mosaic/src/stages/gateway-config-cors.spec.ts`, `packages/mosaic/src/stages/wizard-menu.spec.ts`, `packages/mosaic/src/wizard.ts`. It persists only non-secret references/injected-variable contracts; source inspection excludes `tools/install.sh`, which does not read/write the database DSN. | -| `KBN-101-05` renderer and deployment | 00,03,10 | **Only:** `tools/db/render-postgres-secrets.ts`, `tools/db/render-postgres-secrets.spec.ts`; `apps/gateway/Dockerfile`, `apps/gateway/Dockerfile.spec.ts`; `docker-compose.yml`, `docker-compose.spec.ts`; `docker-compose.federated.yml`, `docker-compose.federated.spec.ts`; `deploy/portainer/federated-test.stack.yml`, `deploy/portainer/federated-test.stack.spec.ts`; `tools/federation-harness/docker-compose.two-gateways.yml`, `tools/federation-harness/docker-compose.two-gateways.spec.ts`. It consumes the -00 bootstrap interface and -03 immutable runner image, and exclusively renders/tests fixed Gateway/importer/migrator UIDs; runner-only root-owned signing-key reference; KV-v2 importer URL plus same-response `data.metadata.version`; separate immutable generation-pinned URL/version mounts for `10003:10003` migrator and `10002:10002` importer; importer-only CA at `DATABASE_TLS_CA_CERT_PATH`, public-key/key-ring, and controlled producer-only-to-importer-only attestation handoff mount; privileged controller verification/copy/fsync/atomic-rename/seal-before-importer-start behavior; Compose generation-dir fsync/atomic-rename and Swarm versioned-secret/config no-mixed-generation behavior. It owns no bootstrap, runner, config, storage, or CI file. | -| `KBN-101-07` operator/runbook/docs | 02,03,04,05 | **Only:** `README.md`, `CLAUDE.md`, `docs/guides/admin-guide.md`, `docs/guides/dev-guide.md`, `docs/guides/deployment.md`, **`docs/guides/migrate-tier.md`**, `docs/guides/user-guide.md`, `docs/federation/MILESTONES.md`, `docs/federation/SETUP.md`, `docs/federation/TASKS.md`, `docs/fleet/backlog-conventions.md`, `docs/PERFORMANCE.md`, `docs/design/storage-abstraction-middleware.md`, `docs/plans/2026-03-15-agent-platform-architecture.md`, `docs/plans/2026-03-15-wave2-tui-layout-navigation.md`, `docs/runbooks/kbn-101-database-role-split.md`, `docs/reports/native-kanban-sot/kbn-101-operator-readiness-report.md`, `docs/native-kanban-sot/tests/kbn-101-operator-docs.spec.ts`. It exclusively owns the active migrate-tier operator route; local PGlite/non-PostgreSQL Compose disposition; the held PostgreSQL/federated activation order; and the non-operative production renderer/Vault generation-pinned process-exec or `LoadCredential` consumer-isolation schematic. It documents interfaces produced by -02/-03/-04/-05, including both file references, signing-key isolation, attestation bindings, rotation/replay, no-connection/no-DML errors, and the ban on current production `.env`, monorepo auto-load, environment-file, credential export/argv, or restart-as-secret-activation guidance; it owns no source, storage, CLI, runner, or CI file. | -| `KBN-101-06` CI classifier and command matrix | 02,03,05,07,10 | **Only:** `.woodpecker/ci.yml`, `tools/ci/kbn101-ddl-inventory.ts`, `tools/ci/kbn101-ddl-inventory.spec.ts`, `tools/ci/fixtures/kbn101-ddl-inventory.json`, `tools/ci/kbn101-entrypoint-matrix.ts`, `tools/ci/kbn101-entrypoint-matrix.spec.ts`. It invokes the already-produced bin/image/deployment/doc artifacts and edits no producer file. Its inventory test enforces manifest overlap, ownerless, duplicate-owner, path-existence, allowlist, active-route field completeness, finite operator-document inventory, normative/status-only non-masking, unknown-command, and historical/status-only masking failures. Before inventory, ownership, or status masking, its semantic suite fails the exact README commented code-fence generic-wrapper form and exact user-guide executable generic-wrapper form recorded in §2.2, and source-consistency opens `packages/storage/src/cli.ts` to prove its current direct-Drizzle `pnpm --filter @mosaicstack/db db:migrate` `execSync` implementation and absence of a `mosaic-db-migrator` bin make runner-delegation copy false. It also fails former `SETUP.md` automatic-first-boot/startup, the exact former `MILESTONES.md` wording, and the exact former README/dev/deployment Compose-first sequences before an inventory record can mask any path; it fails Compose-up-before-runner, extension/schema/migration automatic wording, init-script authority, production `.env`, `EnvironmentFile=`, credential shell export/argv, and restart-as-secret-activation. It passes the rc.16 held PGlite-data-layer/non-PostgreSQL-Compose disposition (and fails Gateway/Web local start while daemon/inherited/root/app-local DSN or non-local-tier input could select PostgreSQL), the one `Held future procedure` section with KBN-101-00/-03/-05 and external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose-readiness, and the README legacy-N-1 CI status assertion; every runner hit outside that section and every current-CI authority variant fails before masking. Its matrix invokes the -02 secure target route and verifies URL/version generation mismatch, provider rotation/revocation, consumer isolation, safe-open TOCTOU/link/mode/owner, absent/wrong importer CA, no log/oracle, every declared attestation refusal before target connection/DDL, and post-connect mismatch with zero DML. | -| `KBN-101-08` foundation certification and **atomic activation release** | 00…07,10 | **Only evidence:** `docs/reports/native-kanban-sot/kbn-101-foundation-activation-certificate.md`, `docs/reports/native-kanban-sot/kbn-101-foundation-activation-evidence.json`. It changes no implementation path. Independent review and terminal-green CI must verify prepared artifacts before Mosaic control plane/Jason authorizes backup → drain/scale-zero N-1 → TLS → roles → runner → verified readiness → rolling runtime; any red result aborts. | -| `KBN-101-09` post-KBN-100 certification | KBN-100,08 | **Only evidence:** `docs/reports/native-kanban-sot/kbn-101-immutable-role-certificate.md`, `docs/reports/native-kanban-sot/kbn-101-immutable-role-evidence.json`. It changes no implementation path and records real deployed runtime INSERT/SELECT plus UPDATE/DELETE-denial evidence and independent security/Ultron approval. | - -The manifests above are the complete ownership universe for KBN-101 implementation paths **including KBN-101-10**; the KBN-101-06 inventory test fails on overlap, an ownerless in-scope path, or a nonexistent declared path. Cards `00`–`07` are prepared artifacts, not independently deployed releases: the immutable N-1 owner-runtime image stays live until KBN-101-08 control-plane atomic activation. No activation card edits a source-changing path, and no runtime bypass or broken deployed intermediate exists. - -**Authority:** Mosaic control plane/Jason is the sole activation and rollback authority. CI, Gateway, migrator, Coordinator, and Certifier cannot activate, waive a red result, or force release. Before an incompatible KBN-100 switch, the authority stops/scales runtime, uses the approved backup/restore or separately reviewed runner artifact, restores only a known TLS-compatible runtime with its runtime secret after `hostssl` enforcement, and verifies no plaintext sessions plus TLS/readiness. Migration URL is never injected into Gateway to enable rollback. KBN-100 starts only after KBN-101-08; KBN-105 starts only after KBN-101-09. - -**ASSUMPTION K101-A2:** every eligible production-like deployment can schedule a dedicated migration Job/one-shot command and an operator/IaC-controlled TLS bootstrap. A target that cannot do both is ineligible for KBN certification. - -## 8. Acceptance traceability - -| Requirement / acceptance criterion | Required implementation evidence | -| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| K101-REQ-01 / AC-K101-01 | DTO/command matrix covers local/PGlite, standalone, federated, and both harness pairs; every finite classified §2 path rejects `DATABASE_URL`-only before connection/DDL; no runtime fallback/default; `--help`, argv, import-compile, and live-operator-route negatives pass. Before any inventory/status mask, the semantic fixture fails the exact README commented code-fence and user-guide executable former generic-wrapper forms in §2.2; source-consistency proves current `packages/storage/src/cli.ts` directly executes `pnpm --filter @mosaicstack/db db:migrate` and has no `mosaic-db-migrator` bin, so runner-delegation prose cannot pass. The active migrate-tier route proves runner-produced JCS/Ed25519 attestation, root-owned signing/private-key and importer-public-key isolation, Vault KV-v2 `secret-{env}/mosaic-stack/database/importer` URL plus same-response authenticated `data.metadata.version`, separate immutable renderer generations, importer CA, exact `--target-url-file /run/secrets/mosaic-migrate-target-url` plus version file and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`, safe-fd/digest/TLS/server/database/role/manifest/schema bindings, expiry/replay/provider-rotation/revocation/TOCTOU checks, bounded non-DDL importer DML, and consumer-isolation/no-log-oracle plus no-connection versus zero-DML refusal. | -| K101-REQ-02 / AC-K101-02 | KBN-101-03 one-session fixed two-int lock, `--run`/`--verify` exit-code, contention/crash/readiness/unrelated-key tests; manifest-v1 canonical bytes/digest and all reconciliation states; Gateway/replica DDL impossibility. | -| K101-REQ-03 / AC-K101-03 | KBN-101-00 bootstrap schema/extension-owner/default-privilege and direct-denial proof against actual PostgreSQL 17 + pgvector 0.8.2 control metadata (`trusted` absent/untrusted, `relocatable=true`); external-superuser `SET ROLE` create/update/`RESET ROLE` audit proof; `rolcanlogin=false`, `rolsuper=true`, zero members/no runtime credential proof; KBN-101-03 catalog relocation and future-Drizzle-only-`mosaic` proof plus approved-owner/shadow runner integration; `pg_namespace.nspowner`, `pg_extension.extowner`, owner-bearing member/schema/version, and runtime/migrator/schema-owner/importer/all-service-role catalog plus `SET ROLE`/ALTER/DROP/member-update denials; KBN-101-01/03 role, path, TEMP, ledger/default-grant, and pool-reset tests. | -| K101-REQ-04 / AC-K101-04 | KBN-101-00 bootstrap-interface and KBN-101-05 renderer/deployment tests; immutable-image exact Job commands and runner-before-readiness order; fresh/existing verified-TLS Compose/Swarm/two-gateway positives; both-pair CA/SAN/downgrade/key-permission negatives; exact UID/GID/mode and runtime/migrator secret-consumer rendering/CI negatives. | -| K101-REQ-05 / AC-K101-05 | KBN-101-09 after KBN-100: real deployed runtime INSERT/SELECT success and UPDATE/DELETE denial for each frozen relation, with RESTRICT retention evidence. | -| K101-REQ-06 / AC-K101-06 | KBN-101-00…08 prepared-card/no-intermediate-deploy evidence; one final activation authority record; N-1 drain/zero-plaintext-session/`hostssl`, backup/restore, CA overlap rotation, TLS-only rollback, Vault/redaction, and no-force-on-red evidence. | -| K101-REQ-07 / KBN sequence | KBN-101-08 foundation certificate before KBN-100, KBN-101-09 real immutable-role certificate plus Ultron approval before KBN-105; KBN-100 rebases/restores Drizzle consistency and never bypasses the serial gates. | -| Delivery integrity | One-card/one-PR DAG, exact file ownership, docs/link/contract checks, independent author≠reviewer re-review on the pushed exact head, and terminal-green CI for implementation cards. | - -## 9. Non-goals and residual authority - -KBN-101 planning does not create roles, certificates, Vault paths, migrations, deployment artifacts, or a deployed certificate. It does not replace KBN-100’s data migration, immutable retention, tenant constraints, or KBN-105 endpoint freeze. A PostgreSQL superuser/break-glass operator—including the deliberately isolated extension owner—remains outside application containment: `GRANT`/`REVOKE` cannot constrain that authority. Separate identity/non-login/zero-membership/external-control/audit controls, independent review, maintenance windows, backup/rollback evidence, and drills are mandatory. +- **Any PostgreSQL DSN.** No role classification is attempted. A DSN cannot be proven + superuser-or-not without connecting, and connecting is the thing being prevented — so the rule is + the simple provable one: every PostgreSQL DSN, including the `DEFAULT_DATABASE_URL` fallback + constant, is forbidden. +- **Any non-local tier value.** + +A forbidden value refuses **even when it is shadowed** — that is, even when precedence means it +would not have won. Every source is inspected independently, before precedence is applied. + +## Source inventory — the complete set + +| Label | Location | +| ---------------------- | ------------------------------------------- | +| `process-environment` | inherited ambient environment | +| `daemon-dotenv` | `$HOME/.config/mosaic/gateway/.env` | +| `monorepo-root-dotenv` | anchored monorepo root `.env` | +| `gateway-local-dotenv` | anchored `apps/gateway/.env` | +| `daemon-json` | `$MOSAIC_GATEWAY_HOME/mosaic.config.json` | +| `app-json` | anchored `apps/gateway/mosaic.config.json` | +| `monorepo-root-json` | anchored monorepo root `mosaic.config.json` | + +**`MOSAIC_GATEWAY_HOME` redirects the daemon JSON only.** The daemon dotenv remains at +`$HOME/.config/mosaic/gateway/.env` regardless of the redirect. This is the real distinction in +`apps/gateway/src/env.ts` and it is not widened here. + +The CWD-relative bare `loadConfig()` search is **not** part of this inventory: the gateway always +passes `resolveGatewayConfigPath()`, and existing AppModule coverage proves the CWD JSON path is +ignored. No new input is created for it. + +## Freeze contract + +The channel establishes **one** resolved policy, once: + +- allowed-only object, deep-cloned then deep-frozen; +- a second establish attempt **fails**; there is no reset API; +- rejected raw JSON, dotenv contents, DSNs and secrets are **neither retained nor logged**; +- errors expose source **labels** and stable **codes** only — never values. + +Every consumer in the local channel reads that frozen policy. No consumer performs a second +independent read, so a post-freeze mutation of the environment or of a config file changes nothing +any consumer sees. + +Normal (non-local) startup uses a separate explicit legacy path. There is no +`getFrozen() ?? loadConfig()` masking: the two channels do not fall through to one another. + +## Import-order contract — load-bearing + +`local-dev.ts` statically imports **only** the policy and inventory modules. It establishes the +frozen policy first, and only then **dynamically** imports the exported `bootstrap()` from +`main.ts`. Importing `main.ts` must not start anything by side effect. The local `env.ts` path +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` + +These paths are assigned to `-02a`. A 16th path is a STOP. + +| # | Path | +| --- | --------------------------------------------------------- | +| 1 | `docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md` | +| 2 | `apps/gateway/package.json` | +| 3 | `apps/gateway/src/env.ts` | +| 4 | `apps/gateway/src/app.module.ts` | +| 5 | `apps/gateway/src/config/config.module.ts` | +| 6 | `apps/gateway/src/database/database.module.ts` | +| 7 | `apps/gateway/src/main.ts` | +| 8 | `apps/gateway/src/env.spec.ts` | +| 9 | `apps/gateway/src/database/database.module.spec.ts` | +| 10 | `apps/gateway/src/local-start/source-inventory.ts` | +| 11 | `apps/gateway/src/local-start/source-inventory.spec.ts` | +| 12 | `apps/gateway/src/local-start/local-start-policy.ts` | +| 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` | + +**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`), +`packages/config/src/mosaic-config.ts`. `-02a` takes no runner or migration ownership. It proves +the local entrypoint never _reaches_ those paths, from a higher seam, rather than modifying them. + +## Zero-side-effect bars + +Every refusing case proves zero of each: tier probe (`detectAndAssertTier`), `createDb`, +`createStorageAdapter` / PostgresAdapter construction, `runMigrations`, extension DDL, and +`NestFactory.create`. + +A case that connects and then rolls back fails these bars by construction. That is deliberate. + +## Mutation → guard mapping + +Each row names one guard, one mutation that removes it, and the case that must go red when it is +removed alone. Aggregate reverts do not satisfy this. + +| # | Guard | Mutation that removes it | +| --- | --------------------------------------------- | ------------------------------------------------------ | +| 1 | inherited-env source inspected | drop `process-environment` from the inventory | +| 2 | shared dotenv sources inspected | drop the two anchored dotenv sources | +| 3 | daemon JSON source inspected | drop `daemon-json` | +| 4 | app JSON source inspected | drop `app-json` | +| 5 | root JSON source inspected | drop `monorepo-root-json` | +| 6 | redirect followed | ignore `MOSAIC_GATEWAY_HOME` when locating daemon JSON | +| 7 | exact local tier required | accept any tier value | +| 8 | exact PGlite + disposable state root required | accept any storage type / default the state root | +| 9 | one-shot freeze + dynamic import | allow re-establish, or statically import `main.js` | + +A guard whose case survives its own removal is reported as such. A green test that cannot fail for +its stated reason is the same defect class as the span gap that created this item. + +## RED phase result + +Focused matrix, gateway package: **50 failing / 10 passing (60 cases, 5 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`, +`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 +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 | + +The three vacuous greens are meaningful only once the implementation exists. They are re-checked +at GREEN, not carried forward as evidence. + +## Scope discipline + +`-02a` neither breaks production-like entrypoints nor authorizes their local use. They resolve +exactly as they do today when no local channel is active, and they remain held.