Rocko's round 2 candidate, approved by Darkwing (#1519 comment 26757).
build.patch 40d7e838, manifest 61519059, 24 files under packages/bus,
schema v3b (179ffe35, lead decision 60). Integration gate in a git
worktree of 942dca9e (S1 in the tree) plus the patch: bus 43/43 and
business 60/60 on Node 24 and 26, every package test and every
scripts/test-*.sh green, test-task 98/98 with the live-provider cases.
Rulings from lead decisions 62 and 63: the human proof is cooperative in
slice 1, and a self-raised cross-role decision routes to the human.
Single-use gated approvals follow in row 43.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
212 lines
13 KiB
SQL
212 lines
13 KiB
SQL
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'));
|