docs(slice1): schema v3 prototype, addendum B section 5

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]>
This commit is contained in:
2026-10-04 21:55:17 -05:00
co-authored by Claude Opus 5.5
parent 05a1cbf85a
commit 7ed831781b
5 changed files with 572 additions and 0 deletions
@@ -0,0 +1,93 @@
-- open-time schema check
check after create -> match
-- decisions.blocking
refuse raise without blocking -> NOT NULL constraint failed: decisions.blocking
refuse raise with blocking 2 -> CHECK constraint failed: blocking IN (0,1)
refuse raise blocking without task_ref -> a blocking decision cites the task it blocks
ok raise blocking gated with task_ref
ok raise non-blocking gated
ok raise blocking cross-role
view urgent_inbox -> [{"id":"d-3","task_ref":"vikunja:3/41"}]
ok resolve d-3 with A
view urgent_inbox after resolve -> []
-- events: closed kinds and the new kinds
refuse unknown kind task.deleted -> CHECK constraint failed: kind IN ( 'session.launched', 'session.ended', 'action.allowed',
ok credential.expiring vikunja coder
ok credential.expired vikunja coder
ok credential.changed gitea pm
refuse credential.changed without instance -> credential events name a service and a role instance
refuse credential.expiring service github -> credential events name a service and a role instance
refuse task.missing without reason -> task.missing names the task, a reason, and the new project when moved
refuse task.missing reason deleted -> task.missing names the task, a reason, and the new project when moved
refuse task.missing without subject -> task.missing names the task, a reason, and the new project when moved
refuse task.missing moved without project -> task.missing names the task, a reason, and the new project when moved
ok task.missing not-found
ok task.missing moved to project 9
ok digest.sent
refuse launch.revoked by pm run -> only the human revokes or restores launching
ok launch.revoked by human
view launch_state -> [{"business":"mosaic-stack","state":"revoked"}]
ok launch.restored by human
view launch_state -> [{"business":"mosaic-stack","state":"allowed"}]
-- task_snapshots
refuse self without role and run -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL)
refuse poll with a role -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL)
refuse poll without via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
refuse poll without read_at -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
refuse self with via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
refuse unknown via webhook -> CHECK constraint failed: via IN ('board','cursor','task','reconcile')
refuse fields without bucket -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
refuse bucket as text -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
refuse gone on a board read -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
refuse gone on self -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
refuse bad task_ref -> CHECK constraint failed: task_ref GLOB 'vikunja:[0-9]*/[0-9]*'
refuse bad digest -> CHECK constraint failed: length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'
ok self X by coder, response :02
ok cursor X sent :04 (unchanged)
view external after cursor X -> []
ok cursor Y sent :06, same second (person edit)
view external after cursor Y -> [{"task_ref":"vikunja:3/41","via":"cursor"}]
ok self Z by coder (move to in-review), response :10
ok stale board read sent :09 shows Y
view external after self Z, stale board read -> []
ok stale cursor read, updated 11:59:00
view external after stale cursor read -> []
ok board read sent :30 agrees with Z
view external after board agrees -> []
ok person moves to blocked, updated unchanged, board sent :40
view external after person's move -> [{"task_ref":"vikunja:3/41","via":"board"}]
ok board read on vikunja:3/42 with no self row
ok cursor read on vikunja:3/44, done
view tasks_open -> [{"task_ref":"vikunja:3/41","bucket":14},{"task_ref":"vikunja:3/42","bucket":11}]
ok tombstone for vikunja:3/42 (GET 404)
view tasks_open after tombstone -> [{"task_ref":"vikunja:3/41","bucket":14}]
view external, all -> [{"task_ref":"vikunja:3/41","via":"board"},{"task_ref":"vikunja:3/42","via":"task"},{"task_ref":"vikunja:3/44","via":"cursor"}]
-- append-only on every table
refuse meta UPDATE -> meta is append-only
refuse meta DELETE -> meta is append-only
refuse meta INSERT OR REPLACE -> meta is append-only
refuse events UPDATE -> events is append-only
refuse events DELETE -> events is append-only
refuse events INSERT OR REPLACE -> events is append-only
refuse role_claims UPDATE -> role_claims is append-only
refuse role_claims DELETE -> role_claims is append-only
refuse role_claims INSERT OR REPLACE -> role_claims is append-only
refuse decisions UPDATE -> decisions is append-only
refuse decisions DELETE -> decisions is append-only
refuse decisions INSERT OR REPLACE -> decisions is append-only
refuse decision_events UPDATE -> decision_events is append-only
refuse decision_events DELETE -> decision_events is append-only
refuse decision_events INSERT OR REPLACE -> decision_events is append-only
refuse messages UPDATE -> messages is append-only
refuse messages DELETE -> messages is append-only
refuse messages INSERT OR REPLACE -> messages is append-only
refuse deliveries UPDATE -> deliveries is append-only
refuse deliveries DELETE -> deliveries is append-only
refuse deliveries INSERT OR REPLACE -> deliveries is append-only
refuse task_snapshots UPDATE -> task_snapshots is append-only
refuse task_snapshots DELETE -> task_snapshots is append-only
refuse task_snapshots INSERT OR REPLACE -> task_snapshots is append-only
-- tamper: drop a guard, reopen
check on reopen -> match
check after DROP TRIGGER -> MISMATCH
node 24.21.0 | sqlite 3.53.4 | journal: wal | tables: 8 | triggers: 33 | views: 4
@@ -0,0 +1,93 @@
-- open-time schema check
check after create -> match
-- decisions.blocking
refuse raise without blocking -> NOT NULL constraint failed: decisions.blocking
refuse raise with blocking 2 -> CHECK constraint failed: blocking IN (0,1)
refuse raise blocking without task_ref -> a blocking decision cites the task it blocks
ok raise blocking gated with task_ref
ok raise non-blocking gated
ok raise blocking cross-role
view urgent_inbox -> [{"id":"d-3","task_ref":"vikunja:3/41"}]
ok resolve d-3 with A
view urgent_inbox after resolve -> []
-- events: closed kinds and the new kinds
refuse unknown kind task.deleted -> CHECK constraint failed: kind IN ( 'session.launched', 'session.ended', 'action.allowed',
ok credential.expiring vikunja coder
ok credential.expired vikunja coder
ok credential.changed gitea pm
refuse credential.changed without instance -> credential events name a service and a role instance
refuse credential.expiring service github -> credential events name a service and a role instance
refuse task.missing without reason -> task.missing names the task, a reason, and the new project when moved
refuse task.missing reason deleted -> task.missing names the task, a reason, and the new project when moved
refuse task.missing without subject -> task.missing names the task, a reason, and the new project when moved
refuse task.missing moved without project -> task.missing names the task, a reason, and the new project when moved
ok task.missing not-found
ok task.missing moved to project 9
ok digest.sent
refuse launch.revoked by pm run -> only the human revokes or restores launching
ok launch.revoked by human
view launch_state -> [{"business":"mosaic-stack","state":"revoked"}]
ok launch.restored by human
view launch_state -> [{"business":"mosaic-stack","state":"allowed"}]
-- task_snapshots
refuse self without role and run -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL)
refuse poll with a role -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL)
refuse poll without via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
refuse poll without read_at -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
refuse self with via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
refuse unknown via webhook -> CHECK constraint failed: via IN ('board','cursor','task','reconcile')
refuse fields without bucket -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
refuse bucket as text -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
refuse gone on a board read -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
refuse gone on self -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
refuse bad task_ref -> CHECK constraint failed: task_ref GLOB 'vikunja:[0-9]*/[0-9]*'
refuse bad digest -> CHECK constraint failed: length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'
ok self X by coder, response :02
ok cursor X sent :04 (unchanged)
view external after cursor X -> []
ok cursor Y sent :06, same second (person edit)
view external after cursor Y -> [{"task_ref":"vikunja:3/41","via":"cursor"}]
ok self Z by coder (move to in-review), response :10
ok stale board read sent :09 shows Y
view external after self Z, stale board read -> []
ok stale cursor read, updated 11:59:00
view external after stale cursor read -> []
ok board read sent :30 agrees with Z
view external after board agrees -> []
ok person moves to blocked, updated unchanged, board sent :40
view external after person's move -> [{"task_ref":"vikunja:3/41","via":"board"}]
ok board read on vikunja:3/42 with no self row
ok cursor read on vikunja:3/44, done
view tasks_open -> [{"task_ref":"vikunja:3/41","bucket":14},{"task_ref":"vikunja:3/42","bucket":11}]
ok tombstone for vikunja:3/42 (GET 404)
view tasks_open after tombstone -> [{"task_ref":"vikunja:3/41","bucket":14}]
view external, all -> [{"task_ref":"vikunja:3/41","via":"board"},{"task_ref":"vikunja:3/42","via":"task"},{"task_ref":"vikunja:3/44","via":"cursor"}]
-- append-only on every table
refuse meta UPDATE -> meta is append-only
refuse meta DELETE -> meta is append-only
refuse meta INSERT OR REPLACE -> meta is append-only
refuse events UPDATE -> events is append-only
refuse events DELETE -> events is append-only
refuse events INSERT OR REPLACE -> events is append-only
refuse role_claims UPDATE -> role_claims is append-only
refuse role_claims DELETE -> role_claims is append-only
refuse role_claims INSERT OR REPLACE -> role_claims is append-only
refuse decisions UPDATE -> decisions is append-only
refuse decisions DELETE -> decisions is append-only
refuse decisions INSERT OR REPLACE -> decisions is append-only
refuse decision_events UPDATE -> decision_events is append-only
refuse decision_events DELETE -> decision_events is append-only
refuse decision_events INSERT OR REPLACE -> decision_events is append-only
refuse messages UPDATE -> messages is append-only
refuse messages DELETE -> messages is append-only
refuse messages INSERT OR REPLACE -> messages is append-only
refuse deliveries UPDATE -> deliveries is append-only
refuse deliveries DELETE -> deliveries is append-only
refuse deliveries INSERT OR REPLACE -> deliveries is append-only
refuse task_snapshots UPDATE -> task_snapshots is append-only
refuse task_snapshots DELETE -> task_snapshots is append-only
refuse task_snapshots INSERT OR REPLACE -> task_snapshots is append-only
-- tamper: drop a guard, reopen
check on reopen -> match
check after DROP TRIGGER -> MISMATCH
node 26.8.1 | sqlite 3.53.4 | journal: wal | tables: 8 | triggers: 33 | views: 4
@@ -0,0 +1,75 @@
# Slice 1 prototype, v3 (addendum B section 5, lead decision 52)
Darkwing, 2026-10-04, for the slice 1 brief (`docs/plans/2026-10-04_slice-1.md`,
row S2 starts from this schema). The v1 and v2 files are unchanged.
`schema-v3.sql` is a full schema that stands on its own and replaces v2.
It isn't a migration.
What changed from `schema-v2.sql`. `diff schema-v2.sql schema-v3.sql`
shows all of it:
- `task_snapshots.via`: which read produced a poll snapshot, one of
`board` (the open-task kanban listing), `cursor` (the `updated`
cursor), `task` (a single `GET` after a task left the open set) or
`reconcile`.
- `task_snapshots.read_at`: when the broker sent that read. A poll row
needs both `via` and `read_at`, and a `self` row has neither. For a
`self` row, `at` is when the write's response arrived.
- Every snapshot carries an integer `fields.bucket`. The one exception is
a tombstone, a poll row with `via` `task` and a text `fields.gone`. The
CHECK uses `IS`, not `=`: a missing JSON path makes `json_type` NULL,
and SQLite passes a CHECK that evaluates to NULL. Addendum B section 5
records how I found that.
- `task_external_changes` adds `p.read_at > ls.at`. A move between
columns that aren't done doesn't change `updated` (probe P3), so
`updated` alone can't order a board read against the broker's own move.
The view also returns `via`.
- New view `tasks_open`: tasks whose latest snapshot is open and not a
tombstone. The poll compares the board read with it.
- New trigger `events_task_missing_body`. A `task.missing` event names its
task in `subject` and carries `reason` `moved`, `not-found` or
`no-access`; `moved` also carries the new `project` as an integer.
Addendum B section 5 gave the body. The trigger enforces it, the same
way v2 enforces the `credential.*` body. It is the one addition beyond
section 5's text.
`proto-v3.mjs` is `proto-v2.mjs` with two sections changed. The
`task.missing` cases now include four refusals, and the `task_snapshots`
section was rewritten for the new columns. Apart from the header comment,
the temp directory prefix and the schema file name, the rest is
unchanged.
Results: `proto-v3-node24.txt` (Node 24.21.0 in the `node:24` image, no
network, the directory mounted read-only) and `proto-v3-node26.txt`
(Node 26.8.1 on the host). Both use SQLite 3.53.4, and the outputs differ
only in the version line. Every refusal the script expects happens:
- a poll row without `via` or `read_at`, a `self` row with `via`, an
unknown `via`;
- a snapshot with no bucket or a text bucket, a `gone` on a board read or
on a `self` row;
- a `task.missing` with no reason, an unknown reason, no subject, or
`moved` without a project.
The views, in order:
- an unchanged cursor read isn't external;
- a person's edit in the same second as a write is external, `via` `cursor`;
- a board read sent before the broker's move finished isn't external;
- a stale cursor read isn't external;
- a board read that agrees with the move isn't external;
- a person's move with `updated` unchanged is external, `via` `board`;
- a tombstone drops the task from `tasks_open`.
UPDATE, DELETE and INSERT OR REPLACE are refused on all eight tables.
Dropping one guard and reopening gives `MISMATCH`.
Mutant: the same script against `schema-v3.sql` without
`AND p.read_at > ls.at` reports the stale board read as an external
change (`[{"task_ref":"vikunja:3/41"}]`). Run in
`~/darkwing-scratch/v3mut`, output in `mutant.txt` there.
Limits, the same as v1 and v2. The triggers catch our own bugs. A process
running as the same user can still drop a trigger, and the digest check
only notices that afterwards. The views are demonstrations. The broker
runs its own queries, and the views show the rules in SQL. The digest's
field list and the rule to write a poll row only when the digest changed
are broker behaviour (addendum B section 5), so the schema can't check
them.
@@ -0,0 +1,119 @@
// 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"));
@@ -0,0 +1,192 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT, requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[0-9]*/[0-9]*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
NEW.subject IS NULL OR NOT NEW.subject GLOB 'vikunja:[0-9]*/[0-9]*'
OR json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing names the task, a reason, and the new project when moved'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_snapshots s
WHERE s.seq = (SELECT max(seq) FROM task_snapshots WHERE business = s.business AND task_ref = s.task_ref)
AND json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));