diff --git a/agents/gizmoduck/work/queue-271/BUILD.md b/agents/gizmoduck/work/queue-271/BUILD.md index 14a057044..e85ef1b2a 100644 --- a/agents/gizmoduck/work/queue-271/BUILD.md +++ b/agents/gizmoduck/work/queue-271/BUILD.md @@ -29,7 +29,12 @@ Answer, the operation envelope: "state": "clearing", "start": null, "refusal": null, "at": "..." } ``` -- `successor` is bound when the request is accepted, before any asynchronous work. +- `successor` is bound when the request is accepted, before any asynchronous work. It is a + lowercase UUID, `xxxxxxxx-xxxx-8xxx-Vxxx-xxxxxxxxxxxx` with V one of `8`, `9`, `a`, `b` (RFC 9562 + version 8, variant 10). It's the first 32 hex digits of sha256(business, thread, old session, op) + with those two nibbles set. It matches the UUID test every session selector uses + (cli/src/thread.mjs:41), so 264a's selectors and deep links take it unchanged (Huey's question, + gizmoduck-0274). - `state`: - `clearing`: accepted. Sends and launches to the old session are fenced, and the drain or stop is in progress. @@ -139,8 +144,9 @@ alone can't show that no child is running. - Check owner and grant (the `session.create` path), the revision, and that processing isn't halted. - Bind the successor before any async work: - - The id is deterministic, from a sha256 of (business, thread, old session, op), shaped to - `ID_PATTERN` (vocabulary.mjs:199). + - The id is deterministic, from a sha256 of (business, thread, old session, op), shaped as a + lowercase version 8 UUID (the contract above). 264a's ids are UUIDs, and every selector + checks the UUID shape, so draft 2's `ID_PATTERN` shape is replaced. - The name is `-N`, with N the next free number in that thread. If a user already made a session called `-N`, the next free N is taken. - Both go on the `requested` row, so a repeat of the op returns the same id and name. diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql index 59d1c80a5..ada6e8ca9 100644 --- a/packages/bus/schema.sql +++ b/packages/bus/schema.sql @@ -478,3 +478,40 @@ CREATE TABLE gitea_grants ( PRIMARY KEY (business, agent), FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent) ) STRICT; +CREATE TABLE session_ops ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + business TEXT NOT NULL, + thread TEXT NOT NULL REFERENCES threads(id), + session TEXT NOT NULL REFERENCES sessions(id), + op TEXT NOT NULL, + stage TEXT NOT NULL CHECK (stage IN ('requested','drained','uncertain','exited','retired','failed','cancelled','start-held','start-started','start-refused')), + attempt INTEGER NOT NULL CHECK (CASE WHEN stage GLOB 'start-*' THEN attempt >= 1 ELSE attempt = 0 END), + work_attempt INTEGER CHECK (work_attempt IS NULL OR (stage GLOB 'start-*' AND work_attempt >= 1)), + successor TEXT NOT NULL, + successor_name TEXT NOT NULL, + run TEXT, + generation INTEGER CHECK (generation IS NULL OR generation >= 1), + digest TEXT, + refusal TEXT, + UNIQUE (thread, op, stage, attempt) +) STRICT; +CREATE TABLE message_cancels ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + message TEXT NOT NULL UNIQUE REFERENCES messages(id), + session TEXT NOT NULL REFERENCES sessions(id), + op TEXT NOT NULL +) STRICT; +CREATE INDEX session_ops_session ON session_ops (session, seq); +CREATE TRIGGER session_ops_no_update BEFORE UPDATE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END; +CREATE TRIGGER session_ops_no_delete BEFORE DELETE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END; +CREATE TRIGGER session_ops_no_replace BEFORE INSERT ON session_ops WHEN EXISTS (SELECT 1 FROM session_ops WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END; +CREATE TRIGGER session_ops_one_open BEFORE INSERT ON session_ops + WHEN NEW.stage = 'requested' AND EXISTS (SELECT 1 FROM session_ops o WHERE o.session = NEW.session + AND o.seq = (SELECT max(seq) FROM session_ops WHERE thread = o.thread AND op = o.op) + AND o.stage IN ('requested','drained','uncertain','exited')) + BEGIN SELECT RAISE(ABORT, 'session has an open clear'); END; +CREATE TRIGGER message_cancels_no_update BEFORE UPDATE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END; +CREATE TRIGGER message_cancels_no_delete BEFORE DELETE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END; +CREATE TRIGGER message_cancels_no_replace BEFORE INSERT ON message_cancels WHEN EXISTS (SELECT 1 FROM message_cancels WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END; diff --git a/packages/bus/src/migrations/3l-3m.sql b/packages/bus/src/migrations/3l-3m.sql new file mode 100644 index 000000000..a4fd9da8b --- /dev/null +++ b/packages/bus/src/migrations/3l-3m.sql @@ -0,0 +1,40 @@ +-- Row 271 (264d, #1776, T1 item 7 D7/D8): clear context. session_ops is the clear journal, one row +-- per stage, and message_cancels holds a receipt for each message a clear cancelled. Only new +-- tables, an index and their triggers. +CREATE TABLE session_ops ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + business TEXT NOT NULL, + thread TEXT NOT NULL REFERENCES threads(id), + session TEXT NOT NULL REFERENCES sessions(id), + op TEXT NOT NULL, + stage TEXT NOT NULL CHECK (stage IN ('requested','drained','uncertain','exited','retired','failed','cancelled','start-held','start-started','start-refused')), + attempt INTEGER NOT NULL CHECK (CASE WHEN stage GLOB 'start-*' THEN attempt >= 1 ELSE attempt = 0 END), + work_attempt INTEGER CHECK (work_attempt IS NULL OR (stage GLOB 'start-*' AND work_attempt >= 1)), + successor TEXT NOT NULL, + successor_name TEXT NOT NULL, + run TEXT, + generation INTEGER CHECK (generation IS NULL OR generation >= 1), + digest TEXT, + refusal TEXT, + UNIQUE (thread, op, stage, attempt) +) STRICT; +CREATE TABLE message_cancels ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + message TEXT NOT NULL UNIQUE REFERENCES messages(id), + session TEXT NOT NULL REFERENCES sessions(id), + op TEXT NOT NULL +) STRICT; +CREATE INDEX session_ops_session ON session_ops (session, seq); +CREATE TRIGGER session_ops_no_update BEFORE UPDATE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END; +CREATE TRIGGER session_ops_no_delete BEFORE DELETE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END; +CREATE TRIGGER session_ops_no_replace BEFORE INSERT ON session_ops WHEN EXISTS (SELECT 1 FROM session_ops WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END; +CREATE TRIGGER session_ops_one_open BEFORE INSERT ON session_ops + WHEN NEW.stage = 'requested' AND EXISTS (SELECT 1 FROM session_ops o WHERE o.session = NEW.session + AND o.seq = (SELECT max(seq) FROM session_ops WHERE thread = o.thread AND op = o.op) + AND o.stage IN ('requested','drained','uncertain','exited')) + BEGIN SELECT RAISE(ABORT, 'session has an open clear'); END; +CREATE TRIGGER message_cancels_no_update BEFORE UPDATE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END; +CREATE TRIGGER message_cancels_no_delete BEFORE DELETE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END; +CREATE TRIGGER message_cancels_no_replace BEFORE INSERT ON message_cancels WHEN EXISTS (SELECT 1 FROM message_cancels WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END; diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs index cad4c4272..8ce7fc7fe 100644 --- a/packages/bus/src/store.mjs +++ b/packages/bus/src/store.mjs @@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path'; import { createHash } from 'node:crypto'; const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); -export const SCHEMA_SHA256 = 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a'; -export const SCHEMA_VERSION = '3l'; +export const SCHEMA_SHA256 = '129a76db0fe9973cdea5a87f274941b4111c86730fba8a0cbe16e61a534525d0'; +export const SCHEMA_VERSION = '3m'; // Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned. const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8'); const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65'; @@ -72,6 +72,11 @@ const migrations = [{ from: '3k', to: '3l', sql: readFileSync(new URL('./migrations/3k-3l.sql', import.meta.url), 'utf8'), +}, { + // Row 271 (#1776): clear context. The clear journal and the cancelled-message receipts. Only new tables. + from: '3l', + to: '3m', + sql: readFileSync(new URL('./migrations/3l-3m.sql', import.meta.url), 'utf8'), }]; const timedTables = [ 'events', @@ -89,6 +94,8 @@ const timedTables = [ 'thread_opens', 'instance_names', 'usage_records', + 'session_ops', + 'message_cancels', ]; const hex = (value) => createHash('sha256').update(value).digest('hex'); if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');