docs(slice1): schema v3b, current snapshot view (darkwing)

Lead decision 59. task_current picks each task's current snapshot,
skipping a poll row whose read_at is at or before the latest self row's
at; tasks_open reads it. Prototype on Node 24 and 26, two mutants
caught, and Dewey's fixture replayed: tasks_open now differs from v3a
only on #42 (in-progress). The notes add an S3 poller rule: compare a
read against task_current, not the raw latest row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-04 22:33:09 -05:00
co-authored by Claude Opus 5.5
parent 554b03f565
commit 48d76de7c6
7 changed files with 756 additions and 0 deletions
@@ -0,0 +1,27 @@
// Replays Dewey's slice 1 mockup fixture (read-only) into schema v3a and v3b, the way
// checks/slice1-verify.mjs check 15 does, and compares tasks_open. No browser.
import { readFileSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import vm from "node:vm";
import { createHash } from "node:crypto";
const [dataJs, v3a, v3b] = process.argv.slice(2);
const ctx = { window: {} }; vm.createContext(ctx); vm.runInContext(readFileSync(dataJs, "utf8"), ctx);
const S = ctx.window.S1;
const order = ["decisions", "messages", "deliveries", "decision_events", "role_claims", "events", "task_snapshots"];
const run = (file) => {
const schema = readFileSync(file, "utf8");
const db = new DatabaseSync(":memory:"); db.exec(schema);
const cols = Object.fromEntries(order.map((t) => [t, db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name).filter((c) => c !== "seq")]));
const rows = order.flatMap((t, ti) => S[t].map((r) => ({ t, ti, r }))).sort((a, b) => a.r.at.localeCompare(b.r.at) || a.ti - b.ti);
let bad = 0;
for (const { t, r } of rows) { try { db.prepare(`INSERT INTO ${t} (${cols[t].join(",")}) VALUES (${cols[t].map(() => "?").join(",")})`).run(...cols[t].map((c) => r[c] ?? null)); } catch (e) { bad++; console.log("refused", t, e.message); } }
const open = db.prepare("SELECT task_ref, bucket FROM tasks_open").all().map((x) => `${x.task_ref} ${S.lookups.buckets[x.bucket]}`).sort();
const ext = db.prepare("SELECT task_ref FROM task_external_changes ORDER BY task_ref").all().map((x) => x.task_ref);
const urgent = db.prepare("SELECT id FROM urgent_inbox ORDER BY id").all().map((x) => x.id);
console.log(file.split("/").pop(), createHash("sha256").update(schema).digest("hex").slice(0, 8), `rows ${rows.length} refused ${bad}`);
return { open, ext, urgent };
};
const a = run(v3a), b = run(v3b);
console.log("tasks_open only in v3a:", JSON.stringify(a.open.filter((x) => !b.open.includes(x))));
console.log("tasks_open only in v3b:", JSON.stringify(b.open.filter((x) => !a.open.includes(x))));
console.log("task_external_changes same:", a.ext.join() === b.ext.join(), "| urgent_inbox same:", a.urgent.join() === b.urgent.join());
@@ -0,0 +1,5 @@
schema-v3a.sql d55e41fd rows 72 refused 0
schema-v3b.sql 179ffe35 rows 72 refused 0
tasks_open only in v3a: ["vikunja:3/42 todo"]
tasks_open only in v3b: ["vikunja:3/42 in-progress"]
task_external_changes same: true | urgent_inbox same: true
@@ -0,0 +1,132 @@
-- open-time schema check
check after create -> match
digest 52514a1173bef256ac262b397321bd5cf16f6a903ea2de40e25db895154fc692
-- 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 -> []
view current after self Z, stale board read -> [{"task_ref":"vikunja:3/41","source":"self","bucket":13}]
view tasks_open after self Z, stale board read -> [{"task_ref":"vikunja:3/41","bucket":13}]
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"}]
-- current snapshot (lead decision 59)
ok self T on vikunja:3/46 by coder, response :50
ok board read sent :50 exactly shows S
view current on vikunja:3/46 -> [{"source":"self","bucket":12}]
view latest row on vikunja:3/46 -> [{"source":"poll","bucket":11}]
view poller, read S at :55: differs from latest row -> [{"write":0}]
view poller, read S at :55: differs from current -> [{"write":1}]
ok person moves vikunja:3/46 to todo, board sent :55
view current on vikunja:3/46 -> [{"source":"poll","bucket":11}]
view tasks_open -> [{"task_ref":"vikunja:3/41","bucket":14},{"task_ref":"vikunja:3/46","bucket":11}]
view external, all -> [{"task_ref":"vikunja:3/41","via":"board"},{"task_ref":"vikunja:3/42","via":"task"},{"task_ref":"vikunja:3/44","via":"cursor"},{"task_ref":"vikunja:3/46","via":"board"}]
view current, one row per task -> [{"rows":4,"tasks":4}]
-- 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: 5
@@ -0,0 +1,132 @@
-- open-time schema check
check after create -> match
digest 52514a1173bef256ac262b397321bd5cf16f6a903ea2de40e25db895154fc692
-- 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 -> []
view current after self Z, stale board read -> [{"task_ref":"vikunja:3/41","source":"self","bucket":13}]
view tasks_open after self Z, stale board read -> [{"task_ref":"vikunja:3/41","bucket":13}]
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"}]
-- current snapshot (lead decision 59)
ok self T on vikunja:3/46 by coder, response :50
ok board read sent :50 exactly shows S
view current on vikunja:3/46 -> [{"source":"self","bucket":12}]
view latest row on vikunja:3/46 -> [{"source":"poll","bucket":11}]
view poller, read S at :55: differs from latest row -> [{"write":0}]
view poller, read S at :55: differs from current -> [{"write":1}]
ok person moves vikunja:3/46 to todo, board sent :55
view current on vikunja:3/46 -> [{"source":"poll","bucket":11}]
view tasks_open -> [{"task_ref":"vikunja:3/41","bucket":14},{"task_ref":"vikunja:3/46","bucket":11}]
view external, all -> [{"task_ref":"vikunja:3/41","via":"board"},{"task_ref":"vikunja:3/42","via":"task"},{"task_ref":"vikunja:3/44","via":"cursor"},{"task_ref":"vikunja:3/46","via":"board"}]
view current, one row per task -> [{"rows":4,"tasks":4}]
-- 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: 5
@@ -0,0 +1,85 @@
# Slice 1 prototype, v3b (lead decision 59)
Darkwing, 2026-10-04. Dewey's mockup check found the case, recorded in
`agents/dewey/work/wui/SLICE1-VIEWS.md` section 8. A board read that
started before the broker's own move is written, because its digest
differs from the latest row. It then becomes the latest snapshot, and
`tasks_open` shows the old bucket until the next tick. Sage's ruling keeps
the row as evidence and puts the rule for "current" in the schema.
`schema-v3b.sql` is a full schema that replaces v3a. It isn't a
migration, and the v1 to v3a files are unchanged. `diff schema-v3a.sql
schema-v3b.sql` shows the whole change:
- New view `task_current`: one row per task, with every
`task_snapshots` column. It is the task's highest-`seq` row, skipping a
`poll` row whose `read_at` is at or before the `at` of the task's latest
`self` row. A task with no `self` row takes its latest row.
- `tasks_open` now reads `task_current` instead of picking the latest row
itself. Its columns are unchanged.
- `task_external_changes` is unchanged. It already ignores a read with
`read_at` at or before the latest `self` row, and it only reports a
task whose latest row is a poll, so a row it reports is also that
task's current row.
Counts: tables 8, triggers 36 (the prototype prints 35 after its tamper
check drops one), views 5. Schema digest (the `meta` value the
prototype stores at create time):
`52514a1173bef256ac262b397321bd5cf16f6a903ea2de40e25db895154fc692`.
## A rule for the S3 poller
Today the poller writes a poll snapshot when the read's digest differs
from the task's latest row. It has to compare against `task_current`
instead. Otherwise a stale row can hide a real change. In this sequence:
1. The broker moves a task to `in-progress`.
2. A stale read still shows `todo` and is written.
3. A person really moves the task back to `todo`.
The next read shows `todo`, which matches the stale latest row, so the
poller doesn't write it. The view goes on showing `in-progress`, and
`task_external_changes` never reports the person's move. Compared against
`task_current`, the read differs and is written, and both views come out
right. The prototype shows both comparisons on `vikunja:3/46`. This is my
row (S3), and it applies to addendum B section 5. It isn't a reason to
skip the stale write; with the comparison fixed, keeping the row costs
nothing.
## Prototype
`proto-v3b.mjs` is `proto-v3a.mjs` with these changes:
- the header, the temp directory prefix and the schema file name;
- a `digest` line after the open-time check;
- `task_current` and `tasks_open` shown for `vikunja:3/41` right after the
stale board read;
- a new section, "current snapshot", with a read whose `read_at` equals
the `self` row's `at` (skipped), the two poller comparisons, the
person's move (now current and external), and a check that
`task_current` has one row per task.
Results: `proto-v3b-node24.txt` (Node 24.21.0 in the `node:24` image, no
network, the directory mounted read-only) and `proto-v3b-node26.txt`
(Node 26.8.1 on the host). Both use SQLite 3.53.4, and the outputs differ
only in the version line. Against `proto-v3a-node26.txt`, every v3a line
is unchanged. The new lines are the digest, the two views after the stale
read (bucket 13, in review, not the stale read's 12, in progress), the new
section and the view count of 5.
Mutants, each run with the same script in `~/darkwing-scratch/v3b`:
- The rule dropped (`task_current` is the plain latest row): the stale
read shows as current on `vikunja:3/41` (bucket 12) and on
`vikunja:3/46`, and the current-based poller comparison says "no
write". Caught on four lines.
- `>` widened to `>=` (a read at the same instant as the `self` row
counts): `vikunja:3/46` shows the stale read. Caught on two lines.
## Dewey's fixture
`fixture-replay.mjs` loads `mockups/slice1/data.js` (sha256
`aed3616f…`, Dewey's uncommitted working file, read-only) the way
`checks/slice1-verify.mjs` check 15 does, without the browser, into both
schemas. All 72 rows insert in both. `tasks_open` differs only on #42:
`todo` in v3a, `in-progress` in v3b. `task_external_changes` and
`urgent_inbox` are the same in both. Check 15 found v3a differed from the
page on #42 alone, so v3b agrees with the page on every row. Output:
`fixture-replay.txt`. Dewey's check still pins `schema-v3a.sql` d55e41fd;
moving it to v3b is Dewey's change.
@@ -0,0 +1,164 @@
// Slice 1 prototype, v3b schema (v3a plus the current snapshot view of lead decision 59). Same pattern as proto-v3a.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(), "s1v3b-")), "bus.sqlite");
let db = new DatabaseSync(f, { timeout: 5000 });
db.exec(readFileSync(new URL("./schema-v3b.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("digest", db.prepare("SELECT value FROM meta WHERE key = 'schema_digest'").get().value);
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");
show("current after self Z, stale board read", "SELECT task_ref, source, json_extract(fields, '$.bucket') AS bucket FROM task_current WHERE task_ref = 'vikunja:3/41'");
show("tasks_open after self Z, stale board read", "SELECT task_ref, bucket FROM tasks_open WHERE task_ref = 'vikunja:3/41'");
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("-- current snapshot (lead decision 59)");
const T = { title: "Add review push", bucket: 12, done: 0 }, S = { ...T, bucket: 11 };
tryit("self T on vikunja:3/46 by coder, response :50", () => self("vikunja:3/46", U, T, 50, "coder", "run-C"));
tryit("board read sent :50 exactly shows S", () => poll("vikunja:3/46", U, S, "board", 50));
show("current on vikunja:3/46", "SELECT source, json_extract(fields, '$.bucket') AS bucket FROM task_current WHERE task_ref = 'vikunja:3/46'");
show("latest row on vikunja:3/46", "SELECT source, json_extract(fields, '$.bucket') AS bucket FROM task_snapshots WHERE task_ref = 'vikunja:3/46' ORDER BY seq DESC LIMIT 1");
// The poller writes a read whose digest differs from the task's current snapshot. Compared with the
// latest row instead, a person's real move back to S at :55 matches the stale row and is never written.
const differs = (from, ref, fields) => (db.prepare(`SELECT digest FROM ${from} WHERE task_ref = ? ORDER BY seq DESC LIMIT 1`).get(ref)?.digest ?? null) !== hex(JSON.stringify(fields));
show("poller, read S at :55: differs from latest row", `SELECT ${differs("task_snapshots", "vikunja:3/46", S) ? 1 : 0} AS write`);
show("poller, read S at :55: differs from current", `SELECT ${differs("task_current", "vikunja:3/46", S) ? 1 : 0} AS write`);
tryit("person moves vikunja:3/46 to todo, board sent :55", () => poll("vikunja:3/46", U, S, "board", 55));
show("current on vikunja:3/46", "SELECT source, json_extract(fields, '$.bucket') AS bucket FROM task_current WHERE task_ref = 'vikunja:3/46'");
show("tasks_open", "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");
show("current, one row per task", "SELECT count(*) AS rows, count(DISTINCT business || task_ref) AS tasks FROM task_current");
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,211 @@
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 task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE 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'));