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