Darkwing's schema-v2 adds task_snapshots, decisions.blocking and a closed events.kind list. Sage reran proto-v2 on Node 26 with matching output. Decision 50 accepts the five choices beyond addendum A. Co-Authored-By: Claude Opus 5.5 <[email protected]>
176 lines
10 KiB
SQL
176 lines
10 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, 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:[0-9]*/[0-9]*'),
|
|
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')),
|
|
role TEXT, run TEXT,
|
|
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL))
|
|
) 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 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.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.digest <> ls.digest));
|
|
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'));
|