Files
stack/packages/bus/tests/project-archive.test.mjs
jason.woltje e6ab8bc1fd feat(bus,plugins,organization,cli,webui): SP9 remove system types outright, bus schema 3v-3w drops project_system_types (#1873)
Jason's 2026-11-12 ruling: the only deployed system is the dev environment on
dragon-lin, so the system-type plug-in kind goes with no retired kind, fence,
conversion or new refusal code.

- plugins: system-type leaves KINDS; system-types.mjs and the five modules are
  deleted. An org file naming the kind refuses unknown-kind.
- organization: the plugins.system-type check, systemTypesFence and the
  system-types.console right are removed.
- bus: system-types.console, project.system-types[.set], the system-type half
  of the tester check, the runtime reads and system-type-not-enabled are
  removed. Step 3v-3w drops project_system_types, its index, its four guards
  and its rows; SCHEMA_VERSION is 3w (32 tables, 126 triggers, 5 views).
- cli: mosaic org system-types and mosaic project system-types[-set] are
  removed; stack-release COUNTED and the budget supervisor's closed list move
  to 3w.
- channel: the project.system-types verbs are removed.
- webui: the System types page, route, dashboard row, chips and project
  selection are removed.
- docs: REQ-SYS-1/2 now describe starter packs; README system-type text and
  the requirements titles follow.
2026-11-12 21:02:46 -06:00

283 lines
18 KiB
JavaScript

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 it; a 3s store has neither table.
const ddl3s = currentDDL.replace(migrationSQL, '').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), '3w');
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 = '3w';", "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), '3w');
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' }));
});