import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { DatabaseSync } from 'node:sqlite'; import { Store, schemaDigest } from '../src/store.mjs'; import { Broker, TESTER_VERBS, USER_VERBS, VIEWER_VERBS } from '../src/broker.mjs'; // #797 MVP3 (owner report 2026-11-06): the owner's reversible project archive. The verbs are the // human's alone (tester, user and agent refused), an archived project hides from project.list // until includeArchived asks for it, refuses new threads and requests, keeps every record // readable, and unarchive restores everything. Nothing is ever deleted. const shipped = (name) => JSON.parse(readFileSync(new URL(`../../../roles/${name}.json`, import.meta.url), 'utf8')); const AT = '2026-11-05T00:00:00.000Z'; const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { continuity: { definition: 'continuity', authority: { withinRole: ['message.send'], crossRole: [] } }, chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, }, definitions: { continuity: { messaging: shipped('continuity').messaging }, 'chief-of-staff': {} }, projects: { stack: { root: '/nonexistent' } }, vars: { 'bus.tester': true }, }; function setup(t, { grants = null } = {}) { const root = mkdtempSync(join(tmpdir(), 'bus-project-archive-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: grants ?? [] } }; const b = new Broker({ store, businesses: { demo: business }, access: () => access.doc }); const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), tester: b.bindTester({ business: 'demo' }), }; const call = (verb, args = {}, who = 'human') => b.request(caps[who], { verb, args }); const code = (verb, args = {}, who = 'human') => { try { call(verb, args, who); } catch (e) { return e.code; } return null; }; caps.agent = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }); call('role.claim', {}, 'agent'); if (grants) caps.user = b.bindUser({ business: 'demo', user: 'alice' }); return { b, caps, store, call, code, root, access }; } const project = (f, name = 'demo-project') => f.call('project.create', { name }).id; test('project.archive and project.unarchive are the human\'s alone', (t) => { const f = setup(t), id = project(f); for (const list of [TESTER_VERBS, USER_VERBS, VIEWER_VERBS]) assert.ok(!list.includes('project.archive') && !list.includes('project.unarchive')); assert.equal(f.code('project.archive', { project: id }, 'tester'), 'tester-refused'); assert.equal(f.code('project.archive', { project: id }, 'agent'), 'human-required'); assert.throws(() => f.b.bindHuman({ business: 'demo', human: 'not-owner', via: 'cli', outsideAgent: true }), { code: 'human-required' }); assert.throws(() => f.b.bindConsole({ business: 'demo', human: 'not-owner', subject: { provider: 'test', iss: 'test', sub: 'other' } }), { code: 'human-required' }); f.caps.console = f.b.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } }); assert.equal(f.code('project.archive', { project: id }, 'console'), 'human-required'); const u = setup(t, { grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['continuity'], level: 'collaborator' }] }); assert.equal(u.code('project.archive', { project: id }, 'user'), 'access-refused'); assert.equal(u.code('project.unarchive', { project: id }, 'user'), 'access-refused'); assert.deepEqual(f.call('project.archive', { project: id }), { project: id, archived: true }); assert.deepEqual(f.call('project.unarchive', { project: id }), { project: id, archived: false }); }); test('archive verbs validate arguments and unknown projects', (t) => { const f = setup(t), id = project(f); assert.equal(f.code('project.archive', {}), 'invalid-request'); assert.equal(f.code('project.archive', { project: id, extra: 1 }), 'invalid-request'); // identifier() takes any id-shaped string, so a non-uuid name simply finds no project. assert.equal(f.code('project.archive', { project: 'not-a-uuid' }), 'project-not-found'); assert.equal(f.code('project.archive', { project: '00000000-0000-4000-8000-000000000009' }), 'project-not-found'); assert.equal(f.code('project.unarchive', { project: id }), 'project-not-archived'); f.call('project.archive', { project: id }); assert.equal(f.code('project.archive', { project: id }), 'project-already-archived'); // A repeat op never writes a second row. assert.equal(f.store.get('SELECT count(*) n FROM project_archives').n, 1); }); test('project.list hides archived projects until includeArchived, which flags them', (t) => { const f = setup(t); const live = project(f, 'live'), gone = project(f, 'gone'); f.call('project.archive', { project: gone }); assert.deepEqual(f.call('project.list').map((p) => p.id), [live]); assert.ok(f.call('project.list').every((p) => !('archived' in p))); const all = f.call('project.list', { includeArchived: true }); assert.deepEqual(all.map((p) => p.id), [live, gone]); assert.deepEqual(all.map((p) => p.archived), [undefined, true]); assert.equal(f.code('project.list', { includeArchived: 'yes' }), 'invalid-request'); assert.equal(f.code('project.list', { includeArchived: false }), 'invalid-request'); // FED R3: a user still lists granted projects only, archived or not. Grants name project ids. const u = setup(t); const uGone = project(u, 'gone'); u.access.doc.grants.push({ id: 'g-1', user: 'alice', business: 'demo', projects: [uGone], instances: ['continuity'], level: 'collaborator' }); u.caps.user = u.b.bindUser({ business: 'demo', user: 'alice' }); assert.deepEqual(u.call('project.list', {}, 'user').map((p) => p.id), [uGone]); assert.deepEqual(u.call('project.list', { includeArchived: true }, 'user').map((p) => p.id), [uGone]); u.call('project.archive', { project: uGone }); assert.deepEqual(u.call('project.list', {}, 'user').map((p) => p.id), []); assert.deepEqual(u.call('project.list', { includeArchived: true }, 'user').map((p) => [p.id, p.archived]), [[uGone, true]]); }); test('an archived project refuses new threads and requests, keeps records readable, and unarchive restores everything', (t) => { const f = setup(t), id = project(f); const thread = f.call('thread.create', { project: id, name: 'continuity', instance: 'continuity' }).id; assert.ok(f.call('thread.send', { thread, body: 'before archive' }).id); const rows = () => ({ threads: f.store.get('SELECT count(*) n FROM threads').n, messages: f.store.get('SELECT count(*) n FROM messages').n, events: f.store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n, }); f.call('room.open', { project: id }); const before = rows(); f.call('project.archive', { project: id }); assert.equal(f.code('thread.create', { project: id, name: 'another', instance: 'continuity' }), 'project-archived'); assert.equal(f.code('thread.send', { thread, body: 'while archived' }), 'project-archived'); assert.equal(f.code('room.open', { project: id }), 'project-archived'); assert.equal(f.code('room.send', { project: id, body: 'while archived' }), 'project-archived'); assert.deepEqual(rows(), before, 'an archived project writes no thread, message or event'); // Existing records stay readable. assert.deepEqual(f.call('thread.list', { project: id }).map((r) => r.id), [thread]); assert.equal(f.call('thread.messages', { thread }).length, 1); assert.ok(Array.isArray(f.call('room.read', { project: id }))); // Unarchive restores everything. f.call('project.unarchive', { project: id }); assert.ok(f.call('project.list').some((p) => p.id === id)); assert.ok(f.call('thread.create', { project: id, name: 'another', instance: 'continuity' }).id); assert.ok(f.call('thread.send', { thread, body: 'after unarchive' }).id); assert.ok(f.call('room.send', { project: id, body: 'after unarchive' }).ids.length > 0); // The audit trail: one row per change, append-only, naming who and when. assert.deepEqual(f.store.all('SELECT business,project,op,by FROM project_archives').map((r) => ({ ...r })), [ { business: 'demo', project: id, op: 'archive', by: 'jason' }, { business: 'demo', project: id, op: 'unarchive', by: 'jason' }, ]); assert.match(f.store.get('SELECT at FROM project_archives').at, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/); }); // --- The 3s→3t migration: project_archives is new and empty, and everything else stays. --- const here = (p) => new URL(p, import.meta.url); const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8'); const currentDDL = readFileSync(here('../schema.sql'), 'utf8'); assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once'); // 3u-3v (#1873, U4a) adds store_tasks after them; a 3s store has neither. const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), ''); function source3s(t) { const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-')); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path); db.exec(ddl3s); db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3s'); db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db)); db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', AT); db.prepare("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p1',?,'demo','kept',NULL,'jason')").run(AT); db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved',?,'demo','action.allowed','{}')").run(AT); db.close(); chmodSync(path, 0o600); return { root, path }; } const master = (path) => { const db = new DatabaseSync(path, { readOnly: true }); try { return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })); } finally { db.close(); } }; const version = (path) => { const db = new DatabaseSync(path, { readOnly: true }); try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); } }; test('a 3s store migrates to 3t: project_archives is new and empty, every row stays, and the schema is the fresh one', (t) => { const { root, path } = source3s(t); const was = master(path); assert.throws(() => new Store(root), /schema-mismatch/); assert.equal(version(path), '3s'); assert.deepEqual(master(path), was, 'the refused open changed nothing'); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); assert.equal(version(path), '3v'); assert.equal(store.get("SELECT id FROM events").id, 'preserved'); assert.equal(store.get("SELECT name FROM projects").name, 'kept'); assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []); const freshRoot = mkdtempSync(join(tmpdir(), 'project-archive-fresh-')); t.after(() => rmSync(freshRoot, { recursive: true, force: true })); new Store(freshRoot).close(); assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite'))); // The guards: append-only, state changes only, and a row belongs to its project's business. const next = (op, p = 'p1') => store.run('INSERT INTO project_archives(at,business,project,op,by) VALUES(?,?,?,?,?)', AT, 'demo', p, op, 'jason'); next('archive'); assert.throws(() => next('archive'), /a project archive row changes state/); next('unarchive'); for (const [sql, ...args] of [["UPDATE project_archives SET op='unarchive'"], ['DELETE FROM project_archives'], ["INSERT OR REPLACE INTO project_archives(seq,at,business,project,op,by) VALUES(1,?,'demo','p1','unarchive','jason')", AT]]) // The replace also trips the state guard when its op repeats the latest row's. assert.throws(() => store.run(sql, ...args), /project_archives is append-only|a project archive row changes state/, sql); // A row naming a project that does not exist is refused (the business guard fires first). assert.throws(() => next('archive', '00000000-0000-4000-8000-000000000009'), /FOREIGN KEY|an archive row belongs to its project's business/); assert.throws(() => store.run("INSERT INTO project_archives(at,business,project,op,by) VALUES(?,'other','p1','archive','jason')", AT), /an archive row belongs to its project's business/); assert.throws(() => store.run("INSERT INTO project_archives(at,business,project,op,by) VALUES(?,'demo','p1','grant','jason')", AT), /CHECK/); assert.equal(store.get('SELECT count(*) n FROM project_archives').n, 2); }); test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it was', async (t) => { const { root, path } = source3s(t); new Store(root, { migrateSchema: true }).close(); const was = master(path); const moduleRoot = join(root, 'engine-3s'); mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true }); const source = readFileSync(here('../src/store.mjs'), 'utf8'); const edited = source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`) .replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3s';") .replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); writeFileSync(join(moduleRoot, 'schema.sql'), ddl3s); for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql']) copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file)); const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; assert.throws(() => new Store3s(root), /schema-mismatch/); assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/); assert.equal(version(path), '3v'); assert.deepEqual(master(path), was); // The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy. const { root: old } = source3s(t); new Store3s(old).close(); }); test('archived threads refuse open, clear and fresh claims, including metadata-only sessions', t => { const f = setup(t), p = project(f); const thread = f.call('thread.create', { project: p, name: 'archive-admission', instance: 'continuity' }).id; const session = f.call('session.list', { thread }).find(s => s.default); f.call('thread.open', { thread }); // queued before archive const cap = f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'queued', harness: 'pi', thread, session: session.id }); f.call('project.archive', { project: p }); const named = f.call('session.create', { thread, name: 'metadata-only', op: 'metadata-only' }).id; for (const se of [session.id, named]) { assert.equal(f.code('thread.open', { thread, session: se }), 'project-archived'); assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'new-' + se, harness: 'pi', thread, session: se }), { code: 'project-archived' }); } assert.equal(f.code('session.clear', { thread, session: session.id, revision: session.revision, confirm: true, op: 'archived-clear' }), 'project-archived'); assert.throws(() => f.b.request(cap, { verb: 'role.claim' }), { code: 'project-archived' }); assert.equal(f.store.get('SELECT count(*) n FROM session_ops').n, 0); assert.equal(f.store.get('SELECT count(*) n FROM thread_claims').n, 0); f.call('project.unarchive', { project: p }); assert.deepEqual(f.call('thread.open', { thread, session: named }), { opened: thread }); assert.ok(f.b.request(cap, { verb: 'role.claim' })); }); test('a queued clear can retire and cancel after archive but cannot start its successor', t => { const f = setup(t), p = project(f); const thread = f.call('thread.create', { project: p, name: 'queued-clear', instance: 'continuity' }).id; const se = f.call('session.list', { thread }).find(s => s.default); f.call('session.clear', { thread, session: se.id, revision: se.revision, confirm: true, op: 'queued-clear' }); f.call('project.archive', { project: p }); assert.equal(f.code('session.clear', { thread, session: se.id, revision: se.revision, confirm: true, op: 'queued-clear' }), 'project-archived'); const where = { business: 'demo', thread, op: 'queued-clear' }; f.b.sessionClearExited({ ...where, run: null }); f.b.sessionClearRetire(where); const pending = f.b.sessionClears('demo').find(s => s.op === where.op); assert.ok(pending.successor); assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'successor', harness: 'pi', thread, session: pending.successor }), { code: 'project-archived' }); assert.ok(f.call('session.cancel', { thread, op: where.op, confirm: true })); assert.ok(f.call('session.list', { thread }).length); }); test('archive preserves an accepted run result, release/end and business-wide launch admission', t => { const f = setup(t), p = project(f); const thread = f.call('thread.create', { project: p, name: 'accepted', instance: 'continuity' }).id; const session = f.call('session.list', { thread }).find(s => s.default).id; f.caps.running = f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'accepted', harness: 'pi', thread, session }); f.call('role.claim', {}, 'running'); f.call('project.archive', { project: p }); assert.ok(f.call('message.send', { to: 'human', body: 'Accepted work is complete.' }, 'running').id); assert.equal(f.call('thread.messages', { thread }).at(-1).body, 'Accepted work is complete.'); assert.ok(f.call('role.release', {}, 'running')); assert.ok(f.b.endLaunch({ business: 'demo', run: 'accepted', reason: 'done' })); assert.ok(f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'business-live', harness: 'pi' })); });