task_snapshots gains via and read_at, an integer-bucket CHECK with a tombstone exception, the read-ordering rule in task_external_changes and the tasks_open view. task.missing names its task and a reason. Prototype rerun on Node 24.21.0 and 26.8.1; a mutant without the read_at rule reports the broker's own move as external. Lead decision 52 (B3). Co-Authored-By: Claude Opus 5.5 <[email protected]>
120 lines
12 KiB
JavaScript
120 lines
12 KiB
JavaScript
// Slice 1 prototype, v3 schema (addendum B section 5, lead decision 52). Same pattern as proto-v2.mjs.
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import { readFileSync, mkdtempSync } from "node:fs";
|
|
import { join } from "node:path"; import { tmpdir } from "node:os";
|
|
import { createHash } from "node:crypto";
|
|
const f = join(mkdtempSync(join(tmpdir(), "s1v3-")), "bus.sqlite");
|
|
let db = new DatabaseSync(f, { timeout: 5000 });
|
|
db.exec(readFileSync(new URL("./schema-v3.sql", import.meta.url), "utf8"));
|
|
let t = 0; const now = () => new Date(Date.UTC(2026, 9, 4, 12, 0, t++)).toISOString();
|
|
const tryit = (label, fn) => { try { fn(); console.log("ok ", label); } catch (e) { console.log("refuse", label, "->", e.message.replace(/\s+/g, " ").slice(0, 90)); } };
|
|
const show = (label, sql) => console.log("view ", label, "->", JSON.stringify(db.prepare(sql).all()));
|
|
const hex = (s) => createHash("sha256").update(s).digest("hex");
|
|
const schemaDigest = (d) => hex(d.prepare("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name").all().map((r) => `${r.type}|${r.name}|${r.sql}`).join("\n"));
|
|
|
|
console.log("-- open-time schema check");
|
|
db.prepare("INSERT INTO meta (key, value) VALUES ('schema_digest', ?)").run(schemaDigest(db));
|
|
const check = () => db.prepare("SELECT value FROM meta WHERE key = 'schema_digest'").get().value === schemaDigest(db) ? "match" : "MISMATCH";
|
|
console.log("check ", "after create ->", check());
|
|
|
|
console.log("-- decisions.blocking");
|
|
const dec = db.prepare("INSERT INTO decisions (id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,blocking) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
|
const opts = JSON.stringify([{ key: "A", text: "rotate" }, { key: "B", text: "wait" }]);
|
|
tryit("raise without blocking", () => db.exec(`INSERT INTO decisions (id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation) VALUES ('d-0','${now()}','mosaic-stack','coder','run-C','gated','credential.mint','human','?','${opts}','A')`));
|
|
tryit("raise with blocking 2", () => dec.run("d-1", now(), "mosaic-stack", "coder", "run-C", "gated", "credential.mint", "human", "Rotate?", opts, "A", "vikunja:3/41", 2));
|
|
tryit("raise blocking without task_ref", () => dec.run("d-2", now(), "mosaic-stack", "coder", "run-C", "gated", "credential.mint", "human", "Rotate?", opts, "A", null, 1));
|
|
tryit("raise blocking gated with task_ref", () => dec.run("d-3", now(), "mosaic-stack", "coder", "run-C", "gated", "credential.mint", "human", "Rotate coder vikunja token?", opts, "A", "vikunja:3/41", 1));
|
|
tryit("raise non-blocking gated", () => dec.run("d-4", now(), "mosaic-stack", "pm", "run-P", "gated", "deploy", "human", "Deploy?", opts, "B", null, 0));
|
|
tryit("raise blocking cross-role", () => dec.run("d-5", now(), "mosaic-stack", "coder", "run-C", "cross-role", "task.scope.change", "pm", "Widen scope?", opts, "B", "vikunja:3/41", 1));
|
|
show("urgent_inbox", "SELECT id, task_ref FROM urgent_inbox");
|
|
tryit("resolve d-3 with A", () => db.prepare("INSERT INTO decision_events (decision,at,op,by,choice,via) VALUES (?,?,?,?,?,?)").run("d-3", now(), "resolved", "jason", "A", "cli"));
|
|
show("urgent_inbox after resolve", "SELECT id FROM urgent_inbox");
|
|
|
|
console.log("-- events: closed kinds and the new kinds");
|
|
const ev = db.prepare("INSERT INTO events (id,at,business,kind,actor_role,actor_run,subject,body) VALUES (?,?,?,?,?,?,?,?)");
|
|
let n = 0; const e = (kind, role, run, body, subject = null) => ev.run(`e-${++n}`, now(), "mosaic-stack", kind, role, run, subject, JSON.stringify(body));
|
|
tryit("unknown kind task.deleted", () => e("task.deleted", "pm", "run-P", {}));
|
|
tryit("credential.expiring vikunja coder", () => e("credential.expiring", null, null, { service: "vikunja", instance: "coder", expires: "2026-10-11" }));
|
|
tryit("credential.expired vikunja coder", () => e("credential.expired", null, null, { service: "vikunja", instance: "coder", decision: "d-3" }));
|
|
tryit("credential.changed gitea pm", () => e("credential.changed", null, null, { service: "gitea", instance: "pm", stat: { inode: 1, size: 41 } }));
|
|
tryit("credential.changed without instance", () => e("credential.changed", null, null, { service: "gitea" }));
|
|
tryit("credential.expiring service github", () => e("credential.expiring", null, null, { service: "github", instance: "pm" }));
|
|
tryit("task.missing without reason", () => e("task.missing", null, null, { reconcile: "r-1" }, "vikunja:3/40"));
|
|
tryit("task.missing reason deleted", () => e("task.missing", null, null, { reason: "deleted" }, "vikunja:3/40"));
|
|
tryit("task.missing without subject", () => e("task.missing", null, null, { reason: "not-found" }));
|
|
tryit("task.missing moved without project", () => e("task.missing", null, null, { reason: "moved" }, "vikunja:3/40"));
|
|
tryit("task.missing not-found", () => e("task.missing", null, null, { reason: "not-found" }, "vikunja:3/40"));
|
|
tryit("task.missing moved to project 9", () => e("task.missing", null, null, { reason: "moved", project: 9 }, "vikunja:3/43"));
|
|
tryit("digest.sent", () => e("digest.sent", null, null, { decisions: ["d-4"], transport: "discord-dm" }));
|
|
tryit("launch.revoked by pm run", () => e("launch.revoked", "pm", "run-P", {}));
|
|
tryit("launch.revoked by human", () => e("launch.revoked", null, null, { via: "cli" }));
|
|
show("launch_state", "SELECT business, state FROM launch_state");
|
|
tryit("launch.restored by human", () => e("launch.restored", null, null, { via: "cli" }));
|
|
show("launch_state", "SELECT business, state FROM launch_state");
|
|
|
|
console.log("-- task_snapshots");
|
|
const snap = db.prepare("INSERT INTO task_snapshots (at,business,task_ref,updated,etag,digest,fields,source,via,read_at,role,run) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
|
|
const at = (sec) => new Date(Date.UTC(2026, 9, 4, 13, 0, sec)).toISOString();
|
|
const self = (ref, updated, fields, sec, role, run) => snap.run(at(sec), "mosaic-stack", ref, updated, `"${hex(JSON.stringify(fields)).slice(0, 8)}"`, hex(JSON.stringify(fields)), JSON.stringify(fields), "self", null, null, role, run);
|
|
const poll = (ref, updated, fields, via, readSec) => snap.run(at(readSec + 1), "mosaic-stack", ref, updated, null, hex(JSON.stringify(fields)), JSON.stringify(fields), "poll", via, at(readSec), null, null);
|
|
const raw = (source, via, readAt, role, run, fields) => snap.run(at(59), "mosaic-stack", "vikunja:3/49", "2026-10-04T12:00:00Z", null, hex(JSON.stringify(fields)), JSON.stringify(fields), source, via, readAt, role, run);
|
|
// Buckets: 11 todo, 12 in-progress, 13 in-review, 14 blocked, 15 done.
|
|
const X = { title: "Add broker push", bucket: 12, done: 0 }, Y = { ...X, title: "Add broker push (Jason edit)" }, Z = { ...Y, bucket: 13 }, B = { ...Z, bucket: 14 }, W = { title: "Add broker push", bucket: 11, done: 0 };
|
|
const U = "2026-10-04T12:00:00Z";
|
|
tryit("self without role and run", () => raw("self", null, null, null, null, X));
|
|
tryit("poll with a role", () => raw("poll", "board", at(58), "pm", "run-P", X));
|
|
tryit("poll without via", () => raw("poll", null, at(58), null, null, X));
|
|
tryit("poll without read_at", () => raw("poll", "board", null, null, null, X));
|
|
tryit("self with via", () => raw("self", "board", at(58), "coder", "run-C", X));
|
|
tryit("unknown via webhook", () => raw("poll", "webhook", at(58), null, null, X));
|
|
tryit("fields without bucket", () => raw("poll", "cursor", at(58), null, null, { title: "x", done: 0 }));
|
|
tryit("bucket as text", () => raw("poll", "cursor", at(58), null, null, { title: "x", bucket: "in-progress", done: 0 }));
|
|
tryit("gone on a board read", () => raw("poll", "board", at(58), null, null, { gone: "not-found" }));
|
|
tryit("gone on self", () => raw("self", null, null, "pm", "run-P", { gone: "not-found" }));
|
|
tryit("bad task_ref", () => snap.run(at(59), "mosaic-stack", "PROJ-41", U, null, hex("x"), JSON.stringify(X), "poll", "board", at(58), null, null));
|
|
tryit("bad digest", () => snap.run(at(59), "mosaic-stack", "vikunja:3/41", U, null, "abc", JSON.stringify(X), "poll", "board", at(58), null, null));
|
|
tryit("self X by coder, response :02", () => self("vikunja:3/41", U, X, 2, "coder", "run-C"));
|
|
tryit("cursor X sent :04 (unchanged)", () => poll("vikunja:3/41", U, X, "cursor", 4));
|
|
show("external after cursor X", "SELECT task_ref FROM task_external_changes");
|
|
tryit("cursor Y sent :06, same second (person edit)", () => poll("vikunja:3/41", U, Y, "cursor", 6));
|
|
show("external after cursor Y", "SELECT task_ref, via FROM task_external_changes");
|
|
tryit("self Z by coder (move to in-review), response :10", () => self("vikunja:3/41", U, Z, 10, "coder", "run-C"));
|
|
tryit("stale board read sent :09 shows Y", () => poll("vikunja:3/41", U, Y, "board", 9));
|
|
show("external after self Z, stale board read", "SELECT task_ref FROM task_external_changes");
|
|
tryit("stale cursor read, updated 11:59:00", () => poll("vikunja:3/41", "2026-10-04T11:59:00Z", W, "cursor", 20));
|
|
show("external after stale cursor read", "SELECT task_ref FROM task_external_changes");
|
|
tryit("board read sent :30 agrees with Z", () => poll("vikunja:3/41", U, Z, "board", 30));
|
|
show("external after board agrees", "SELECT task_ref FROM task_external_changes");
|
|
tryit("person moves to blocked, updated unchanged, board sent :40", () => poll("vikunja:3/41", U, B, "board", 40));
|
|
show("external after person's move", "SELECT task_ref, via FROM task_external_changes");
|
|
tryit("board read on vikunja:3/42 with no self row", () => poll("vikunja:3/42", "2026-10-04T12:01:00Z", W, "board", 41));
|
|
tryit("cursor read on vikunja:3/44, done", () => poll("vikunja:3/44", "2026-10-04T12:01:00Z", { ...W, bucket: 15, done: 1 }, "cursor", 41));
|
|
show("tasks_open", "SELECT task_ref, bucket FROM tasks_open ORDER BY task_ref");
|
|
tryit("tombstone for vikunja:3/42 (GET 404)", () => poll("vikunja:3/42", "2026-10-04T12:01:00Z", { gone: "not-found" }, "task", 45));
|
|
show("tasks_open after tombstone", "SELECT task_ref, bucket FROM tasks_open ORDER BY task_ref");
|
|
show("external, all", "SELECT task_ref, via FROM task_external_changes ORDER BY task_ref");
|
|
|
|
console.log("-- append-only on every table");
|
|
const keys = { meta: "key = 'schema_digest'", events: "id = 'e-2'", role_claims: "1", decisions: "id = 'd-3'", decision_events: "1", messages: "1", deliveries: "1", task_snapshots: "seq = 1" };
|
|
db.exec("INSERT INTO role_claims (at,business,role,op,holder_run,harness,by) VALUES ('x','mosaic-stack','pm','claim','run-P','pi','run-P')");
|
|
db.exec("INSERT INTO messages (id,at,business,from_role,from_run,to_role,class,decision,body) VALUES ('m-1','x','mosaic-stack','pm','run-P','human','RESULT','d-3','rotate')");
|
|
db.exec("INSERT INTO deliveries (message,at,op,transport) VALUES ('m-1','x','delivered','discord-dm')");
|
|
for (const [tbl, where] of Object.entries(keys)) {
|
|
const row = db.prepare(`SELECT * FROM ${tbl} WHERE ${where} LIMIT 1`).get();
|
|
const cols = Object.keys(row);
|
|
const col = cols.find((c) => !["seq", "id", "key"].includes(c));
|
|
tryit(`${tbl} UPDATE`, () => db.exec(`UPDATE ${tbl} SET ${col} = ${col} WHERE ${where}`));
|
|
tryit(`${tbl} DELETE`, () => db.exec(`DELETE FROM ${tbl} WHERE ${where}`));
|
|
tryit(`${tbl} INSERT OR REPLACE`, () => db.prepare(`INSERT OR REPLACE INTO ${tbl} (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`).run(...cols.map((c) => row[c])));
|
|
}
|
|
|
|
console.log("-- tamper: drop a guard, reopen");
|
|
db.close(); db = new DatabaseSync(f, { timeout: 5000 });
|
|
console.log("check ", "on reopen ->", check());
|
|
db.exec("DROP TRIGGER task_snapshots_no_update");
|
|
db.close(); db = new DatabaseSync(f, { timeout: 5000 });
|
|
console.log("check ", "after DROP TRIGGER ->", check());
|
|
|
|
const count = (type) => db.prepare("SELECT count(*) n FROM sqlite_master WHERE type = ?").get(type).n;
|
|
console.log("node", process.versions.node, "| sqlite", db.prepare("SELECT sqlite_version() v").get().v, "| journal:", db.prepare("PRAGMA journal_mode").get().journal_mode, "| tables:", count("table") - 1, "| triggers:", count("trigger"), "| views:", count("view"));
|