From 29daa4821666158c6a272aadc8d1e17bfc513c9d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 4 Oct 2026 22:02:31 -0500 Subject: [PATCH] docs(slice1): schema v3a, task event rules (lead decision 56, Q3) Two triggers: every task.* event names its task in subject, and task.created cites a human.input event and a requirement id. The task ref pattern is tightened on snapshots, decisions and subjects. Runs on Node 24.21.0 and 26.8.1 match apart from the version line. Co-Authored-By: Claude Opus 5.5 --- .../work/slice1-proto/proto-v3a-node24.txt | 117 ++++++++++ .../work/slice1-proto/proto-v3a-node26.txt | 117 ++++++++++ .../work/slice1-proto/proto-v3a-notes.md | 73 +++++++ .../darkwing/work/slice1-proto/proto-v3a.mjs | 144 +++++++++++++ .../darkwing/work/slice1-proto/schema-v3a.sql | 203 ++++++++++++++++++ 5 files changed, 654 insertions(+) create mode 100644 agents/darkwing/work/slice1-proto/proto-v3a-node24.txt create mode 100644 agents/darkwing/work/slice1-proto/proto-v3a-node26.txt create mode 100644 agents/darkwing/work/slice1-proto/proto-v3a-notes.md create mode 100644 agents/darkwing/work/slice1-proto/proto-v3a.mjs create mode 100644 agents/darkwing/work/slice1-proto/schema-v3a.sql diff --git a/agents/darkwing/work/slice1-proto/proto-v3a-node24.txt b/agents/darkwing/work/slice1-proto/proto-v3a-node24.txt new file mode 100644 index 00000000..f34c05ff --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v3a-node24.txt @@ -0,0 +1,117 @@ +-- 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 -> a task event names its task in subject +refuse unknown kind task.deleted with a subject -> CHECK constraint failed: kind IN ( 'session.launched', 'session.ended', 'action.allowed', +ok credential.expiring vikunja coder +ok credential.expired vikunja coder +ok credential.changed gitea pm +refuse credential.changed without instance -> credential events name a service and a role instance +refuse credential.expiring service github -> credential events name a service and a role instance +refuse task.missing without reason -> task.missing carries a reason, and the new project when moved +refuse task.missing reason deleted -> task.missing carries a reason, and the new project when moved +refuse task.missing without subject -> a task event names its task in subject +refuse task.missing moved without project -> task.missing carries a reason, and the new project when moved +ok task.missing not-found +ok task.missing moved to project 9 +-- task events name their task (lead decision 56, Q3) +refuse task.state without subject -> a task event names its task in subject +refuse task.state subject PROJ-41 -> a task event names its task in subject +refuse task.state subject vikunja:3/41x -> a task event names its task in subject +refuse task.state subject vikunja:03/41 -> a task event names its task in subject +refuse task.state subject vikunja:3/4/1 -> a task event names its task in subject +ok task.state subject vikunja:3/41 +ok human.input from the cli +ok human.input in another business +refuse task.created without request -> task.created cites the human.input that asked for it and a requirement id +refuse task.created request names an unknown event -> task.created cites the human.input that asked for it and a requirement id +refuse task.created request names a credential event -> task.created cites the human.input that asked for it and a requirement id +refuse task.created request from another business -> task.created cites the human.input that asked for it and a requirement id +refuse task.created request as a number -> task.created cites the human.input that asked for it and a requirement id +refuse task.created without requirement -> task.created cites the human.input that asked for it and a requirement id +refuse task.created requirement REQ-task-1 -> task.created cites the human.input that asked for it and a requirement id +refuse task.created requirement REQ-TASK-0 -> task.created cites the human.input that asked for it and a requirement id +refuse task.created without subject -> a task event names its task in subject +ok task.created cites h-1 and REQ-TASK-1 +refuse decision with task_ref vikunja:3/41x -> CHECK constraint failed: task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NO +view e-3 is -> [{"kind":"credential.expiring"}] +view trail from h-1 -> [{"kind":"task.created","subject":"vikunja:3/50"}] +ok digest.sent +refuse launch.revoked by pm run -> only the human revokes or restores launching +ok launch.revoked by human +view launch_state -> [{"business":"mosaic-stack","state":"revoked"}] +ok launch.restored by human +view launch_state -> [{"business":"mosaic-stack","state":"allowed"}] +-- task_snapshots +refuse self without role and run -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL) +refuse poll with a role -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL) +refuse poll without via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL) +refuse poll without read_at -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL) +refuse self with via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL) +refuse unknown via webhook -> CHECK constraint failed: via IN ('board','cursor','task','reconcile') +refuse fields without bucket -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A +refuse bucket as text -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A +refuse gone on a board read -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A +refuse gone on self -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A +refuse bad task_ref -> CHECK constraint failed: task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) +refuse task_ref vikunja:3/41x -> CHECK constraint failed: task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) +refuse bad digest -> CHECK constraint failed: length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*' +ok self X by coder, response :02 +ok cursor X sent :04 (unchanged) +view external after cursor X -> [] +ok cursor Y sent :06, same second (person edit) +view external after cursor Y -> [{"task_ref":"vikunja:3/41","via":"cursor"}] +ok self Z by coder (move to in-review), response :10 +ok stale board read sent :09 shows Y +view external after self Z, stale board read -> [] +ok stale cursor read, updated 11:59:00 +view external after stale cursor read -> [] +ok board read sent :30 agrees with Z +view external after board agrees -> [] +ok person moves to blocked, updated unchanged, board sent :40 +view external after person's move -> [{"task_ref":"vikunja:3/41","via":"board"}] +ok board read on vikunja:3/42 with no self row +ok cursor read on vikunja:3/44, done +view tasks_open -> [{"task_ref":"vikunja:3/41","bucket":14},{"task_ref":"vikunja:3/42","bucket":11}] +ok tombstone for vikunja:3/42 (GET 404) +view tasks_open after tombstone -> [{"task_ref":"vikunja:3/41","bucket":14}] +view external, all -> [{"task_ref":"vikunja:3/41","via":"board"},{"task_ref":"vikunja:3/42","via":"task"},{"task_ref":"vikunja:3/44","via":"cursor"}] +-- append-only on every table +refuse meta UPDATE -> meta is append-only +refuse meta DELETE -> meta is append-only +refuse meta INSERT OR REPLACE -> meta is append-only +refuse events UPDATE -> events is append-only +refuse events DELETE -> events is append-only +refuse events INSERT OR REPLACE -> events is append-only +refuse role_claims UPDATE -> role_claims is append-only +refuse role_claims DELETE -> role_claims is append-only +refuse role_claims INSERT OR REPLACE -> role_claims is append-only +refuse decisions UPDATE -> decisions is append-only +refuse decisions DELETE -> decisions is append-only +refuse decisions INSERT OR REPLACE -> decisions is append-only +refuse decision_events UPDATE -> decision_events is append-only +refuse decision_events DELETE -> decision_events is append-only +refuse decision_events INSERT OR REPLACE -> decision_events is append-only +refuse messages UPDATE -> messages is append-only +refuse messages DELETE -> messages is append-only +refuse messages INSERT OR REPLACE -> messages is append-only +refuse deliveries UPDATE -> deliveries is append-only +refuse deliveries DELETE -> deliveries is append-only +refuse deliveries INSERT OR REPLACE -> deliveries is append-only +refuse task_snapshots UPDATE -> task_snapshots is append-only +refuse task_snapshots DELETE -> task_snapshots is append-only +refuse task_snapshots INSERT OR REPLACE -> task_snapshots is append-only +-- tamper: drop a guard, reopen +check on reopen -> match +check after DROP TRIGGER -> MISMATCH +node 24.21.0 | sqlite 3.53.4 | journal: wal | tables: 8 | triggers: 35 | views: 4 diff --git a/agents/darkwing/work/slice1-proto/proto-v3a-node26.txt b/agents/darkwing/work/slice1-proto/proto-v3a-node26.txt new file mode 100644 index 00000000..c9607524 --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v3a-node26.txt @@ -0,0 +1,117 @@ +-- 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 -> a task event names its task in subject +refuse unknown kind task.deleted with a subject -> CHECK constraint failed: kind IN ( 'session.launched', 'session.ended', 'action.allowed', +ok credential.expiring vikunja coder +ok credential.expired vikunja coder +ok credential.changed gitea pm +refuse credential.changed without instance -> credential events name a service and a role instance +refuse credential.expiring service github -> credential events name a service and a role instance +refuse task.missing without reason -> task.missing carries a reason, and the new project when moved +refuse task.missing reason deleted -> task.missing carries a reason, and the new project when moved +refuse task.missing without subject -> a task event names its task in subject +refuse task.missing moved without project -> task.missing carries a reason, and the new project when moved +ok task.missing not-found +ok task.missing moved to project 9 +-- task events name their task (lead decision 56, Q3) +refuse task.state without subject -> a task event names its task in subject +refuse task.state subject PROJ-41 -> a task event names its task in subject +refuse task.state subject vikunja:3/41x -> a task event names its task in subject +refuse task.state subject vikunja:03/41 -> a task event names its task in subject +refuse task.state subject vikunja:3/4/1 -> a task event names its task in subject +ok task.state subject vikunja:3/41 +ok human.input from the cli +ok human.input in another business +refuse task.created without request -> task.created cites the human.input that asked for it and a requirement id +refuse task.created request names an unknown event -> task.created cites the human.input that asked for it and a requirement id +refuse task.created request names a credential event -> task.created cites the human.input that asked for it and a requirement id +refuse task.created request from another business -> task.created cites the human.input that asked for it and a requirement id +refuse task.created request as a number -> task.created cites the human.input that asked for it and a requirement id +refuse task.created without requirement -> task.created cites the human.input that asked for it and a requirement id +refuse task.created requirement REQ-task-1 -> task.created cites the human.input that asked for it and a requirement id +refuse task.created requirement REQ-TASK-0 -> task.created cites the human.input that asked for it and a requirement id +refuse task.created without subject -> a task event names its task in subject +ok task.created cites h-1 and REQ-TASK-1 +refuse decision with task_ref vikunja:3/41x -> CHECK constraint failed: task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NO +view e-3 is -> [{"kind":"credential.expiring"}] +view trail from h-1 -> [{"kind":"task.created","subject":"vikunja:3/50"}] +ok digest.sent +refuse launch.revoked by pm run -> only the human revokes or restores launching +ok launch.revoked by human +view launch_state -> [{"business":"mosaic-stack","state":"revoked"}] +ok launch.restored by human +view launch_state -> [{"business":"mosaic-stack","state":"allowed"}] +-- task_snapshots +refuse self without role and run -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL) +refuse poll with a role -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL) +refuse poll without via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL) +refuse poll without read_at -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL) +refuse self with via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL) +refuse unknown via webhook -> CHECK constraint failed: via IN ('board','cursor','task','reconcile') +refuse fields without bucket -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A +refuse bucket as text -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A +refuse gone on a board read -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A +refuse gone on self -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A +refuse bad task_ref -> CHECK constraint failed: task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) +refuse task_ref vikunja:3/41x -> CHECK constraint failed: task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) +refuse bad digest -> CHECK constraint failed: length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*' +ok self X by coder, response :02 +ok cursor X sent :04 (unchanged) +view external after cursor X -> [] +ok cursor Y sent :06, same second (person edit) +view external after cursor Y -> [{"task_ref":"vikunja:3/41","via":"cursor"}] +ok self Z by coder (move to in-review), response :10 +ok stale board read sent :09 shows Y +view external after self Z, stale board read -> [] +ok stale cursor read, updated 11:59:00 +view external after stale cursor read -> [] +ok board read sent :30 agrees with Z +view external after board agrees -> [] +ok person moves to blocked, updated unchanged, board sent :40 +view external after person's move -> [{"task_ref":"vikunja:3/41","via":"board"}] +ok board read on vikunja:3/42 with no self row +ok cursor read on vikunja:3/44, done +view tasks_open -> [{"task_ref":"vikunja:3/41","bucket":14},{"task_ref":"vikunja:3/42","bucket":11}] +ok tombstone for vikunja:3/42 (GET 404) +view tasks_open after tombstone -> [{"task_ref":"vikunja:3/41","bucket":14}] +view external, all -> [{"task_ref":"vikunja:3/41","via":"board"},{"task_ref":"vikunja:3/42","via":"task"},{"task_ref":"vikunja:3/44","via":"cursor"}] +-- append-only on every table +refuse meta UPDATE -> meta is append-only +refuse meta DELETE -> meta is append-only +refuse meta INSERT OR REPLACE -> meta is append-only +refuse events UPDATE -> events is append-only +refuse events DELETE -> events is append-only +refuse events INSERT OR REPLACE -> events is append-only +refuse role_claims UPDATE -> role_claims is append-only +refuse role_claims DELETE -> role_claims is append-only +refuse role_claims INSERT OR REPLACE -> role_claims is append-only +refuse decisions UPDATE -> decisions is append-only +refuse decisions DELETE -> decisions is append-only +refuse decisions INSERT OR REPLACE -> decisions is append-only +refuse decision_events UPDATE -> decision_events is append-only +refuse decision_events DELETE -> decision_events is append-only +refuse decision_events INSERT OR REPLACE -> decision_events is append-only +refuse messages UPDATE -> messages is append-only +refuse messages DELETE -> messages is append-only +refuse messages INSERT OR REPLACE -> messages is append-only +refuse deliveries UPDATE -> deliveries is append-only +refuse deliveries DELETE -> deliveries is append-only +refuse deliveries INSERT OR REPLACE -> deliveries is append-only +refuse task_snapshots UPDATE -> task_snapshots is append-only +refuse task_snapshots DELETE -> task_snapshots is append-only +refuse task_snapshots INSERT OR REPLACE -> task_snapshots is append-only +-- tamper: drop a guard, reopen +check on reopen -> match +check after DROP TRIGGER -> MISMATCH +node 26.8.1 | sqlite 3.53.4 | journal: wal | tables: 8 | triggers: 35 | views: 4 diff --git a/agents/darkwing/work/slice1-proto/proto-v3a-notes.md b/agents/darkwing/work/slice1-proto/proto-v3a-notes.md new file mode 100644 index 00000000..292506df --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v3a-notes.md @@ -0,0 +1,73 @@ +# Slice 1 prototype, v3a (lead decision 56, Q3) + +Darkwing, 2026-10-04. Q3 asked whether a trigger or the broker enforces +two rules from Dewey's S5 questions. My answer is a trigger for both, +plus the broker setting the fields correctly in the first place. That's +how v2 and v3 already treat the `credential.*` and `task.missing` +bodies. Both rules depend only on the row being inserted and on rows +already in `events`, so SQL can check them. A broker bug that drops a +subject or the request id then fails at the insert. Without the trigger, +the bad row would only show up later as a gap in a trail. The limit is +the same as before: a process running as the same user can drop a +trigger, and the open-time digest only notices that afterwards. + +`schema-v3a.sql` is a full schema that replaces v3. It isn't a +migration. The v1, v2 and v3 files are unchanged. `diff schema-v3.sql +schema-v3a.sql` shows every change: +- New trigger `events_task_subject`. Every `task.*` event must carry a + task ref in `subject`. That covers `task.created`, `task.assigned`, + `task.state`, `task.closed`, `task.changed.external`, `task.conflict` + and `task.missing`. +- New trigger `events_task_created_body`. A `task.created` body carries + `request`, the id of a `human.input` event in the same business that + is already in `events`. It also carries `requirement`, a PRD id shaped + like `REQ-TASK-1`. Addendum A section 3 made the `task.created` event + the authoritative holder of the requirement id, so I check it in the + same trigger. Checking that the id exists in the PRD version the + business file names stays with the broker, because the schema can't + read the PRD. Sage can drop the requirement half if it's unwanted. +- The task ref pattern is now `vikunja:/`, with both ids + positive integers, no leading zero and exactly one slash. v3's + `GLOB 'vikunja:[0-9]*/[0-9]*'` let `vikunja:3/41x` through. The + pattern applies to `task_snapshots.task_ref`, to `decisions.task_ref` + when it isn't null (v3 didn't check that column at all), and to task + event subjects. +- `events_task_missing_body` no longer checks the subject, because + `events_task_subject` does. Its message changed to match. + +The broker enforces the rest of Q3's first rule. `action.allowed`, +`action.refused` and `review.*` events are sometimes about one task, but +the kind doesn't say when. A refused `task.create`, for example, has no +task yet. The broker sets `subject` on those whenever the action names a +task. + +`proto-v3a.mjs` is `proto-v3.mjs` with these changes: the header, the +temp directory prefix, the schema file name, a new "task events name +their task" section, a `task_ref vikunja:3/41x` snapshot case, a second +unknown-kind case with a subject, and the append-only check's event row +(`h-1` instead of `e-2`, since `e-2` is now a refused insert). + +Results: `proto-v3a-node24.txt` (Node 24.21.0 in the `node:24` image, +no network, the directory mounted read-only) and `proto-v3a-node26.txt` +(Node 26.8.1 on the host). Both use SQLite 3.53.4, and the outputs +differ only in the version line. Tables 8, triggers 35, views 4. The new +refusals: +- `task.state` with no subject, `PROJ-41`, `vikunja:3/41x`, + `vikunja:03/41` or `vikunja:3/4/1`; +- `task.created` with no request, a request naming an unknown event, a + credential event, a `human.input` from another business or a number; + with no requirement, `REQ-task-1` or `REQ-TASK-0`; or with no subject; +- a decision with `task_ref` `vikunja:3/41x`, and a snapshot with the + same ref. + +`task.created` citing `h-1` and `REQ-TASK-1` is accepted, and the trail +view from `h-1` finds it. The v3 cases give the same results as before, +with one exception. "Unknown kind task.deleted" is now refused by the +subject trigger, because BEFORE INSERT triggers run before the table's +CHECK. The new case "with a subject" shows the kind CHECK still refuses +it. + +Mutant: the same script against `schema-v3a.sql` without the `EXISTS` +clause accepts the unknown-event, credential-event and other-business +cases, and changes nothing else. It ran in `~/darkwing-scratch/v3a`, +with output in `mutant.txt` there. diff --git a/agents/darkwing/work/slice1-proto/proto-v3a.mjs b/agents/darkwing/work/slice1-proto/proto-v3a.mjs new file mode 100644 index 00000000..953c42ec --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-v3a.mjs @@ -0,0 +1,144 @@ +// Slice 1 prototype, v3a schema (v3 plus the task event rules of lead decision 56, Q3). Same pattern as proto-v3.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(), "s1v3a-")), "bus.sqlite"); +let db = new DatabaseSync(f, { timeout: 5000 }); +db.exec(readFileSync(new URL("./schema-v3a.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("unknown kind task.deleted with a subject", () => e("task.deleted", "pm", "run-P", {}, "vikunja:3/41")); +tryit("credential.expiring vikunja coder", () => e("credential.expiring", null, null, { service: "vikunja", instance: "coder", expires: "2026-10-11" })); +tryit("credential.expired vikunja coder", () => e("credential.expired", null, null, { service: "vikunja", instance: "coder", decision: "d-3" })); +tryit("credential.changed gitea pm", () => e("credential.changed", null, null, { service: "gitea", instance: "pm", stat: { inode: 1, size: 41 } })); +tryit("credential.changed without instance", () => e("credential.changed", null, null, { service: "gitea" })); +tryit("credential.expiring service github", () => e("credential.expiring", null, null, { service: "github", instance: "pm" })); +tryit("task.missing without reason", () => e("task.missing", null, null, { reconcile: "r-1" }, "vikunja:3/40")); +tryit("task.missing reason deleted", () => e("task.missing", null, null, { reason: "deleted" }, "vikunja:3/40")); +tryit("task.missing without subject", () => e("task.missing", null, null, { reason: "not-found" })); +tryit("task.missing moved without project", () => e("task.missing", null, null, { reason: "moved" }, "vikunja:3/40")); +tryit("task.missing not-found", () => e("task.missing", null, null, { reason: "not-found" }, "vikunja:3/40")); +tryit("task.missing moved to project 9", () => e("task.missing", null, null, { reason: "moved", project: 9 }, "vikunja:3/43")); +console.log("-- task events name their task (lead decision 56, Q3)"); +tryit("task.state without subject", () => e("task.state", "coder", "run-C", { bucket: 12 })); +tryit("task.state subject PROJ-41", () => e("task.state", "coder", "run-C", { bucket: 12 }, "PROJ-41")); +tryit("task.state subject vikunja:3/41x", () => e("task.state", "coder", "run-C", { bucket: 12 }, "vikunja:3/41x")); +tryit("task.state subject vikunja:03/41", () => e("task.state", "coder", "run-C", { bucket: 12 }, "vikunja:03/41")); +tryit("task.state subject vikunja:3/4/1", () => e("task.state", "coder", "run-C", { bucket: 12 }, "vikunja:3/4/1")); +tryit("task.state subject vikunja:3/41", () => e("task.state", "coder", "run-C", { bucket: 12 }, "vikunja:3/41")); +tryit("human.input from the cli", () => ev.run("h-1", now(), "mosaic-stack", "human.input", null, null, null, JSON.stringify({ via: "cli", text: "Add the broker push." }))); +tryit("human.input in another business", () => ev.run("h-2", now(), "other", "human.input", null, null, null, JSON.stringify({ via: "cli", text: "x" }))); +const tc = (body, subject = "vikunja:3/50") => e("task.created", "pm", "run-P", body, subject); +tryit("task.created without request", () => tc({ requirement: "REQ-TASK-1" })); +tryit("task.created request names an unknown event", () => tc({ request: "h-9", requirement: "REQ-TASK-1" })); +tryit("task.created request names a credential event", () => tc({ request: "e-3", requirement: "REQ-TASK-1" })); +tryit("task.created request from another business", () => tc({ request: "h-2", requirement: "REQ-TASK-1" })); +tryit("task.created request as a number", () => tc({ request: 1, requirement: "REQ-TASK-1" })); +tryit("task.created without requirement", () => tc({ request: "h-1" })); +tryit("task.created requirement REQ-task-1", () => tc({ request: "h-1", requirement: "REQ-task-1" })); +tryit("task.created requirement REQ-TASK-0", () => tc({ request: "h-1", requirement: "REQ-TASK-0" })); +tryit("task.created without subject", () => tc({ request: "h-1", requirement: "REQ-TASK-1" }, null)); +tryit("task.created cites h-1 and REQ-TASK-1", () => tc({ request: "h-1", requirement: "REQ-TASK-1" })); +tryit("decision with task_ref vikunja:3/41x", () => dec.run("d-6", now(), "mosaic-stack", "coder", "run-C", "gated", "deploy", "human", "?", opts, "A", "vikunja:3/41x", 0)); +show("e-3 is", "SELECT kind FROM events WHERE id = 'e-3'"); +show("trail from h-1", "SELECT e.kind, e.subject FROM events e WHERE json_extract(e.body, '$.request') = 'h-1'"); +tryit("digest.sent", () => e("digest.sent", null, null, { decisions: ["d-4"], transport: "discord-dm" })); +tryit("launch.revoked by pm run", () => e("launch.revoked", "pm", "run-P", {})); +tryit("launch.revoked by human", () => e("launch.revoked", null, null, { via: "cli" })); +show("launch_state", "SELECT business, state FROM launch_state"); +tryit("launch.restored by human", () => e("launch.restored", null, null, { via: "cli" })); +show("launch_state", "SELECT business, state FROM launch_state"); + +console.log("-- task_snapshots"); +const snap = db.prepare("INSERT INTO task_snapshots (at,business,task_ref,updated,etag,digest,fields,source,via,read_at,role,run) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); +const at = (sec) => new Date(Date.UTC(2026, 9, 4, 13, 0, sec)).toISOString(); +const self = (ref, updated, fields, sec, role, run) => snap.run(at(sec), "mosaic-stack", ref, updated, `"${hex(JSON.stringify(fields)).slice(0, 8)}"`, hex(JSON.stringify(fields)), JSON.stringify(fields), "self", null, null, role, run); +const poll = (ref, updated, fields, via, readSec) => snap.run(at(readSec + 1), "mosaic-stack", ref, updated, null, hex(JSON.stringify(fields)), JSON.stringify(fields), "poll", via, at(readSec), null, null); +const raw = (source, via, readAt, role, run, fields) => snap.run(at(59), "mosaic-stack", "vikunja:3/49", "2026-10-04T12:00:00Z", null, hex(JSON.stringify(fields)), JSON.stringify(fields), source, via, readAt, role, run); +// Buckets: 11 todo, 12 in-progress, 13 in-review, 14 blocked, 15 done. +const X = { title: "Add broker push", bucket: 12, done: 0 }, Y = { ...X, title: "Add broker push (Jason edit)" }, Z = { ...Y, bucket: 13 }, B = { ...Z, bucket: 14 }, W = { title: "Add broker push", bucket: 11, done: 0 }; +const U = "2026-10-04T12:00:00Z"; +tryit("self without role and run", () => raw("self", null, null, null, null, X)); +tryit("poll with a role", () => raw("poll", "board", at(58), "pm", "run-P", X)); +tryit("poll without via", () => raw("poll", null, at(58), null, null, X)); +tryit("poll without read_at", () => raw("poll", "board", null, null, null, X)); +tryit("self with via", () => raw("self", "board", at(58), "coder", "run-C", X)); +tryit("unknown via webhook", () => raw("poll", "webhook", at(58), null, null, X)); +tryit("fields without bucket", () => raw("poll", "cursor", at(58), null, null, { title: "x", done: 0 })); +tryit("bucket as text", () => raw("poll", "cursor", at(58), null, null, { title: "x", bucket: "in-progress", done: 0 })); +tryit("gone on a board read", () => raw("poll", "board", at(58), null, null, { gone: "not-found" })); +tryit("gone on self", () => raw("self", null, null, "pm", "run-P", { gone: "not-found" })); +tryit("bad task_ref", () => snap.run(at(59), "mosaic-stack", "PROJ-41", U, null, hex("x"), JSON.stringify(X), "poll", "board", at(58), null, null)); +tryit("task_ref vikunja:3/41x", () => snap.run(at(59), "mosaic-stack", "vikunja:3/41x", U, null, hex("x"), JSON.stringify(X), "poll", "board", at(58), null, null)); +tryit("bad digest", () => snap.run(at(59), "mosaic-stack", "vikunja:3/41", U, null, "abc", JSON.stringify(X), "poll", "board", at(58), null, null)); +tryit("self X by coder, response :02", () => self("vikunja:3/41", U, X, 2, "coder", "run-C")); +tryit("cursor X sent :04 (unchanged)", () => poll("vikunja:3/41", U, X, "cursor", 4)); +show("external after cursor X", "SELECT task_ref FROM task_external_changes"); +tryit("cursor Y sent :06, same second (person edit)", () => poll("vikunja:3/41", U, Y, "cursor", 6)); +show("external after cursor Y", "SELECT task_ref, via FROM task_external_changes"); +tryit("self Z by coder (move to in-review), response :10", () => self("vikunja:3/41", U, Z, 10, "coder", "run-C")); +tryit("stale board read sent :09 shows Y", () => poll("vikunja:3/41", U, Y, "board", 9)); +show("external after self Z, stale board read", "SELECT task_ref FROM task_external_changes"); +tryit("stale cursor read, updated 11:59:00", () => poll("vikunja:3/41", "2026-10-04T11:59:00Z", W, "cursor", 20)); +show("external after stale cursor read", "SELECT task_ref FROM task_external_changes"); +tryit("board read sent :30 agrees with Z", () => poll("vikunja:3/41", U, Z, "board", 30)); +show("external after board agrees", "SELECT task_ref FROM task_external_changes"); +tryit("person moves to blocked, updated unchanged, board sent :40", () => poll("vikunja:3/41", U, B, "board", 40)); +show("external after person's move", "SELECT task_ref, via FROM task_external_changes"); +tryit("board read on vikunja:3/42 with no self row", () => poll("vikunja:3/42", "2026-10-04T12:01:00Z", W, "board", 41)); +tryit("cursor read on vikunja:3/44, done", () => poll("vikunja:3/44", "2026-10-04T12:01:00Z", { ...W, bucket: 15, done: 1 }, "cursor", 41)); +show("tasks_open", "SELECT task_ref, bucket FROM tasks_open ORDER BY task_ref"); +tryit("tombstone for vikunja:3/42 (GET 404)", () => poll("vikunja:3/42", "2026-10-04T12:01:00Z", { gone: "not-found" }, "task", 45)); +show("tasks_open after tombstone", "SELECT task_ref, bucket FROM tasks_open ORDER BY task_ref"); +show("external, all", "SELECT task_ref, via FROM task_external_changes ORDER BY task_ref"); + +console.log("-- append-only on every table"); +const keys = { meta: "key = 'schema_digest'", events: "id = 'h-1'", 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-v3a.sql b/agents/darkwing/work/slice1-proto/schema-v3a.sql new file mode 100644 index 00000000..16283e2c --- /dev/null +++ b/agents/darkwing/work/slice1-proto/schema-v3a.sql @@ -0,0 +1,203 @@ +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 CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), 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:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'), + updated TEXT NOT NULL, + etag TEXT, + digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'), + fields TEXT NOT NULL CHECK (json_valid(fields)), + source TEXT NOT NULL CHECK (source IN ('self','poll')), + via TEXT CHECK (via IN ('board','cursor','task','reconcile')), + read_at TEXT, + role TEXT, run TEXT, + CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)), + CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)), + CHECK (json_type(fields, '$.bucket') IS 'integer' + OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text')) +) STRICT; +CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq); +CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events + WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS ( + SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired')) + BEGIN SELECT RAISE(ABORT, 'decision already closed'); END; +CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events + WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS ( + SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice)) + BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END; +CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims + WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim' + BEGIN SELECT RAISE(ABORT, 'role already held'); END; +CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims + WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim' + BEGIN SELECT RAISE(ABORT, 'role is not held'); END; +CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims + WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run + BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END; +CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims + WHEN NEW.op = 'revoke' AND NEW.decision IS NULL + BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); END; +CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END; +CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END; +CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END; +CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END; +CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END; +CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END; +CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END; +CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END; +CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END; +CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END; +CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END; +CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END; +CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END; +CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END; +CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END; +CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END; +CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END; +CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END; +CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END; +CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END; +CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END; +CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END; +CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END; +CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END; +CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions + WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL + BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END; +CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events + WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL) + BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END; +CREATE TRIGGER events_credential_body BEFORE INSERT ON events + WHEN NEW.kind GLOB 'credential.*' AND ( + json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja') + OR json_extract(NEW.body, '$.instance') IS NULL) + BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END; +CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events + WHEN NEW.kind = 'task.missing' AND ( + json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access') + OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer')) + BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END; +CREATE TRIGGER events_task_subject BEFORE INSERT ON events + WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*')) + BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END; +CREATE TRIGGER events_task_created_body BEFORE INSERT ON events + WHEN NEW.kind = 'task.created' AND ( + json_type(NEW.body, '$.request') IS NOT 'text' + OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request') + AND kind = 'human.input' AND business = NEW.business) + OR json_type(NEW.body, '$.requirement') IS NOT 'text' + OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*' + OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*') + BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END; +CREATE VIEW launch_state AS + SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at + FROM events e WHERE kind IN ('launch.revoked','launch.restored') + AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored')); +CREATE VIEW task_external_changes AS + SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest + FROM task_snapshots p + LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots + WHERE business = p.business AND task_ref = p.task_ref AND source = 'self') + WHERE p.source = 'poll' + AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref) + AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest)); +CREATE VIEW tasks_open AS + SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq + FROM task_snapshots s + WHERE s.seq = (SELECT max(seq) FROM task_snapshots WHERE business = s.business AND task_ref = s.task_ref) + AND json_type(s.fields, '$.gone') IS NULL + AND json_extract(s.fields, '$.done') = 0; +CREATE VIEW urgent_inbox AS + SELECT d.id, d.business, d.task_ref, d.question, d.at + FROM decisions d + WHERE d.class = 'gated' AND d.blocking = 1 + AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));