From 99336840b8c8816e2ace065ca52449da2beb540f Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:17:39 +0900 Subject: [PATCH 1/7] feat(interactive): M3 canonical snapshots and bounded JSON validation --- src/interactive/canonical.mjs | 119 ++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/interactive/canonical.mjs diff --git a/src/interactive/canonical.mjs b/src/interactive/canonical.mjs new file mode 100644 index 0000000..0aaf1fc --- /dev/null +++ b/src/interactive/canonical.mjs @@ -0,0 +1,119 @@ +import {createHash} from 'node:crypto'; +import {types as nodeTypes} from 'node:util'; + +/** RFC 8785-style JSON: UTF-16 key ordering and ECMAScript number/string encoding. + * Reject rather than coerce non-JSON inputs; never call user toJSON/getters. + */ +export const MAX_CHECKPOINT_BYTES = 2 * 1024 * 1024; +export const MAX_JOURNAL_BYTES = 2 * 1024 * 1024; + +function validUnicode(value) { + for (let i = 0; i < value.length; i += 1) { + const unit = value.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new TypeError('Lone UTF-16 high surrogate is not canonical JSON'); + } + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + throw new TypeError('Lone UTF-16 low surrogate is not canonical JSON'); + } + } +} + +/** Return freshly allocated canonical bytes; no caller-owned object is retained. */ +export function canonicalBytes(input, maximumBytes = MAX_CHECKPOINT_BYTES) { + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) { + throw new TypeError('Invalid canonical JSON size limit'); + } + const seen = new Set(); + function serialize(value, depth) { + if (depth > 64) throw new TypeError('Maximum canonical JSON depth exceeded'); + if (value === null || typeof value === 'boolean') return JSON.stringify(value); + if (typeof value === 'string') { + validUnicode(value); + return JSON.stringify(value); + } + if (typeof value === 'number') { + if (!Number.isFinite(value) || Object.is(value, -0)) { + throw new TypeError('Non-canonical JSON number'); + } + return JSON.stringify(value); + } + if (!value || typeof value !== 'object' || nodeTypes.isProxy(value)) { + throw new TypeError('Expected plain JSON value, not executable or proxy input'); + } + if (seen.has(value)) throw new TypeError('Cyclic JSON value'); + const array = Array.isArray(value); + if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { + throw new TypeError('Expected plain JSON object'); + } + seen.add(value); + try { + const keys = Reflect.ownKeys(value); + if (keys.some(key => typeof key !== 'string')) throw new TypeError('Symbol JSON key'); + if (array) { + if (!Number.isSafeInteger(value.length)) throw new TypeError('Invalid array length'); + const indices = keys.filter(key => key !== 'length'); + if (indices.length !== value.length + || indices.some(key => !/^(0|[1-9]\d*)$/.test(key) || Number(key) >= value.length)) { + throw new TypeError('Sparse or extended JSON array'); + } + const parts = []; + for (let i = 0; i < value.length; i += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(i)); + if (!descriptor || !Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) { + throw new TypeError('Accessor or hidden JSON array value'); + } + parts.push(serialize(descriptor.value, depth + 1)); + } + return '[' + parts.join(',') + ']'; + } + const ordered = [...keys].sort(); + const parts = []; + for (const key of ordered) { + validUnicode(key); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) { + throw new TypeError('Accessor or hidden JSON object property'); + } + parts.push(JSON.stringify(key) + ':' + serialize(descriptor.value, depth + 1)); + } + return '{' + parts.join(',') + '}'; + } finally { + seen.delete(value); + } + } + const encoded = Buffer.from(serialize(input, 0), 'utf8'); + if (encoded.byteLength > maximumBytes) throw new RangeError('Canonical JSON exceeds maximum bytes'); + return encoded; +} + +export function sha256Bytes(bytes) { + return 'sha256:' + createHash('sha256').update(bytes).digest('hex'); +} + +export function assertSHA256(value) { + if (typeof value !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value)) { + throw new TypeError('Expected lowercase sha256: identity'); + } +} + +export function safeJSONSnapshot(input, limit = MAX_CHECKPOINT_BYTES) { + return JSON.parse(canonicalBytes(input, limit).toString('utf8')); +} + +export function decodeCanonical(text, expectedHash, limit = MAX_CHECKPOINT_BYTES) { + if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > limit) { + throw new Error('FK_I_STORE_CORRUPT: unexpected JSON bytes'); + } + const buffer = Buffer.from(text, 'utf8'); + if (expectedHash !== sha256Bytes(buffer)) { + throw new Error('FK_I_STORE_CORRUPT: data hash mismatch'); + } + const parsed = JSON.parse(text); + if (!canonicalBytes(parsed, limit).equals(buffer)) { + throw new Error('FK_I_STORE_CORRUPT: noncanonical JSON bytes'); + } + return parsed; +} From 1cf36c72bd7618088d13023ceec3b4dcb9083bd1 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:19:13 +0900 Subject: [PATCH 2/7] feat(interactive): M3 namespace-scoped SQLite checkpoint CAS, artifacts, and journal --- src/interactive/sqlite-store.mjs | 316 +++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 src/interactive/sqlite-store.mjs diff --git a/src/interactive/sqlite-store.mjs b/src/interactive/sqlite-store.mjs new file mode 100644 index 0000000..26cfd5d --- /dev/null +++ b/src/interactive/sqlite-store.mjs @@ -0,0 +1,316 @@ +import {DatabaseSync} from 'node:sqlite'; +import {Buffer} from 'node:buffer'; +import { + MAX_CHECKPOINT_BYTES, MAX_JOURNAL_BYTES, assertSHA256, + canonicalBytes, decodeCanonical, safeJSONSnapshot, sha256Bytes, +} from './canonical.mjs'; + +const TERMINAL = new Set(['succeeded', 'failed', 'errored']); +const STATUSES = new Set(['running', 'waiting', 'paused', ...TERMINAL]); + +function failure(code, detail) { + return Object.assign(new Error(code + ': ' + detail), {code}); +} +function identifier(value, name) { + if (typeof value !== 'string' || value.length < 1 || value.length > 256 + || /[\u0000-\u001f\u007f]/.test(value)) { + throw new TypeError(name + ' must be a nonempty, bounded identifier'); + } + return value; +} +function assertCheckpoint(value, namespace, runId, revision, artifactHash) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || value.schemaVersion !== 1 || value.namespace !== namespace + || value.runId !== runId || value.revision !== revision + || !Number.isSafeInteger(value.revision) || value.revision < 0) { + throw failure('FK_I_INVALID_CHECKPOINT', 'namespace/run/revision/schema mismatch'); + } + assertSHA256(value.artifactHash); + if (artifactHash !== undefined && value.artifactHash !== artifactHash) { + throw failure('FK_I_PROGRAM_MISMATCH', 'run artifact is immutable'); + } + if (!STATUSES.has(value.status) + || typeof value.blockId !== 'string' || !value.blockId + || typeof value.phase !== 'string' || !value.phase + || !value.blockId.endsWith('::' + value.phase) + || !value.state || typeof value.state !== 'object' || Array.isArray(value.state) + || !Array.isArray(value.controlStack)) { + throw failure('FK_I_INVALID_CHECKPOINT', 'incomplete state-machine checkpoint'); + } + if (revision > 0) { + identifier(value.startKey, 'checkpoint.startKey'); + assertSHA256(value.startIntentHash); + } +} +function immutableIdentity(previous, next) { + for (const key of ['namespace', 'runId', 'schemaVersion', 'artifactHash', 'startKey', 'startIntentHash']) { + if (previous[key] !== next[key]) { + throw failure('FK_I_INVALID_CHECKPOINT', 'immutable field changed: ' + key); + } + } +} +function journalDigest(namespace, runId, revision, transitionId, previousHash, checkpointHash, entry) { + return sha256Bytes(canonicalBytes({ + namespace, runId, revision, transitionId, previousHash, checkpointHash, entry, + }, MAX_JOURNAL_BYTES)); +} + +/** + * Host-owned, synchronous P0-A SQLite persistence. No model/skill calls here. + * Each run is fully scoped to the constructor's namespace. Node's DatabaseSync + * is experimental on Node 22; the CI matrix tests it as an explicit boundary. + */ +export function createSqliteCheckpointStore(file, {namespace} = {}) { + identifier(file, 'SQLite file'); + identifier(namespace, 'namespace'); + const db = new DatabaseSync(file); + try { + db.exec('PRAGMA busy_timeout = 15000'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA synchronous = FULL'); + db.exec([ + 'CREATE TABLE IF NOT EXISTS fk_i_artifacts (', + ' namespace TEXT NOT NULL, artifact_hash TEXT NOT NULL, source_hash TEXT NOT NULL,', + ' identity_json TEXT NOT NULL, program_bytes BLOB NOT NULL,', + ' PRIMARY KEY (namespace, artifact_hash)', + ') STRICT;', + 'CREATE TABLE IF NOT EXISTS fk_i_runs (', + ' namespace TEXT NOT NULL, run_id TEXT NOT NULL, start_key TEXT NOT NULL,', + ' start_intent_hash TEXT NOT NULL, revision INTEGER NOT NULL CHECK (revision >= 0),', + ' artifact_hash TEXT NOT NULL, checkpoint_json TEXT NOT NULL, checkpoint_hash TEXT NOT NULL,', + ' PRIMARY KEY (namespace, run_id), UNIQUE (namespace, start_key),', + ' FOREIGN KEY (namespace, artifact_hash) REFERENCES fk_i_artifacts(namespace, artifact_hash)', + ') STRICT;', + 'CREATE TABLE IF NOT EXISTS fk_i_journal (', + ' namespace TEXT NOT NULL, run_id TEXT NOT NULL, revision INTEGER NOT NULL,', + ' transition_id TEXT NOT NULL, previous_hash TEXT, checkpoint_hash TEXT NOT NULL,', + ' entry_json TEXT NOT NULL, entry_hash TEXT NOT NULL,', + ' PRIMARY KEY (namespace, run_id, revision), UNIQUE (namespace, run_id, transition_id),', + ' FOREIGN KEY (namespace, run_id) REFERENCES fk_i_runs(namespace, run_id)', + ') STRICT;', + ].join('\n')); + } catch (error) { + db.close(); + throw error; + } + + const read = db.prepare('SELECT * FROM fk_i_runs WHERE namespace = ? AND run_id = ?'); + const byStartKey = db.prepare('SELECT * FROM fk_i_runs WHERE namespace = ? AND start_key = ?'); + const latest = db.prepare('SELECT revision, checkpoint_hash, entry_hash FROM fk_i_journal WHERE namespace = ? AND run_id = ? ORDER BY revision DESC LIMIT 1'); + const allEntries = db.prepare('SELECT * FROM fk_i_journal WHERE namespace = ? AND run_id = ? ORDER BY revision'); + const artifact = db.prepare('SELECT * FROM fk_i_artifacts WHERE namespace = ? AND artifact_hash = ?'); + const putArtifact = db.prepare('INSERT OR IGNORE INTO fk_i_artifacts(namespace, artifact_hash, source_hash, identity_json, program_bytes) VALUES (?, ?, ?, ?, ?)'); + const insertRun = db.prepare('INSERT INTO fk_i_runs(namespace, run_id, start_key, start_intent_hash, revision, artifact_hash, checkpoint_json, checkpoint_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); + const updateRun = db.prepare('UPDATE fk_i_runs SET revision = ?, checkpoint_json = ?, checkpoint_hash = ? WHERE namespace = ? AND run_id = ? AND revision = ?'); + const insertJournal = db.prepare('INSERT INTO fk_i_journal(namespace, run_id, revision, transition_id, previous_hash, checkpoint_hash, entry_json, entry_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); + + function transaction(mode, work) { + db.exec(mode === 'write' ? 'BEGIN IMMEDIATE' : 'BEGIN'); + try { + const result = work(); + db.exec('COMMIT'); + return result; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + } + function decodeRow(row) { + if (!row) return null; + const cp = decodeCanonical(row.checkpoint_json, row.checkpoint_hash, MAX_CHECKPOINT_BYTES); + assertCheckpoint(cp, namespace, row.run_id, row.revision, row.artifact_hash); + if (cp.startKey !== row.start_key || cp.startIntentHash !== row.start_intent_hash) { + throw failure('FK_I_STORE_CORRUPT', 'run identity differs from checkpoint'); + } + const tail = latest.get(namespace, row.run_id); + if (!tail || tail.revision !== row.revision || tail.checkpoint_hash !== row.checkpoint_hash) { + throw failure('FK_I_STORE_CORRUPT', 'journal does not match latest checkpoint'); + } + return cp; + } + function appendJournal(runId, revision, transitionId, checkpointHash, previousHash, entry) { + identifier(transitionId, 'transitionId'); + const detail = safeJSONSnapshot(entry, MAX_JOURNAL_BYTES); + const json = canonicalBytes(detail, MAX_JOURNAL_BYTES).toString('utf8'); + const digest = journalDigest(namespace, runId, revision, transitionId, previousHash, checkpointHash, detail); + insertJournal.run(namespace, runId, revision, transitionId, previousHash, checkpointHash, json, digest); + return digest; + } + + return Object.freeze({ + namespace, + close() { db.close(); }, + + /** Preserve exact compiler-produced bytes, keyed by their content hash. */ + putArtifact({programIdentity, bytes}) { + if (!(bytes instanceof Uint8Array)) throw new TypeError('Program bytes must be Uint8Array'); + if (!programIdentity || typeof programIdentity !== 'object') throw new TypeError('Program identity required'); + const identity = safeJSONSnapshot(programIdentity); + assertSHA256(identity.artifactHash); + assertSHA256(identity.sourceHash); + if (identity.programAbiVersion !== 1 + || identity.semanticsVersion !== 'interactive-state-machine-v1' + || identity.implementedStage !== 'P0-A') { + throw failure('FK_I_PROGRAM_MISMATCH', 'unsupported Program identity'); + } + const copied = Buffer.from(bytes); + if (copied.length > 4 * 1024 * 1024 || sha256Bytes(copied) !== identity.artifactHash) { + throw failure('FK_I_PROGRAM_MISMATCH', 'Program bytes are not the declared artifact'); + } + const identityJson = canonicalBytes(identity).toString('utf8'); + transaction('write', () => { + putArtifact.run(namespace, identity.artifactHash, identity.sourceHash, identityJson, copied); + const existing = artifact.get(namespace, identity.artifactHash); + if (!existing || existing.identity_json !== identityJson || !Buffer.from(existing.program_bytes).equals(copied)) { + throw failure('FK_I_PROGRAM_MISMATCH', 'artifact identity is immutable'); + } + }); + return identity.artifactHash; + }, + + getArtifact(artifactHash) { + assertSHA256(artifactHash); + return transaction('read', () => { + const record = artifact.get(namespace, artifactHash); + if (!record) return null; + const copied = Buffer.from(record.program_bytes); + if (sha256Bytes(copied) !== artifactHash) { + throw failure('FK_I_STORE_CORRUPT', 'artifact bytes modified'); + } + const identity = JSON.parse(record.identity_json); + if (!canonicalBytes(identity).equals(Buffer.from(record.identity_json, 'utf8')) + || identity.artifactHash !== artifactHash || identity.sourceHash !== record.source_hash) { + throw failure('FK_I_STORE_CORRUPT', 'artifact metadata modified'); + } + return {programIdentity: identity, bytes: copied}; + }); + }, + + /** Deliberately refuses removal while any historical run references bytes. */ + deleteArtifact(artifactHash) { + assertSHA256(artifactHash); + return transaction('write', () => { + const count = db.prepare('SELECT COUNT(*) AS n FROM fk_i_runs WHERE namespace = ? AND artifact_hash = ?').get(namespace, artifactHash).n; + if (count !== 0) throw failure('FK_I_ARTIFACT_IN_USE', 'cannot delete referenced Program'); + return db.prepare('DELETE FROM fk_i_artifacts WHERE namespace = ? AND artifact_hash = ?').run(namespace, artifactHash).changes !== 0; + }); + }, + + /** + * Durable idempotent start: the server computes the intent fingerprint, + * including tenant, artifact, initial State and caller options. + */ + createRun({startKey, startIntent = null, checkpoint}) { + identifier(startKey, 'startKey'); + const base = safeJSONSnapshot(checkpoint); + identifier(base?.runId, 'checkpoint.runId'); + assertCheckpoint(base, namespace, base.runId, 0); + if (base.startKey !== undefined || base.startIntentHash !== undefined) { + throw failure('FK_I_INVALID_CHECKPOINT', 'start identity is store-generated'); + } + const intent = safeJSONSnapshot(startIntent); + const startIntentHash = sha256Bytes(canonicalBytes({ + namespace, startKey, artifactHash:base.artifactHash, state:base.state, + policyHash:base.policyHash ?? null, intent, + })); + const initial = {...base, startKey, startIntentHash}; + const checkpointBytes = canonicalBytes(initial); + const checkpointHash = sha256Bytes(checkpointBytes); + return transaction('write', () => { + const existing = byStartKey.get(namespace, startKey); + if (existing) { + const current = decodeRow(existing); + if (existing.start_intent_hash !== startIntentHash) { + throw failure('FK_I_START_CONFLICT', 'startKey belongs to different intent'); + } + return {created:false, checkpoint:current}; + } + if (read.get(namespace, initial.runId)) { + throw failure('FK_I_RUN_CONFLICT', 'runId already belongs to another start'); + } + if (!artifact.get(namespace, initial.artifactHash)) { + throw failure('FK_I_PROGRAM_MISMATCH', 'Program bytes must be registered first'); + } + insertRun.run(namespace, initial.runId, startKey, startIntentHash, 0, + initial.artifactHash, checkpointBytes.toString('utf8'), checkpointHash); + appendJournal(initial.runId, 0, 'start:' + startIntentHash, checkpointHash, null, {kind:'created'}); + return {created:true, checkpoint:JSON.parse(checkpointBytes.toString('utf8'))}; + }); + }, + + /** Linearizable checkpoint and journal-head read from one SQLite snapshot. */ + load(runId) { + identifier(runId, 'runId'); + return transaction('read', () => decodeRow(read.get(namespace, runId))); + }, + + /** Atomic, per-phase optimistic CAS; all conflicts leave both tables unchanged. */ + compareAndSwap({runId, expectedRevision, nextCheckpoint, transitionId, journalEntry = null}) { + identifier(runId, 'runId'); + identifier(transitionId, 'transitionId'); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + throw new TypeError('expectedRevision must be a nonnegative safe integer'); + } + const next = safeJSONSnapshot(nextCheckpoint); + const journal = safeJSONSnapshot(journalEntry, MAX_JOURNAL_BYTES); + return transaction('write', () => { + const row = read.get(namespace, runId); + if (!row) throw failure('FK_I_RUN_NOT_FOUND', 'run is not accessible in this namespace'); + const current = decodeRow(row); + if (row.revision !== expectedRevision) { + return {committed:false, checkpoint:current}; + } + if (TERMINAL.has(current.status)) { + throw failure('FK_I_TERMINAL_IMMUTABLE', 'final checkpoint cannot advance'); + } + if (row.revision >= Number.MAX_SAFE_INTEGER) { + throw failure('FK_I_REVISION_EXHAUSTED', 'revision range exceeded'); + } + assertCheckpoint(next, namespace, runId, expectedRevision + 1, row.artifact_hash); + immutableIdentity(current, next); + const bytes = canonicalBytes(next); + const checkpointHash = sha256Bytes(bytes); + const last = latest.get(namespace, runId); + if (!last) throw failure('FK_I_STORE_CORRUPT', 'missing journal head'); + const updated = updateRun.run(next.revision, bytes.toString('utf8'), checkpointHash, + namespace, runId, expectedRevision); + if (updated.changes !== 1) throw failure('FK_I_CAS_CONFLICT', 'revision changed during transaction'); + appendJournal(runId, next.revision, transitionId, checkpointHash, last.entry_hash, journal); + return {committed:true, checkpoint:JSON.parse(bytes.toString('utf8'))}; + }); + }, + + /** Audit chain is verified on each read, independently of a live Coordinator. */ + listJournal(runId) { + identifier(runId, 'runId'); + return transaction('read', () => { + const row = read.get(namespace, runId); + if (!row) return []; + decodeRow(row); + const result = []; + let previousHash = null; + let revision = 0; + for (const entry of allEntries.all(namespace, runId)) { + const detail = decodeCanonical(entry.entry_json, sha256Bytes(Buffer.from(entry.entry_json, 'utf8')), MAX_JOURNAL_BYTES); + if (entry.revision !== revision++ || entry.previous_hash !== previousHash + || entry.entry_hash !== journalDigest(namespace, runId, entry.revision, + entry.transition_id, previousHash, entry.checkpoint_hash, detail)) { + throw failure('FK_I_STORE_CORRUPT', 'invalid journal hash chain'); + } + result.push({ + revision:entry.revision, transitionId:entry.transition_id, + previousHash, entryHash:entry.entry_hash, + checkpointHash:entry.checkpoint_hash, entry:detail, + }); + previousHash = entry.entry_hash; + } + if (result.length !== row.revision + 1 + || result.at(-1)?.checkpointHash !== row.checkpoint_hash) { + throw failure('FK_I_STORE_CORRUPT', 'journal is missing committed revisions'); + } + return result; + }); + }, + }); +} From 89b804f2daa793ea819e91fd08099be31c024c74 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:20:47 +0900 Subject: [PATCH 3/7] test(interactive): M3 durable SQLite CAS, tenant isolation, adversarial JSON and real-process races --- tests/interactive/store.test.mjs | 234 +++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 tests/interactive/store.test.mjs diff --git a/tests/interactive/store.test.mjs b/tests/interactive/store.test.mjs new file mode 100644 index 0000000..ee9e7a8 --- /dev/null +++ b/tests/interactive/store.test.mjs @@ -0,0 +1,234 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {spawn} from 'node:child_process'; +import {DatabaseSync} from 'node:sqlite'; +import {canonicalBytes, safeJSONSnapshot, sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; + +const hash = bytes => sha256Bytes(Buffer.from(bytes)); +const artifactBytes = Buffer.from('// verified compiler bytes fixture\nexport const v = 1;\n'); +const identity = { + artifactHash:hash(artifactBytes), sourceHash:'sha256:' + 'a'.repeat(64), + programAbiVersion:1, semanticsVersion:'interactive-state-machine-v1', + implementedStage:'P0-A', +}; +const checkpoint = (namespace, runId, state = {}) => ({ + schemaVersion:1, namespace, runId, revision:0, status:'running', + artifactHash:identity.artifactHash, blockId:'gate::enter', phase:'enter', + state, controlStack:[], candidate:null, +}); +const next = (cp, status = 'running') => ({ + ...cp, revision:cp.revision + 1, + status, phase:'route', blockId:'gate::route', +}); + +async function fixture(t) { + const dir = await mkdtemp(path.join(tmpdir(), 'flowkit-m3-')); + const file = path.join(dir, 'checkpoint.db'); + t.after(() => rm(dir, {recursive:true, force:true})); + return file; +} + +test('M3 canonical JSON uses stable key order and preserves prototype keys only as data', () => { + const a = canonicalBytes(JSON.parse('{"z":1,"ä":2,"a":{"y":1,"x":2},"__proto__":{"safe":true}}')); + const b = canonicalBytes(JSON.parse('{"__proto__":{"safe":true},"a":{"x":2,"y":1},"z":1,"ä":2}')); + assert.equal(a.toString('hex'), b.toString('hex')); + const copied = safeJSONSnapshot(JSON.parse('{"__proto__":{"safe":true}}')); + assert.ok(Object.hasOwn(copied, '__proto__')); + assert.equal({}.safe, undefined); + assert.equal(copied.__proto__.safe, true); +}); + +test('M3 rejects invalid JSON before any SQLite mutation: accessor, proxy, cycle, -0, nonfinite and limits', async t => { + const file = await fixture(t); + const store = createSqliteCheckpointStore(file, {namespace:'tenant-a'}); + t.after(() => store.close()); + store.putArtifact({bytes:artifactBytes, programIdentity:identity}); + const start = store.createRun({startKey:'key', checkpoint:checkpoint('tenant-a','r')}); + const before = canonicalBytes(start.checkpoint).toString('hex'); + const invalid = [ + {bad:undefined}, + {bad:NaN}, + {bad:Infinity}, + {bad:-0}, + {bad:BigInt(1)}, + {bad:'\ud800'}, + {bad: new Date()}, + {bad:new Proxy({ok:true},{})}, + {bad:'x'.repeat(2*1024*1024+1)}, + ]; + const cycle = {}; cycle.self = cycle; invalid.push(cycle); + const accessor = {}; + Object.defineProperty(accessor,'bad',{get(){throw Error('getter ran');},enumerable:true}); + invalid.push(accessor); + for (const value of invalid) { + assert.throws(() => store.compareAndSwap({ + runId:'r', expectedRevision:0, nextCheckpoint:{...next(start.checkpoint), state:value}, + transitionId:'tx-invalid', + }), error => error instanceof Error); + assert.equal(canonicalBytes(store.load('r')).toString('hex'), before); + assert.equal(store.listJournal('r').length,1); + } +}); + +test('M3 registers immutable exact artifact bytes, isolates tenants, and prevents deletion of referenced programs', async t => { + const file = await fixture(t); + const a = createSqliteCheckpointStore(file,{namespace:'tenant-a'}); + const b = createSqliteCheckpointStore(file,{namespace:'tenant-b'}); + t.after(()=>{a.close();b.close()}); + assert.throws(() => a.putArtifact({bytes:Buffer.from('different'),programIdentity:identity}),/PROGRAM_MISMATCH/); + a.putArtifact({bytes:artifactBytes,programIdentity:identity}); + b.putArtifact({bytes:artifactBytes,programIdentity:identity}); + const copy = a.getArtifact(identity.artifactHash); + copy.bytes.fill(0); + assert.deepEqual(a.getArtifact(identity.artifactHash).bytes,artifactBytes); + assert.equal(a.getArtifact('sha256:' + 'b'.repeat(64)), null); + a.createRun({startKey:'same-key',checkpoint:checkpoint('tenant-a','same-id',{from:'a'})}); + b.createRun({startKey:'same-key',checkpoint:checkpoint('tenant-b','same-id',{from:'b'})}); + assert.equal(a.load('same-id').state.from,'a'); + assert.equal(b.load('same-id').state.from,'b'); + assert.throws(() => b.createRun({startKey:'wrong',checkpoint:checkpoint('tenant-a','foreign')}),/INVALID_CHECKPOINT/); + assert.throws(() => b.compareAndSwap({ + runId:'only-a',expectedRevision:0,nextCheckpoint:checkpoint('tenant-a','only-a'), + transitionId:'t', + }),/RUN_NOT_FOUND/); + assert.throws(()=>a.deleteArtifact(identity.artifactHash),/ARTIFACT_IN_USE/); + assert.throws(()=>b.deleteArtifact(identity.artifactHash),/ARTIFACT_IN_USE/); +}); + +test('M3 idempotent start fingerprints tenant + artifact + initial state + options, rejecting conflicts without writes', async t => { + const file=await fixture(t); + const store=createSqliteCheckpointStore(file,{namespace:'n'}); + t.after(()=>store.close()); + store.putArtifact({programIdentity:identity,bytes:artifactBytes}); + const one=store.createRun({startKey:'daily',startIntent:{policy:'v1'},checkpoint:checkpoint('n','r-1',{a:1,b:2})}); + assert.equal(one.created,true); + const retry=store.createRun({startKey:'daily',startIntent:{policy:'v1'}, + checkpoint:checkpoint('n','new-id',{b:2,a:1})}); + assert.equal(retry.created,false); + assert.equal(retry.checkpoint.runId,'r-1'); + assert.equal(retry.checkpoint.startIntentHash,one.checkpoint.startIntentHash); + for (const changed of [ + {startKey:'daily',startIntent:{policy:'v2'},checkpoint:checkpoint('n','r-3',{a:1,b:2})}, + {startKey:'daily',startIntent:{policy:'v1'},checkpoint:checkpoint('n','r-4',{a:3,b:2})}, + ]) { + assert.throws(()=>store.createRun(changed),/START_CONFLICT/); + } + assert.throws(()=>store.createRun({startKey:'different',checkpoint:checkpoint('n','r-1')}),/RUN_CONFLICT/); + assert.deepEqual(store.load('r-1'),one.checkpoint); + assert.equal(store.listJournal('r-1').length,1); +}); + +test('M3 CAS atomically advances revision and hash-chained Journal; stale/duplicate writes are no-ops', async t => { + const file = await fixture(t); + const store = createSqliteCheckpointStore(file,{namespace:'n'}); + t.after(()=>store.close()); + store.putArtifact({programIdentity:identity,bytes:artifactBytes}); + const initial=store.createRun({startKey:'start',checkpoint:checkpoint('n','r')}).checkpoint; + const first=store.compareAndSwap({ + runId:'r',expectedRevision:0,nextCheckpoint:next(initial),transitionId:'t1', + journalEntry:{kind:'move',blockId:'gate::route'}, + }); + assert.equal(first.committed,true); + const stale=store.compareAndSwap({ + runId:'r',expectedRevision:0,nextCheckpoint:next(initial),transitionId:'another', + journalEntry:{kind:'move'}, + }); + assert.equal(stale.committed,false); + assert.deepEqual(stale.checkpoint, first.checkpoint); + const terminal=store.compareAndSwap({ + runId:'r',expectedRevision:1,nextCheckpoint:next(first.checkpoint,'succeeded'),transitionId:'t2', + journalEntry:{kind:'terminal',status:'succeeded'}, + }); + assert.equal(terminal.committed,true); + assert.throws(()=>store.compareAndSwap({ + runId:'r',expectedRevision:2,nextCheckpoint:next(terminal.checkpoint),transitionId:'t3', + }),/TERMINAL_IMMUTABLE/); + assert.throws(()=>store.compareAndSwap({ + runId:'r',expectedRevision:2,nextCheckpoint:{...next(terminal.checkpoint),artifactHash:hash('different')}, + transitionId:'t4', + }),/TERMINAL_IMMUTABLE|PROGRAM_MISMATCH/); + const history=store.listJournal('r'); + assert.deepEqual(history.map(v=>v.revision),[0,1,2]); + assert.equal(history[1].previousHash,history[0].entryHash); + assert.equal(history[2].previousHash,history[1].entryHash); + assert.equal(history[2].checkpointHash,sha256Bytes(canonicalBytes(terminal.checkpoint))); + assert.equal(store.load('r').revision,2); +}); + +test('M3 a duplicate transition ID rolls back Checkpoint and Journal in the same transaction',async t=>{ + const file=await fixture(t); + const store=createSqliteCheckpointStore(file,{namespace:'n'}); + t.after(()=>store.close()); + store.putArtifact({programIdentity:identity,bytes:artifactBytes}); + const initial=store.createRun({startKey:'k',checkpoint:checkpoint('n','r')}).checkpoint; + store.compareAndSwap({runId:'r',expectedRevision:0,nextCheckpoint:next(initial),transitionId:'tx'}); + const before=store.load('r'); + assert.throws(()=>store.compareAndSwap({ + runId:'r',expectedRevision:1,nextCheckpoint:next(before),transitionId:'tx', + }),/UNIQUE|constraint/i); + assert.deepEqual(store.load('r'),before); + assert.deepEqual(store.listJournal('r').map(x=>x.revision),[0,1]); +}); + +test('M3 a separate SQLite connection observes committed data after process/connection restart',async t=>{ + const file=await fixture(t); + const first=createSqliteCheckpointStore(file,{namespace:'n'}); + first.putArtifact({programIdentity:identity,bytes:artifactBytes}); + const initial=first.createRun({startKey:'s',checkpoint:checkpoint('n','r')}).checkpoint; + const updated=first.compareAndSwap({runId:'r',expectedRevision:0,nextCheckpoint:next(initial),transitionId:'t'}); + assert.equal(updated.committed,true); + first.close(); + const second=createSqliteCheckpointStore(file,{namespace:'n'}); + t.after(()=>second.close()); + assert.deepEqual(second.load('r'),updated.checkpoint); + assert.equal(second.listJournal('r').length,2); +}); + +test('M3 two real OS processes racing at the same revision cannot both commit',async t=>{ + const file=await fixture(t); + const store=createSqliteCheckpointStore(file,{namespace:'n'}); + t.after(()=>store.close()); + store.putArtifact({programIdentity:identity,bytes:artifactBytes}); + const initial=store.createRun({startKey:'k',checkpoint:checkpoint('n','r')}).checkpoint; + const moduleUrl=new URL('../../src/interactive/sqlite-store.mjs',import.meta.url).href; + const script=[ + 'import {createSqliteCheckpointStore} from ' + JSON.stringify(moduleUrl) + ';', + 'const [file, cpJson, transitionId] = process.argv.slice(1);', + 'const store = createSqliteCheckpointStore(file,{namespace:"n"});', + 'const cp=JSON.parse(cpJson);', + 'const next={...cp,revision:1,phase:"route",blockId:"gate::route"};', + 'const r=store.compareAndSwap({runId:"r",expectedRevision:0,nextCheckpoint:next,transitionId});', + 'console.log(r.committed ? "WIN" : "LOSE"); store.close();', + ].join('\n'); + const run=(name)=>new Promise((resolve,reject)=>{ + const child=spawn(process.execPath,['--input-type=module','-e',script,file,JSON.stringify(initial),name],{stdio:['ignore','pipe','pipe']}); + let stdout='',stderr=''; + child.stdout.on('data',data=>{stdout+=data}); + child.stderr.on('data',data=>{stderr+=data}); + child.on('error',reject); + child.on('close',code=>code===0 ? resolve(stdout.trim()) : reject(Error(stderr))); + }); + const results=await Promise.all([run('p1'),run('p2')]); + assert.deepEqual(results.sort(),['LOSE','WIN']); + assert.equal(store.load('r').revision,1); + assert.equal(store.listJournal('r').length,2); +}); + +test('M3 a tampered checkpoint row is rejected, not replayed or silently repaired',async t=>{ + const file=await fixture(t); + const store=createSqliteCheckpointStore(file,{namespace:'n'}); + store.putArtifact({programIdentity:identity,bytes:artifactBytes}); + store.createRun({startKey:'s',checkpoint:checkpoint('n','r')}); + store.close(); + const db=new DatabaseSync(file); + db.prepare('UPDATE fk_i_runs SET checkpoint_json = ? WHERE namespace = ? AND run_id = ?') + .run('{"malicious":true}','n','r'); + db.close(); + const reopened=createSqliteCheckpointStore(file,{namespace:'n'}); + t.after(()=>reopened.close()); + assert.throws(()=>reopened.load('r'),/STORE_CORRUPT/); +}); From 68aabff235ffe2f31b345ddc901d9e1f356c66c3 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:21:13 +0900 Subject: [PATCH 4/7] build(interactive): ship M3 internal SQLite Store with exact package manifest --- scripts/package-files.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/package-files.mjs b/scripts/package-files.mjs index a5ab734..6f38d7e 100644 --- a/scripts/package-files.mjs +++ b/scripts/package-files.mjs @@ -10,7 +10,7 @@ export const packageFiles = [ ...['source', 'extract', 'validate', 'skill-check', 'analyze', 'graph-algorithms', 'graph', 'dataflow', 'values', 'paths', 'mermaid', 'emit-plan', 'helpers', 'emit', 'compile', 'api', 'output'].map(name => [`src/compiler/${name}.mjs`, `dist/compiler/${name}.mjs`]), ...['feature-gate', 'lower', 'validate-ir'].map(name => [`src/compiler/interactive/${name}.mjs`, `dist/compiler/interactive/${name}.mjs`]), ['src/compiler/interactive/emit.mjs', 'dist/compiler/interactive/emit.mjs'], - ...['compile', 'identity', 'load'].map(name => [`src/interactive/${name}.mjs`, `dist/interactive/${name}.mjs`]), + ...['compile', 'identity', 'load', 'canonical', 'sqlite-store'].map(name => [`src/interactive/${name}.mjs`, `dist/interactive/${name}.mjs`]), ...['parser', 'evaluate'].map(name => [`src/when/${name}.mjs`, `dist/when/${name}.mjs`]), ...['context', 'execute', 'load', 'source', 'skills', 'skill-remote', 'run', 'aot', 'dump', 'dump-channel', 'dump-hook'].map(name => [`src/runtime/${name}.mjs`, `dist/runtime/${name}.mjs`]), ...['dsl', 'index', 'compiler-api'].map(name => [`types/${name}.d.ts`, `dist/${name}.d.ts`]), From ed476518fa5cd697020d0c162cb0e2831471078b Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:24:55 +0900 Subject: [PATCH 5/7] test(interactive): close Windows SQLite connections before fixture cleanup --- tests/interactive/store.test.mjs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/interactive/store.test.mjs b/tests/interactive/store.test.mjs index ee9e7a8..9e231e3 100644 --- a/tests/interactive/store.test.mjs +++ b/tests/interactive/store.test.mjs @@ -1,4 +1,4 @@ -import test from 'node:test'; +import test, {after} from 'node:test'; import assert from 'node:assert/strict'; import {mkdtemp, rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; @@ -25,11 +25,16 @@ const next = (cp, status = 'running') => ({ status, phase:'route', blockId:'gate::route', }); -async function fixture(t) { +// Module teardown runs after every test-specific DatabaseSync.close() hook. +// Windows cannot unlink SQLite files while any connection still owns them. +const temporaryDirectories = []; +after(async () => { + for (const dir of temporaryDirectories) await rm(dir, {recursive:true, force:true}); +}); +async function fixture() { const dir = await mkdtemp(path.join(tmpdir(), 'flowkit-m3-')); - const file = path.join(dir, 'checkpoint.db'); - t.after(() => rm(dir, {recursive:true, force:true})); - return file; + temporaryDirectories.push(dir); + return path.join(dir, 'checkpoint.db'); } test('M3 canonical JSON uses stable key order and preserves prototype keys only as data', () => { From 9b5773182f6af3c5d2743d4643669dbfe19caed5 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:28:15 +0900 Subject: [PATCH 6/7] fix(interactive): pin policy, verify SQLite durability and bound artifacts before copying --- src/interactive/sqlite-store.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/interactive/sqlite-store.mjs b/src/interactive/sqlite-store.mjs index 26cfd5d..3420369 100644 --- a/src/interactive/sqlite-store.mjs +++ b/src/interactive/sqlite-store.mjs @@ -43,7 +43,7 @@ function assertCheckpoint(value, namespace, runId, revision, artifactHash) { } } function immutableIdentity(previous, next) { - for (const key of ['namespace', 'runId', 'schemaVersion', 'artifactHash', 'startKey', 'startIntentHash']) { + for (const key of ['namespace', 'runId', 'schemaVersion', 'artifactHash', 'startKey', 'startIntentHash', 'policyHash']) { if (previous[key] !== next[key]) { throw failure('FK_I_INVALID_CHECKPOINT', 'immutable field changed: ' + key); } @@ -69,6 +69,13 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA synchronous = FULL'); + // Neither a storage driver nor an in-memory database may silently weaken durability. + const journalMode = db.prepare('PRAGMA journal_mode').get()?.journal_mode; + const synchronous = db.prepare('PRAGMA synchronous').get()?.synchronous; + const foreignKeys = db.prepare('PRAGMA foreign_keys').get()?.foreign_keys; + if (journalMode !== 'wal' || synchronous !== 2 || foreignKeys !== 1) { + throw failure('FK_I_STORE_UNSAFE', 'WAL, synchronous FULL and foreign keys are required'); + } db.exec([ 'CREATE TABLE IF NOT EXISTS fk_i_artifacts (', ' namespace TEXT NOT NULL, artifact_hash TEXT NOT NULL, source_hash TEXT NOT NULL,', @@ -145,6 +152,9 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { /** Preserve exact compiler-produced bytes, keyed by their content hash. */ putArtifact({programIdentity, bytes}) { if (!(bytes instanceof Uint8Array)) throw new TypeError('Program bytes must be Uint8Array'); + if (bytes.byteLength > 4 * 1024 * 1024) { + throw failure('FK_I_PROGRAM_MISMATCH', 'Program exceeds 4 MiB'); + } if (!programIdentity || typeof programIdentity !== 'object') throw new TypeError('Program identity required'); const identity = safeJSONSnapshot(programIdentity); assertSHA256(identity.artifactHash); @@ -174,6 +184,9 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { return transaction('read', () => { const record = artifact.get(namespace, artifactHash); if (!record) return null; + if (record.program_bytes.byteLength > 4 * 1024 * 1024) { + throw failure('FK_I_STORE_CORRUPT', 'artifact exceeds 4 MiB'); + } const copied = Buffer.from(record.program_bytes); if (sha256Bytes(copied) !== artifactHash) { throw failure('FK_I_STORE_CORRUPT', 'artifact bytes modified'); From c879166346c4e989761b57e73198bc49cdf30a60 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 22:28:39 +0900 Subject: [PATCH 7/7] test(interactive): reject unsafe SQLite modes, oversized artifacts, policy drift --- tests/interactive/store.test.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/interactive/store.test.mjs b/tests/interactive/store.test.mjs index 9e231e3..c9d38af 100644 --- a/tests/interactive/store.test.mjs +++ b/tests/interactive/store.test.mjs @@ -237,3 +237,24 @@ test('M3 a tampered checkpoint row is rejected, not replayed or silently repaire t.after(()=>reopened.close()); assert.throws(()=>reopened.load('r'),/STORE_CORRUPT/); }); + +test('M3 refuses non-WAL storage, oversized Program bytes and policy-identity drift', async t => { + assert.throws(() => createSqliteCheckpointStore(':memory:', {namespace:'n'}), /FK_I_STORE_UNSAFE/); + const file = await fixture(t); + const store = createSqliteCheckpointStore(file,{namespace:'n'}); + t.after(() => store.close()); + const oversized = new Uint8Array(4*1024*1024+1); + assert.throws(() => store.putArtifact({programIdentity:identity,bytes:oversized}), /PROGRAM_MISMATCH/); + store.putArtifact({programIdentity:identity,bytes:artifactBytes}); + const immutablePolicy = 'sha256:' + '7'.repeat(64); + const initial = store.createRun({ + startKey:'fixed-policy', + checkpoint:{...checkpoint('n','r'),policyHash:immutablePolicy}, + }).checkpoint; + const mutated = {...next(initial),policyHash:'sha256:'+'8'.repeat(64)}; + assert.throws(() => store.compareAndSwap({ + runId:'r',expectedRevision:0,nextCheckpoint:mutated,transitionId:'drift', + }), /INVALID_CHECKPOINT/); + assert.deepEqual(store.load('r'),initial); + assert.deepEqual(store.listJournal('r').map(entry=>entry.revision),[0]); +});