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 <[email protected]>
This commit is contained in:
2026-10-04 14:59:33 -05:00
co-authored by Claude Opus 5.5
parent d7723e2237
commit f2b8c92a84
7 changed files with 478 additions and 0 deletions
@@ -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
@@ -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
@@ -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:<project>/<task>`, 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.
@@ -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"));
@@ -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'));
+1
View File
@@ -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
+21
View File
@@ -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.