From 7ed831781b32513ac5b38e4e0b04a31b1ddeb5d8 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 4 Oct 2026 21:55:17 -0500 Subject: [PATCH] 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 --- .../work/slice1-proto/proto-v3-node24.txt | 93 +++++++++ .../work/slice1-proto/proto-v3-node26.txt | 93 +++++++++ .../work/slice1-proto/proto-v3-notes.md | 75 +++++++ .../darkwing/work/slice1-proto/proto-v3.mjs | 119 +++++++++++ .../darkwing/work/slice1-proto/schema-v3.sql | 192 ++++++++++++++++++ 5 files changed, 572 insertions(+) create mode 100644 agents/darkwing/work/slice1-proto/proto-v3-node24.txt create mode 100644 agents/darkwing/work/slice1-proto/proto-v3-node26.txt create mode 100644 agents/darkwing/work/slice1-proto/proto-v3-notes.md create mode 100644 agents/darkwing/work/slice1-proto/proto-v3.mjs create mode 100644 agents/darkwing/work/slice1-proto/schema-v3.sql diff --git a/agents/darkwing/work/slice1-proto/proto-v3-node24.txt b/agents/darkwing/work/slice1-proto/proto-v3-node24.txt new file mode 100644 index 00000000..eba9700f --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v3-node24.txt @@ -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 diff --git a/agents/darkwing/work/slice1-proto/proto-v3-node26.txt b/agents/darkwing/work/slice1-proto/proto-v3-node26.txt new file mode 100644 index 00000000..21d8f556 --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v3-node26.txt @@ -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 diff --git a/agents/darkwing/work/slice1-proto/proto-v3-notes.md b/agents/darkwing/work/slice1-proto/proto-v3-notes.md new file mode 100644 index 00000000..263bec6d --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v3-notes.md @@ -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. diff --git a/agents/darkwing/work/slice1-proto/proto-v3.mjs b/agents/darkwing/work/slice1-proto/proto-v3.mjs new file mode 100644 index 00000000..95c8cafb --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v3.mjs @@ -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")); diff --git a/agents/darkwing/work/slice1-proto/schema-v3.sql b/agents/darkwing/work/slice1-proto/schema-v3.sql new file mode 100644 index 00000000..f27a5fb8 --- /dev/null +++ b/agents/darkwing/work/slice1-proto/schema-v3.sql @@ -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'));