From f2b8c92a84a9016cca911f23f3d0f3ac9c1a11e8 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 4 Oct 2026 14:59:33 -0500 Subject: [PATCH] docs(plans): slice 1 prototype v2 records; lead decision 50 Darkwing's schema-v2 adds task_snapshots, decisions.blocking and a closed events.kind list. Sage reran proto-v2 on Node 26 with matching output. Decision 50 accepts the five choices beyond addendum A. Co-Authored-By: Claude Opus 5.5 --- .../work/slice1-proto/proto-v2-node24.txt | 70 +++++++ .../work/slice1-proto/proto-v2-node26.txt | 70 +++++++ .../work/slice1-proto/proto-v2-notes.md | 50 +++++ .../darkwing/work/slice1-proto/proto-v2.mjs | 91 +++++++++ .../darkwing/work/slice1-proto/schema-v2.sql | 175 ++++++++++++++++++ docs/SESSIONS.md | 1 + docs/plans/2026-09-26_lead-decisions.md | 21 +++ 7 files changed, 478 insertions(+) create mode 100644 agents/darkwing/work/slice1-proto/proto-v2-node24.txt create mode 100644 agents/darkwing/work/slice1-proto/proto-v2-node26.txt create mode 100644 agents/darkwing/work/slice1-proto/proto-v2-notes.md create mode 100644 agents/darkwing/work/slice1-proto/proto-v2.mjs create mode 100644 agents/darkwing/work/slice1-proto/schema-v2.sql diff --git a/agents/darkwing/work/slice1-proto/proto-v2-node24.txt b/agents/darkwing/work/slice1-proto/proto-v2-node24.txt new file mode 100644 index 00000000..406b1106 --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v2-node24.txt @@ -0,0 +1,70 @@ +-- 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 +ok task.missing +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 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, updated 12:00:00 +ok poll X (unchanged) +view external after poll X -> [] +ok poll Y, same second (person edit) +view external after poll Y -> [{"task_ref":"vikunja:3/41","seq":3}] +ok self Z by coder, updated 12:00:05 +ok stale poll W, updated 11:59:00 +view external after self Z, stale poll W -> [] +ok poll on vikunja:3/42 with no self row +view external, task 42 -> [{"task_ref":"vikunja:3/42"}] +-- 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: 32 | views: 3 diff --git a/agents/darkwing/work/slice1-proto/proto-v2-node26.txt b/agents/darkwing/work/slice1-proto/proto-v2-node26.txt new file mode 100644 index 00000000..9fd2c405 --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v2-node26.txt @@ -0,0 +1,70 @@ +-- 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 +ok task.missing +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 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, updated 12:00:00 +ok poll X (unchanged) +view external after poll X -> [] +ok poll Y, same second (person edit) +view external after poll Y -> [{"task_ref":"vikunja:3/41","seq":3}] +ok self Z by coder, updated 12:00:05 +ok stale poll W, updated 11:59:00 +view external after self Z, stale poll W -> [] +ok poll on vikunja:3/42 with no self row +view external, task 42 -> [{"task_ref":"vikunja:3/42"}] +-- 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: 32 | views: 3 diff --git a/agents/darkwing/work/slice1-proto/proto-v2-notes.md b/agents/darkwing/work/slice1-proto/proto-v2-notes.md new file mode 100644 index 00000000..e351ee8b --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v2-notes.md @@ -0,0 +1,50 @@ +# Slice 1 prototype, v2 (addendum A, item A3) + +Darkwing, 2026-10-04, for lead decision 49. Design work, uncommitted. The +v1 files (`schema.sql`, `proto.mjs`, `replace.mjs` and their outputs) are +unchanged. `schema-v2.sql` is a full schema that stands on its own, and +it replaces v1. It isn't a migration. + +What changed from `schema.sql`: +- `decisions.blocking` is `INTEGER NOT NULL CHECK (blocking IN (0,1))` + with no default, so whoever raises a decision has to choose a value. A + trigger refuses `blocking = 1` without a `task_ref`. Addendum A section + 8 said a blocking decision "should" cite its task. The prototype + enforces it. +- `task_snapshots` is new. Its columns follow addendum A section 5. A + `self` row needs a role and a run, and a `poll` row has neither. The + task ref must match `vikunja:/`, and the digest must be + 64 lowercase hex characters. It has the same three guards as every + other table (UPDATE, DELETE, and an existing `seq` on INSERT). +- `events.kind` is now a closed list in a CHECK constraint. It holds + every kind from note section 4 that has no table of its own, plus the + seven from addendum A. Adding a kind is a schema change, and the + open-time digest notices one made outside review. +- Two body rules on events. `launch.revoked` and `launch.restored` must + carry no role and no run, because only the human writes them (REQ- + LAUNCH-1). A `credential.*` event must name a service (`gitea` or + `vikunja`) and a role instance. +- Three views: + - `urgent_inbox` lists open gated decisions with `blocking = 1`. These + go out at once under REQ-DEC-4. + - `launch_state` gives the latest revoke or restore per business. + - `task_external_changes` lists tasks whose newest snapshot is a poll + that differs from the broker's last write, with an `updated` no older + than that write. A stale poll that started before the write doesn't + count. A person's edit in the same second as a write does. +- The open-time check now hashes every schema object (tables, indexes, + triggers and views), not only triggers. A CHECK list lives in a + table's SQL, so a trigger-only digest would miss a widened kind list. + +Results: `proto-v2-node24.txt` (Node 24.21.0 in the `node:24` image) and +`proto-v2-node26.txt` (Node 26.8.1 on the host). Both use SQLite 3.53.4, +and the two outputs differ only in the version line. Every refusal the +script expects happens. UPDATE, DELETE and INSERT OR REPLACE are refused +on all eight tables. Dropping one guard and reopening the file gives +`MISMATCH`. + +Limits, the same as v1. 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 +will run its own queries, and the views exist so a reviewer can see the +rules in SQL. diff --git a/agents/darkwing/work/slice1-proto/proto-v2.mjs b/agents/darkwing/work/slice1-proto/proto-v2.mjs new file mode 100644 index 00000000..21f4f5e4 --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v2.mjs @@ -0,0 +1,91 @@ +// Slice 1 prototype, v2 schema (addendum A, item A3). Same pattern as proto.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(), "s1v2-")), "bus.sqlite"); +let db = new DatabaseSync(f, { timeout: 5000 }); +db.exec(readFileSync(new URL("./schema-v2.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", () => e("task.missing", null, null, { reconcile: "r-1" }, "vikunja:3/40")); +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,role,run) VALUES (?,?,?,?,?,?,?,?,?,?)"); +const s = (ref, updated, fields, source, role = null, run = null) => snap.run(now(), "mosaic-stack", ref, updated, `"${hex(JSON.stringify(fields)).slice(0, 8)}"`, hex(JSON.stringify(fields)), JSON.stringify(fields), source, role, run); +const X = { title: "Add broker push", bucket: "in-progress" }, Y = { ...X, title: "Add broker push (Jason edit)" }, Z = { ...Y, bucket: "in-review" }, W = { title: "Add broker push", bucket: "todo" }; +tryit("self without role and run", () => s("vikunja:3/41", "2026-10-04T12:00:00Z", X, "self")); +tryit("poll with a role", () => s("vikunja:3/41", "2026-10-04T12:00:00Z", X, "poll", "pm", "run-P")); +tryit("bad task_ref", () => s("PROJ-41", "2026-10-04T12:00:00Z", X, "poll")); +tryit("bad digest", () => snap.run(now(), "mosaic-stack", "vikunja:3/41", "x", null, "abc", "{}", "poll", null, null)); +tryit("self X by coder, updated 12:00:00", () => s("vikunja:3/41", "2026-10-04T12:00:00Z", X, "self", "coder", "run-C")); +tryit("poll X (unchanged)", () => s("vikunja:3/41", "2026-10-04T12:00:00Z", X, "poll")); +show("external after poll X", "SELECT task_ref FROM task_external_changes"); +tryit("poll Y, same second (person edit)", () => s("vikunja:3/41", "2026-10-04T12:00:00Z", Y, "poll")); +show("external after poll Y", "SELECT task_ref, seq FROM task_external_changes"); +tryit("self Z by coder, updated 12:00:05", () => s("vikunja:3/41", "2026-10-04T12:00:05Z", Z, "self", "coder", "run-C")); +tryit("stale poll W, updated 11:59:00", () => s("vikunja:3/41", "2026-10-04T11:59:00Z", W, "poll")); +show("external after self Z, stale poll W", "SELECT task_ref FROM task_external_changes"); +tryit("poll on vikunja:3/42 with no self row", () => s("vikunja:3/42", "2026-10-04T12:01:00Z", W, "poll")); +show("external, task 42", "SELECT task_ref FROM task_external_changes"); + +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-v2.sql b/agents/darkwing/work/slice1-proto/schema-v2.sql new file mode 100644 index 00000000..b459c91d --- /dev/null +++ b/agents/darkwing/work/slice1-proto/schema-v2.sql @@ -0,0 +1,175 @@ +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')), + role TEXT, run TEXT, + CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)) +) 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 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.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.digest <> ls.digest)); +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')); diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index dcbb8e96..84f213f4 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -473,3 +473,4 @@ are never rewritten or removed; corrections are new entries. 2026-10-04T19:26:50Z | Sage (T3 Claude Code, thread 1ef1e4f8) | meta-harness survey received | Filbert's survey (05f83807) committed as a record; lead decision 47 rules on its section 8; two DEFERRED items (host-seat --approve, worker provider-key exposure) 2026-10-04T19:48:15Z | Sage (T3 Claude Code, thread 1ef1e4f8) | PRDY round 2, PRD 0.2 | lead decision 48; PRD draft 0.2 with requirement ids; Researcher's Vikunja and Pocket ID report (fcfc970f) committed as a record (Researcher wrote no SESSIONS line, per its limits) 2026-10-04T19:56:14Z | Sage (T3 Claude Code, thread 1ef1e4f8) | slice 1 addendum A | Darkwing's addendum (0b36acb0) committed as a record; lead decision 49; PRD draft 0.3; assigned Researcher Vikunja probes P1-P7 on a scratch container and Darkwing the task_snapshots prototype extension +2026-10-04T19:59:24Z | Sage (T3 Claude Code, thread 1ef1e4f8) | slice 1 prototype v2 | Darkwing's schema-v2, proto-v2 and outputs committed as records after a Sage rerun on Node 26; lead decision 50 accepts the five choices beyond addendum A; Researcher probes P1-P7 still running diff --git a/docs/plans/2026-09-26_lead-decisions.md b/docs/plans/2026-09-26_lead-decisions.md index f0a62dbf..398b90ea 100644 --- a/docs/plans/2026-09-26_lead-decisions.md +++ b/docs/plans/2026-09-26_lead-decisions.md @@ -803,3 +803,24 @@ which stay with him. Each item names who decided it and what happened. in the slice 1 brief (REQ-CLI-2). - PRD wording for REQ-VAR-2 and REQ-TASK-1 adopted in draft 0.3, with REQ-TASK-2 updated for A2. +50. **Sage's rulings on the slice 1 prototype v2 (2026-10-04).** Source: + Darkwing (CTO), `agents/darkwing/work/slice1-proto/proto-v2-notes.md` + (sha256 90ce5645…), `schema-v2.sql` (ffb7cf90…) and `proto-v2.mjs` + (d428da74…). Sage reran `proto-v2.mjs` on Node 26.8.1 and got the + same output as Darkwing's Node 26 run, apart from version lines. + `schema-v2.sql` replaces `schema.sql` as the design the broker + starts from. + - A blocking decision must cite a task (trigger): accepted. Slice 1 + has no blocking work outside a task. If one turns up, the broker + files a task first. + - `launch.revoked` and `launch.restored` carry no role and no run: + accepted. An empty role is not proof of a human. The broker must + write these only from the CLI path that runs outside any agent run + (REQ-DEC-3). The slice 1 brief states that. + - `credential.*` events name a service and a role instance: accepted. + - The open-time digest covers every schema object: accepted. This + check notices tampering, it doesn't prevent it, the same limit as + the triggers. + - The three views: accepted as demonstrations. Counting a person's + edit in the same second as a broker write as external is the + cautious side, and stays.