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 <[email protected]>
This commit is contained in:
2026-10-04 22:02:31 -05:00
co-authored by Claude Opus 5.5
parent 5e8f0f4749
commit 29daa48216
5 changed files with 654 additions and 0 deletions
@@ -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
@@ -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
@@ -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:<project>/<task>`, 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.
@@ -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"));
@@ -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'));