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:
@@ -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'));
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user