Files
stack/packages/queue/src/queue.mjs
T
jason.woltjeandClaude Opus 5.5 34a72af912 feat(queue): queue as data A1, journal, lock, CLI and verify (#1508)
packages/queue, scripts/queue-commit.sh, scripts/git-hooks and
scripts/test-queue.sh, plus docs/plans/BRIEF-TEMPLATE.md. There is no
queue.json yet, so verify skips until the genesis commit after A2.

Darkwing built it, and Filbert reviewed R0 (6933b885, changes requested)
and r1 (e464be6c, approved). The 20 files match manifest 85a8a453. The
nine suites passed on an index export, including the new queue suite.
test-queue.sh joins the suite list in AGENTS.md. Lead decisions 20, 23
and 26.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:07:48 -05:00

943 lines
44 KiB
JavaScript

// The queue as data (8.2, 8.6, 8.7, 8.8): schema, deterministic
// serialization, the one transition and permission matrix, replay from
// genesis, `next`, and the rendered table. Everything here is pure: no file,
// git or clock access. The write path lives in store.mjs.
import { createHash } from "node:crypto";
import { QueueError } from "./errors.mjs";
export const VERSION = 1;
export const SEMANTICS = 1;
export const STATES = ["queued", "briefed", "in-progress", "in-review", "waiting-on-jason", "done", "blocked", "parked"];
const NON_TERMINAL = new Set(["queued", "briefed", "in-progress", "in-review", "waiting-on-jason"]);
const CLAIM_KEPT = new Set(["in-progress", "in-review", "waiting-on-jason"]);
export const PRIVILEGED = new Set(["jason", "sage"]);
export const VERBS = ["genesis", "add", "move", "release", "assign", "note", "set", "accept-history"];
export const SET_FIELDS = ["piece", "gate", "gate-owner", "after", "reviewers", "issues", "closes", "brief", "required"];
const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
export const CALLER_OP_RE = /^[a-z0-9][a-z0-9._-]{7,71}$/;
export const LOG_OP_RE = /^[a-z0-9][a-z0-9._-]{7,79}$/;
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const BLOB_RE = /^[0-9a-f]{40}$/;
const SHA256_RE = /^[0-9a-f]{64}$/;
const PATH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
const BRANCH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
// C0, DEL, C1, U+2028 and U+2029: none belongs in a one-line field.
const BAD_TEXT_RE = new RegExp(`[${String.fromCharCode(0)}-${String.fromCharCode(0x1f)}${String.fromCharCode(0x7f)}-${String.fromCharCode(0x9f)}${String.fromCharCode(0x2028, 0x2029)}]`);
export const DOC_KEYS = ["version", "canonicalRoot", "revision", "rows", "log"];
export const ROW_KEYS = [
"id", "piece", "owner", "issues", "closes", "state", "previousState", "required", "requiredSince", "gate", "gateOwner",
"brief", "after", "reviewers", "review", "claim", "note", "blockedReason", "createdAt", "updatedAt", "updatedBy",
];
export const ENTRY_KEYS = ["rev", "op", "verb", "args", "by", "at", "semantics", "result", "viewSha"];
const MAP_ROW_KEYS = [
"id", "piece", "owner", "issues", "closes", "state", "previousState", "required", "requiredSince", "gate", "gateOwner",
"brief", "after", "reviewers", "note", "blockedReason", "createdAt",
];
export const BEGIN_MARKER = "<!-- mosaic-queue:begin -->";
export const END_MARKER = "<!-- mosaic-queue:end -->";
function refuse(message) {
return new QueueError(message, 2);
}
export function sha256(data) {
return createHash("sha256").update(data).digest("hex");
}
// Git's blob id for bytes, computed in-process with no git write (8.13).
export function gitBlobId(bytes) {
return createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
}
// --- field checks ---
function isObj(v) {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
function keysExactly(obj, keys, what) {
if (!isObj(obj)) throw refuse(`${what} is not an object`);
const got = Object.keys(obj);
if (got.join("\u0001") !== keys.join("\u0001")) throw refuse(`${what} keys must be exactly ${keys.join(", ")} in that order; got ${got.join(", ")}`);
}
export function checkText(v, what, { max = 500, empty = false } = {}) {
if (typeof v !== "string") throw refuse(`${what} must be text`);
if (!empty && v.length === 0) throw refuse(`${what} must not be empty`);
if (v.length > max) throw refuse(`${what} is longer than ${max} characters`);
if (BAD_TEXT_RE.test(v)) throw refuse(`${what} must be one line with no control characters`);
if (v !== v.trim()) throw refuse(`${what} must not start or end with whitespace`);
return v;
}
export function checkName(v, what) {
if (typeof v !== "string" || !NAME_RE.test(v)) throw refuse(`${what} must be a seat name (lowercase letters, digits, hyphens): ${JSON.stringify(v)}`);
return v;
}
function checkId(v, what = "row id") {
if (!Number.isInteger(v) || v < 1 || v > 1_000_000) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
return v;
}
function checkIssues(v, what) {
if (!Array.isArray(v)) throw refuse(`${what} must be a list`);
for (const n of v) if (!Number.isInteger(n) || n < 1) throw refuse(`${what} must hold positive issue numbers`);
for (let i = 1; i < v.length; i++) if (v[i] <= v[i - 1]) throw refuse(`${what} must be ascending with no repeats`);
return v;
}
function checkNames(v, what) {
if (!Array.isArray(v)) throw refuse(`${what} must be a list`);
v.forEach((n) => checkName(n, what));
for (let i = 1; i < v.length; i++) if (v[i] <= v[i - 1]) throw refuse(`${what} must be sorted with no repeats`);
return v;
}
function checkAfter(v, what = "after") {
if (!Array.isArray(v)) throw refuse(`${what} must be a list`);
for (const a of v) {
keysExactly(a, ["id", "when"], `${what} entry`);
checkId(a.id, `${what} id`);
if (a.when !== "done" && a.when !== "settled") throw refuse(`${what} when must be done or settled`);
}
for (let i = 1; i < v.length; i++) if (v[i].id <= v[i - 1].id) throw refuse(`${what} must be sorted by id with no repeats`);
return v;
}
function checkTime(v, what, { unknown = false, date = false } = {}) {
if (unknown && v === "unknown") return v;
if (typeof v === "string" && (ISO_RE.test(v) || (date && DATE_RE.test(v)))) return v;
throw refuse(`${what} must be an ISO time${date ? " or date" : ""}${unknown ? " or \"unknown\"" : ""}: ${JSON.stringify(v)}`);
}
export function checkRepoPath(v, what) {
if (typeof v !== "string" || !PATH_RE.test(v) || v.split("/").some((s) => s === "." || s === "..")) {
throw refuse(`${what} must be a repo-relative path: ${JSON.stringify(v)}`);
}
return v;
}
export function parseBriefSpec(spec) {
if (typeof spec !== "string") throw refuse("brief must be PATH#ANCHOR");
const i = spec.indexOf("#");
if (i < 0) throw refuse(`brief must be PATH#ANCHOR, naming the heading: ${JSON.stringify(spec)}`);
const path = checkRepoPath(spec.slice(0, i), "brief path");
const anchor = checkText(spec.slice(i + 1), "brief anchor", { max: 200 });
return { path, anchor };
}
function checkBrief(v, what = "brief") {
keysExactly(v, ["path", "anchor", "blob"], what);
checkRepoPath(v.path, `${what} path`);
checkText(v.anchor, `${what} anchor`, { max: 200 });
if (!BLOB_RE.test(v.blob)) throw refuse(`${what} blob must be a 40-hex git blob id`);
return v;
}
function checkCandidate(v) {
keysExactly(v, ["kind", "digest", "text"], "candidate");
if (v.kind === "commit") {
if (!BLOB_RE.test(v.digest) || v.text !== null) throw refuse("a commit candidate is a 40-hex commit id with no text");
} else if (v.kind === "manifest") {
if (!SHA256_RE.test(v.digest) || typeof v.text !== "string" || sha256(v.text) !== v.digest) throw refuse("a manifest candidate's digest must be the SHA-256 of its text");
parseManifest(v.text);
} else {
throw refuse("candidate kind must be commit or manifest");
}
return v;
}
// A manifest in the form of CANDIDATE.sha256: "<sha256> <path>" per line.
export function parseManifest(text) {
if (typeof text !== "string" || text.length === 0 || !text.endsWith("\n")) throw refuse("a candidate manifest must be non-empty and end with a newline");
const seen = new Set();
const lines = text.slice(0, -1).split("\n");
for (const line of lines) {
const m = /^([0-9a-f]{64}) {2}(.+)$/.exec(line);
if (!m) throw refuse(`candidate manifest line is not "<sha256> <path>": ${JSON.stringify(line)}`);
checkRepoPath(m[2], "candidate manifest path");
if (seen.has(m[2])) throw refuse(`candidate manifest names ${m[2]} twice`);
seen.add(m[2]);
}
return lines.length;
}
function checkRound(v, n) {
keysExactly(v, ["n", "op", "by", "at", "candidate", "request"], "review round");
if (v.n !== n) throw refuse(`review rounds must be numbered from 1; expected ${n}`);
if (!LOG_OP_RE.test(v.op)) throw refuse("review round op is not an op id");
checkName(v.by, "review round by");
checkTime(v.at, "review round at");
checkCandidate(v.candidate);
if (v.request !== "none") throw refuse("review round request must be none before Piece D");
}
export function validateRow(row) {
keysExactly(row, ROW_KEYS, `row ${row?.id}`);
const w = `row ${row.id}`;
checkId(row.id);
checkText(row.piece, `${w} piece`, { max: 300 });
checkName(row.owner, `${w} owner`);
checkIssues(row.issues, `${w} issues`);
checkIssues(row.closes, `${w} closes`);
for (const n of row.closes) if (!row.issues.includes(n)) throw refuse(`${w} closes #${n}, which is not in its issues`);
if (!STATES.includes(row.state)) throw refuse(`${w} state ${JSON.stringify(row.state)} is not a state`);
if (row.state === "blocked") {
if (!NON_TERMINAL.has(row.previousState)) throw refuse(`${w} is blocked and needs the non-terminal state it returns to`);
checkText(row.blockedReason, `${w} blockedReason`);
} else if (row.previousState !== null || row.blockedReason !== null) {
throw refuse(`${w} carries previousState or blockedReason but is not blocked`);
}
if (typeof row.required !== "boolean") throw refuse(`${w} required must be true or false`);
if (row.required) checkTime(row.requiredSince, `${w} requiredSince`, { unknown: true, date: true });
else if (row.requiredSince !== null) throw refuse(`${w} has requiredSince but is not required`);
if (row.required && row.state === "parked") throw refuse(`${w} is parked and required`);
checkText(row.gate, `${w} gate`, { max: 300 });
checkName(row.gateOwner, `${w} gateOwner`);
if (row.brief === null) {
if (row.state !== "done") throw refuse(`${w} has no brief; only a done row may lack one`);
} else {
checkBrief(row.brief, `${w} brief`);
}
checkAfter(row.after, `${w} after`);
checkNames(row.reviewers, `${w} reviewers`);
if (row.review !== null) {
keysExactly(row.review, ["issue", "rounds"], `${w} review`);
checkId(row.review.issue, `${w} review issue`);
if (!Array.isArray(row.review.rounds) || row.review.rounds.length === 0) throw refuse(`${w} review needs at least one round`);
row.review.rounds.forEach((r, i) => checkRound(r, i + 1));
}
if (row.claim !== null) {
keysExactly(row.claim, ["seat", "op"], `${w} claim`);
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat ${row.claim.seat} is not the owner ${row.owner}`);
if (!LOG_OP_RE.test(row.claim.op)) throw refuse(`${w} claim op is not an op id`);
const s = row.state === "blocked" ? row.previousState : row.state;
if (!CLAIM_KEPT.has(s)) throw refuse(`${w} is claimed but ${row.state}`);
}
if (row.note !== null) checkText(row.note, `${w} note`);
checkTime(row.createdAt, `${w} createdAt`, { unknown: true, date: true });
checkTime(row.updatedAt, `${w} updatedAt`);
checkName(row.updatedBy, `${w} updatedBy`);
return row;
}
// Whole-table rules: unique ids, `after` names existing other rows, no cycle.
export function validateRows(rows) {
const byId = new Map();
for (const r of rows) {
validateRow(r);
if (byId.has(r.id)) throw refuse(`row id ${r.id} appears twice`);
byId.set(r.id, r);
}
for (const r of rows) {
for (const a of r.after) {
if (a.id === r.id) throw refuse(`row ${r.id} lists itself in after`);
if (!byId.has(a.id)) throw refuse(`row ${r.id} lists missing row ${a.id} in after`);
}
}
const state = new Map();
const visit = (id, path) => {
if (state.get(id) === 2) return;
if (state.get(id) === 1) throw refuse(`after has a cycle: ${[...path, id].join(" -> ")}`);
state.set(id, 1);
for (const a of byId.get(id).after) visit(a.id, [...path, id]);
state.set(id, 2);
};
for (const r of rows) visit(r.id, []);
return byId;
}
// --- canonical arguments (8.6: identity is the op id, same verb and args) ---
function ordered(obj, keys) {
const out = {};
for (const k of keys) out[k] = obj[k];
return out;
}
function nullable(v, fn) {
return v === null || v === undefined ? null : fn(v);
}
function sortedUnique(list, cmp) {
const out = [...list].sort(cmp);
for (let i = 1; i < out.length; i++) if (cmp(out[i], out[i - 1]) === 0) throw refuse(`repeated value ${JSON.stringify(out[i])}`);
return out;
}
export function canonIssues(v) {
if (!Array.isArray(v)) throw refuse("issues must be a list");
return checkIssues(sortedUnique(v.map((n) => checkId(n, "issue")), (a, b) => a - b), "issues");
}
export function canonNames(v, what) {
if (!Array.isArray(v)) throw refuse(`${what} must be a list`);
return sortedUnique(v.map((n) => checkName(n, what)), (a, b) => (a < b ? -1 : a > b ? 1 : 0));
}
export function canonAfter(v) {
if (!Array.isArray(v)) throw refuse("after must be a list");
const list = v.map((a) => {
if (!isObj(a)) throw refuse("after entries are {id, when}");
return { id: checkId(a.id, "after id"), when: a.when };
});
return checkAfter(sortedUnique(list, (a, b) => a.id - b.id));
}
function canonSetValue(field, value) {
switch (field) {
case "piece": return checkText(value, "piece", { max: 300 });
case "gate": return checkText(value, "gate", { max: 300 });
case "gate-owner": return checkName(value, "gate owner");
case "after": return canonAfter(value);
case "reviewers": return canonNames(value, "reviewers");
case "issues": return canonIssues(value);
case "closes": return canonIssues(value);
case "brief": parseBriefSpec(value); return value;
case "required":
if (typeof value !== "boolean") throw refuse("required must be true or false");
return value;
default: throw refuse(`set cannot change ${JSON.stringify(field)}; fields: ${SET_FIELDS.join(", ")}`);
}
}
export function canonArgs(verb, a) {
if (!isObj(a)) throw refuse("args must be an object");
switch (verb) {
case "genesis":
if (typeof a.root !== "string" || !a.root.startsWith("/")) throw refuse("genesis root must be an absolute path");
if (typeof a.branch !== "string" || !BRANCH_RE.test(a.branch)) throw refuse("genesis branch is not a branch name");
checkRepoPath(a.map, "genesis map");
return ordered(a, ["root", "branch", "map"]);
case "add": {
const brief = a.brief;
parseBriefSpec(brief);
return {
piece: checkText(a.piece, "piece", { max: 300 }),
gate: checkText(a.gate, "gate", { max: 300 }),
brief,
issues: canonIssues(a.issues ?? []),
note: nullable(a.note, (v) => checkText(v, "note")),
owner: nullable(a.owner, (v) => checkName(v, "owner")),
gateOwner: nullable(a.gateOwner, (v) => checkName(v, "gate owner")),
after: nullable(a.after, canonAfter),
reviewers: nullable(a.reviewers, (v) => canonNames(v, "reviewers")),
required: nullable(a.required, (v) => { if (typeof v !== "boolean") throw refuse("required must be true or false"); return v; }),
};
}
case "move":
if (!STATES.includes(a.to)) throw refuse(`unknown state ${JSON.stringify(a.to)}; states: ${STATES.join(", ")}`);
return {
id: checkId(a.id),
to: a.to,
reason: nullable(a.reason, (v) => checkText(v, "reason")),
candidate: nullable(a.candidate, (v) => checkText(v, "candidate", { max: 300 })),
evidence: nullable(a.evidence, (v) => checkText(v, "evidence")),
issue: nullable(a.issue, (v) => checkId(v, "issue")),
};
case "release":
return { id: checkId(a.id) };
case "assign":
return { id: checkId(a.id), seat: checkName(a.seat, "seat") };
case "note":
return { id: checkId(a.id), text: checkText(a.text, "note", { empty: true }) };
case "set": {
if (!SET_FIELDS.includes(a.field)) throw refuse(`set cannot change ${JSON.stringify(a.field)}; fields: ${SET_FIELDS.join(", ")}`);
return {
id: checkId(a.id),
field: a.field,
value: canonSetValue(a.field, a.value),
reason: nullable(a.reason, (v) => checkText(v, "reason")),
};
}
case "accept-history":
return { reason: checkText(a.reason, "reason") };
default:
throw refuse(`unknown verb ${JSON.stringify(verb)}`);
}
}
export function sameJson(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
// --- the matrix (8.7) ---
const isPriv = (by) => PRIVILEGED.has(by);
function ownerOrPriv(row, by, doing) {
if (by === row.owner || isPriv(by)) return;
if (row.claim !== null) throw refuse(`row ${row.id} is claimed by ${row.claim.seat}; ${by} cannot ${doing}`);
throw refuse(`only the owner (${row.owner}) or a privileged actor may ${doing} row ${row.id}`);
}
function requirePriv(by, doing) {
if (!isPriv(by)) throw refuse(`only a privileged actor (jason or sage) may ${doing}`);
}
function requireJason(by, doing) {
if (by !== "jason") throw refuse(`only jason may ${doing}`);
}
function afterSatisfied(rows, row) {
const missing = [];
for (const a of row.after) {
const t = rows.get(a.id);
const ok = t && (t.state === "done" || (a.when === "settled" && t.state === "blocked"));
if (!ok) missing.push(`${a.id} (${a.when}; now ${t ? t.state : "missing"})`);
}
return missing;
}
// `comment=<id>,round=<n>,candidate=<digest>`: the J5 evidence before Piece D.
export function parseReviewEvidence(text) {
const m = /^comment=([1-9][0-9]{0,19}),round=([1-9][0-9]{0,5}),candidate=([0-9a-f]{40}|[0-9a-f]{64})$/.exec(text ?? "");
if (!m) throw refuse("in-review to done needs --evidence comment=<id>,round=<n>,candidate=<digest> for the current round");
return { comment: m[1], round: Number(m[2]), candidate: m[3] };
}
// The issue a review round posts to (lead decision 23). The row must list
// one; with several, --issue names it. A later round keeps the previous
// round's issue unless --issue names another, and the kept issue must still
// be one of the row's.
function reviewIssue(row, issue) {
const list = row.issues.map((n) => `#${n}`).join(", ");
if (row.issues.length === 0) throw refuse(`row ${row.id} lists no issues; a privileged actor sets one before review`);
if (issue !== null) {
if (!row.issues.includes(issue)) throw refuse(`--issue #${issue} is not one of row ${row.id}'s issues (${list})`);
return issue;
}
if (row.review) {
if (!row.issues.includes(row.review.issue)) throw refuse(`row ${row.id}'s review issue #${row.review.issue} is no longer one of its issues (${list}); name one with --issue`);
return row.review.issue;
}
if (row.issues.length > 1) throw refuse(`row ${row.id} lists several issues (${list}); name the review's issue with --issue`);
return row.issues[0];
}
function touch(row, entry) {
return { ...row, updatedAt: entry.at, updatedBy: entry.by };
}
function fmt(v) {
if (v === null) return "none";
if (Array.isArray(v)) return v.length ? v.map(fmt).join(", ") : "none";
if (typeof v === "number") return `#${v}`;
if (isObj(v) && "when" in v) return `${v.id} ${v.when}`;
if (isObj(v) && "blob" in v) return `${v.path} § ${v.anchor} @${v.blob.slice(0, 12)}`;
return String(v);
}
function receipt(entry, rev, rest) {
return `ok ${entry.op} rev ${rev} ${rest}`;
}
function getRow(rows, id) {
const row = rows.get(id);
if (!row) throw refuse(`no row ${id}`);
return row;
}
function applyMove(rows, row, entry, resolved) {
const { to, reason, candidate, evidence, issue } = entry.args;
const by = entry.by;
const from = row.state;
const illegal = () => refuse(`row ${row.id}: ${from}→${to} is not a transition`);
if (from === "done") throw refuse(`row ${row.id} is done; done rows never change`);
if (reason !== null && to !== "blocked") throw refuse("--reason applies only to a move to blocked");
if (candidate !== null && !(from === "in-progress" && to === "in-review")) throw refuse("--candidate applies only to in-progress→in-review");
if (evidence !== null && to !== "done") throw refuse("--evidence applies only to a move to done");
if (issue !== null && !(from === "in-progress" && to === "in-review")) throw refuse("--issue applies only to in-progress→in-review");
let next = { ...row, state: to };
let round = null;
let cand = null;
let revIssue = null;
if (to === "blocked") {
if (from === "blocked") throw refuse(`row ${row.id} is already blocked; update the reason with note`);
if (!NON_TERMINAL.has(from)) throw illegal();
ownerOrPriv(row, by, "block");
if (reason === null) throw refuse("a move to blocked needs --reason");
next = { ...next, previousState: from, blockedReason: reason };
} else if (from === "blocked") {
if (to !== row.previousState) throw refuse(`row ${row.id} is blocked from ${row.previousState} and returns only there`);
ownerOrPriv(row, by, "unblock");
next = { ...next, previousState: null, blockedReason: null };
} else if (from === "queued" && to === "briefed") {
requirePriv(by, "accept a brief (queued→briefed)");
} else if (from === "briefed" && to === "in-progress") {
if (by !== row.owner) throw refuse(`only the owner (${row.owner}) may start row ${row.id}`);
const missing = afterSatisfied(rows, row);
if (missing.length) throw refuse(`row ${row.id} waits on ${missing.join(", ")}`);
next.claim = { seat: row.owner, op: entry.op };
} else if (from === "in-progress" && to === "briefed") {
throw refuse(`use \`queue release ${row.id}\` to give up a claim`);
} else if (from === "in-progress" && to === "in-review") {
if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant (${row.claim?.seat ?? "nobody"}) may request review of row ${row.id}`);
if (candidate === null) throw refuse("in-progress→in-review needs --candidate <commit|manifest>");
revIssue = reviewIssue(row, issue);
cand = checkCandidate(resolved.candidate);
const rounds = row.review ? row.review.rounds : [];
round = rounds.length + 1;
next.review = {
issue: revIssue,
rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, candidate: cand, request: "none" }],
};
} else if (from === "in-review" && (to === "in-progress" || to === "waiting-on-jason")) {
ownerOrPriv(row, by, `move to ${to}`);
} else if (from === "waiting-on-jason" && to === "done") {
if (by === "sage") {
if (evidence === null) throw refuse("sage closes a waiting-on-jason row only with --evidence citing Jason's approval");
} else {
requireJason(by, "close a waiting-on-jason row without cited evidence");
}
next.claim = null;
} else if (from === "in-review" && to === "done") {
if (row.gateOwner === "jason") throw refuse(`row ${row.id}'s gate is Jason's; it goes through waiting-on-jason`);
if (by !== row.gateOwner && !isPriv(by)) throw refuse(`only the gate owner (${row.gateOwner}) or a privileged actor may close row ${row.id}`);
const ev = parseReviewEvidence(evidence);
const cur = row.review?.rounds.at(-1);
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
round = cur.n;
next.claim = null;
} else if ((from === "queued" || from === "briefed") && to === "parked") {
requireJason(by, "park a row");
if (row.required) throw refuse(`row ${row.id} is required and cannot be parked`);
} else if (from === "parked" && to === "queued") {
requireJason(by, "unpark a row");
} else {
throw illegal();
}
next = touch(next, entry);
return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand } };
}
function applySet(rows, row, entry, resolved) {
const { field, value, reason } = entry.args;
const by = entry.by;
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
if (reason !== null && field !== "closes") throw refuse("--reason applies only to closes");
let key = field;
let next = { ...row };
switch (field) {
case "piece": case "gate": case "reviewers": case "issues":
requirePriv(by, `change ${field}`);
next[field] = value;
if (field === "issues") next.closes = value;
break;
case "gate-owner":
requirePriv(by, "change the gate owner");
key = "gateOwner";
next.gateOwner = value;
break;
case "after":
if (row.required) requireJason(by, "change after on a required row");
else requirePriv(by, "change after");
next.after = value;
break;
case "closes":
requirePriv(by, "narrow closes");
if (reason === null) throw refuse("narrowing closes needs --reason, which is logged");
for (const n of value) if (!row.issues.includes(n)) throw refuse(`closes #${n} is not one of row ${row.id}'s issues`);
next.closes = value;
break;
case "brief":
requirePriv(by, "re-pin a brief");
next.brief = checkBrief(resolved.brief);
if (next.brief.path !== parseBriefSpec(value).path || next.brief.anchor !== parseBriefSpec(value).anchor) throw refuse("resolved brief does not match the brief argument");
if (row.brief !== null && next.brief.blob === row.brief.blob && sameJson(next.brief, row.brief)) throw refuse(`row ${row.id} brief is already pinned to ${row.brief.blob}`);
break;
case "required":
if (value) {
requirePriv(by, "set required");
if (row.state === "parked") throw refuse(`row ${row.id} is parked; unpark it before making it required`);
next.requiredSince = entry.at;
} else {
requireJason(by, "clear required");
next.requiredSince = null;
}
next.required = value;
break;
default:
throw refuse(`set cannot change ${field}`);
}
if (sameJson(next[key], row[key]) && (field !== "issues" || sameJson(next.closes, row.closes))) {
throw refuse(`row ${row.id} ${key} is already ${fmt(row[key])}`);
}
return { row: touch(next, entry), result: { row: row.id, field: key, from: row[key], to: next[key] } };
}
// One log entry against the state before it. `resolved` carries what the
// verb took from outside (a pinned brief, a candidate, the old witness): at
// write time from the checks in store.mjs, in replay from the entry's own
// result. Returns the new state and the result to record.
export function applyEntry(state, entry, resolved) {
const rows = new Map(state.rows);
let highWater = state.highWater;
const rev = state.revision + 1;
const by = entry.by;
const a = entry.args;
let result;
switch (entry.verb) {
case "add": {
const priv = isPriv(by);
if (!priv) {
for (const k of ["owner", "gateOwner", "after", "reviewers", "required"]) {
if (a[k] !== null && !(k === "owner" && a[k] === by)) throw refuse(`only a privileged actor may set ${k} on add`);
}
}
const brief = checkBrief(resolved.brief);
if (brief.path !== parseBriefSpec(a.brief).path || brief.anchor !== parseBriefSpec(a.brief).anchor) throw refuse("resolved brief does not match the brief argument");
const id = highWater + 1;
highWater = id;
const required = a.required ?? false;
const row = {
id, piece: a.piece, owner: a.owner ?? by, issues: a.issues, closes: a.issues, state: "queued", previousState: null,
required, requiredSince: required ? entry.at : null, gate: a.gate, gateOwner: a.gateOwner ?? "jason", brief,
after: a.after ?? [], reviewers: a.reviewers ?? [], review: null, claim: null, note: a.note, blockedReason: null,
createdAt: entry.at, updatedAt: entry.at, updatedBy: by,
};
rows.set(id, row);
result = { row: id, from: null, to: "queued", brief, receipt: receipt(entry, rev, `row ${id} none→queued`) };
break;
}
case "move": {
const row = getRow(rows, a.id);
const out = applyMove(rows, row, entry, resolved);
rows.set(row.id, out.row);
const r = out.result;
result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}${r.issue ? ` on #${r.issue}` : ""}`) };
break;
}
case "release": {
const row = getRow(rows, a.id);
if (row.state !== "in-progress") throw refuse(`row ${row.id} is ${row.state}; release applies to in-progress rows`);
if (!(row.claim && by === row.claim.seat) && !isPriv(by)) throw refuse(`only the claimant (${row.claim?.seat}) or a privileged actor may release row ${row.id}`);
rows.set(row.id, touch({ ...row, state: "briefed", claim: null }, entry));
result = { row: row.id, from: "in-progress", to: "briefed", receipt: receipt(entry, rev, `row ${row.id} in-progress→briefed`) };
break;
}
case "assign": {
const row = getRow(rows, a.id);
requirePriv(by, "assign a row");
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
if (row.owner === a.seat) throw refuse(`row ${row.id} is already owned by ${a.seat}`);
const claim = row.claim ? { seat: a.seat, op: entry.op } : null;
rows.set(row.id, touch({ ...row, owner: a.seat, claim }, entry));
result = { row: row.id, field: "owner", from: row.owner, to: a.seat, receipt: receipt(entry, rev, `row ${row.id} owner: ${row.owner}→${a.seat}`) };
break;
}
case "note": {
const row = getRow(rows, a.id);
if (row.state === "done" || row.state === "parked") throw refuse(`row ${row.id} is ${row.state}; notes are closed`);
if (by !== row.owner && !row.reviewers.includes(by) && !isPriv(by)) throw refuse(`only the owner (${row.owner}), a listed reviewer or a privileged actor may note row ${row.id}`);
// On a blocked row the note is the reason (8.7: "update the reason
// with note"); elsewhere it is the row's note, and "" clears it.
const field = row.state === "blocked" ? "blockedReason" : "note";
if (field === "blockedReason" && a.text === "") throw refuse(`row ${row.id} is blocked and its reason cannot be empty`);
const text = a.text === "" ? null : a.text;
if (row[field] === text) throw refuse(`row ${row.id} already has that ${field}`);
rows.set(row.id, touch({ ...row, [field]: text }, entry));
result = { row: row.id, field, receipt: receipt(entry, rev, `row ${row.id} ${field}`) };
break;
}
case "set": {
const row = getRow(rows, a.id);
const out = applySet(rows, row, entry, resolved);
rows.set(row.id, out.row);
const r = out.result;
result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.field}: ${fmt(r.from)}→${fmt(r.to)}`) };
break;
}
case "accept-history": {
requirePriv(by, "accept history");
const old = resolved.oldWitness;
if (old !== null) {
keysExactly(old, ["revision", "logDigest"], "old witness");
if (!Number.isInteger(old.revision) || old.revision < 0 || !SHA256_RE.test(old.logDigest)) throw refuse("old witness is malformed");
}
result = {
oldWitness: old,
warning: "ops in the lost range are no longer deduplicated",
receipt: receipt(entry, rev, `accept-history over witness ${old ? `rev ${old.revision}` : "absent"}`),
};
break;
}
case "genesis":
throw refuse("genesis is only log[0]");
default:
throw refuse(`unknown verb ${entry.verb}`);
}
validateRows([...rows.values()]);
return { state: { rows, highWater, retired: state.retired, revision: rev }, result };
}
// What a verb took from outside, read back from its recorded result.
export function resolvedFromResult(verb, args, result) {
if (!isObj(result)) throw refuse("entry result must be an object");
if (verb === "add") return { brief: result.brief };
if (verb === "move") return { candidate: result.candidate };
if (verb === "set" && args.field === "brief") return { brief: result.to };
if (verb === "accept-history") return { oldWitness: result.oldWitness };
return {};
}
// --- genesis ---
export function parseMigrationMap(text) {
const blocks = [...text.matchAll(/^```json queue-map\n([\s\S]*?)^```$/gm)];
if (blocks.length !== 1) throw refuse("the migration map must hold exactly one ```json queue-map block");
let map;
try { map = JSON.parse(blocks[0][1]); } catch (err) { throw refuse(`the migration map's queue-map block is not JSON: ${err.message}`); }
keysExactly(map, ["rows", "retired", "highWater"], "migration map");
if (!Array.isArray(map.rows)) throw refuse("migration map rows must be a list");
map.rows.forEach((r) => {
keysExactly(r, MAP_ROW_KEYS, `migration map row ${r?.id}`);
if (r.brief !== null) keysExactly(r.brief, ["path", "anchor"], `migration map row ${r.id} brief`);
});
if (!Array.isArray(map.retired)) throw refuse("migration map retired must be a list");
map.retired.forEach((id) => checkId(id, "retired id"));
checkId(map.highWater, "highWater");
return map;
}
// Rows at genesis: the map's rows, briefs pinned to HEAD blobs, a claim for
// any row already under way so its owner can move it on.
export function genesisRows(map, blobs, op, at, by) {
return map.rows.map((r) => {
const s = r.state === "blocked" ? r.previousState : r.state;
return {
id: r.id, piece: r.piece, owner: r.owner, issues: r.issues, closes: r.closes, state: r.state,
previousState: r.previousState, required: r.required, requiredSince: r.requiredSince, gate: r.gate,
gateOwner: r.gateOwner, brief: r.brief === null ? null : { path: r.brief.path, anchor: r.brief.anchor, blob: blobs.get(r.id) },
after: r.after, reviewers: r.reviewers, review: null, claim: CLAIM_KEPT.has(s) ? { seat: r.owner, op } : null,
note: r.note, blockedReason: r.blockedReason, createdAt: r.createdAt, updatedAt: at, updatedBy: by,
};
}).sort((x, y) => x.id - y.id);
}
function checkGenesis(entry) {
if (entry.verb !== "genesis" || entry.rev !== 0) throw refuse("log[0] must be the genesis entry at rev 0");
if (!PRIVILEGED.has(entry.by)) throw refuse("genesis must be by a privileged actor");
const r = entry.result;
keysExactly(r, ["mapBlob", "highWater", "retired", "rows", "legacyView", "receipt"], "genesis result");
if (!BLOB_RE.test(r.mapBlob)) throw refuse("genesis mapBlob must be a git blob id");
if (!Array.isArray(r.retired)) throw refuse("genesis retired must be a list");
const retired = new Set(r.retired.map((id) => checkId(id, "retired id")));
if (typeof r.legacyView !== "string") throw refuse("genesis legacyView must be text");
const byId = validateRows(r.rows);
for (let i = 1; i < r.rows.length; i++) if (r.rows[i].id <= r.rows[i - 1].id) throw refuse("genesis rows must be sorted by id");
for (const id of retired) if (byId.has(id)) throw refuse(`retired id ${id} is also a row`);
const top = Math.max(0, ...byId.keys(), ...retired);
checkId(r.highWater, "highWater");
if (r.highWater < top) throw refuse(`genesis highWater ${r.highWater} is below the highest id ${top}`);
for (const row of r.rows) {
if (row.updatedAt !== entry.at || row.updatedBy !== entry.by) throw refuse(`genesis row ${row.id} must carry the genesis time and actor`);
if (row.review !== null) throw refuse(`genesis row ${row.id} cannot carry a review`);
const s = row.state === "blocked" ? row.previousState : row.state;
const want = CLAIM_KEPT.has(s) ? { seat: row.owner, op: entry.op } : null;
if (!sameJson(row.claim, want)) throw refuse(`genesis row ${row.id} claim must be ${fmt(want)}`);
}
if (r.receipt !== genesisReceipt(entry.op, r.rows.length)) throw refuse("genesis receipt does not match");
return { rows: new Map(r.rows.map((x) => [x.id, x])), highWater: r.highWater, retired, revision: 0 };
}
export function genesisReceipt(op, count) {
return `ok ${op} rev 0 genesis ${count} rows`;
}
// --- document ---
export function serialize(doc) {
return JSON.stringify(doc, null, 2) + "\n";
}
export function logDigest(log, revision) {
return sha256(JSON.stringify(log.slice(0, revision + 1)));
}
export function rowsArray(state) {
return [...state.rows.values()].sort((a, b) => a.id - b.id);
}
function checkEntryShape(e, i) {
keysExactly(e, ENTRY_KEYS, `log entry ${i}`);
if (e.rev !== i) throw refuse(`log entry ${i} has rev ${e.rev}`);
if (typeof e.op !== "string" || !LOG_OP_RE.test(e.op)) throw refuse(`log entry ${i} op ${JSON.stringify(e.op)} is not an op id`);
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb ${JSON.stringify(e.verb)} is unknown`);
checkName(e.by, `log entry ${i} by`);
checkTime(e.at, `log entry ${i} at`);
if (e.semantics !== SEMANTICS) throw refuse(`log entry ${i} semantics ${e.semantics} is not ${SEMANTICS}`);
if (typeof e.viewSha !== "string" || !SHA256_RE.test(e.viewSha)) throw refuse(`log entry ${i} viewSha is not a SHA-256`);
}
// Parses, checks the schema and byte-for-byte serialization, and replays the
// log from genesis. Returns {doc, state, branch}. Any failure is a refusal.
export function loadDoc(bytes) {
let text;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw refuse("queue.json is not valid UTF-8");
}
let doc;
try { doc = JSON.parse(text); } catch (err) { throw refuse(`queue.json is not JSON: ${err.message}`); }
keysExactly(doc, DOC_KEYS, "queue.json");
if (serialize(doc) !== text) throw refuse("queue.json does not re-serialize byte for byte (a hand edit?)");
if (doc.version !== VERSION) throw refuse(`queue.json version ${doc.version} is not ${VERSION}`);
if (typeof doc.canonicalRoot !== "string" || !doc.canonicalRoot.startsWith("/")) throw refuse("canonicalRoot must be an absolute path");
if (!Array.isArray(doc.rows) || !Array.isArray(doc.log) || doc.log.length === 0) throw refuse("queue.json needs rows and a log starting at genesis");
doc.log.forEach(checkEntryShape);
const ops = new Set();
for (const e of doc.log) {
if (ops.has(e.op)) throw refuse(`op id ${e.op} appears twice in the log`);
ops.add(e.op);
}
if (doc.revision !== doc.log.length - 1) throw refuse(`revision ${doc.revision} is not the last rev ${doc.log.length - 1}`);
const state = replay(doc.log);
if (!sameJson(rowsArray(state), doc.rows)) throw refuse("rows do not equal the replay of the log (a hand edit?)");
if (doc.canonicalRoot !== doc.log[0].args.root) throw refuse("canonicalRoot differs from the genesis root");
return { doc, state, branch: doc.log[0].args.branch };
}
export function replay(log) {
const g = log[0];
if (!sameJson(canonArgs("genesis", g.args), g.args)) throw refuse("genesis args are not canonical");
let state = checkGenesis(g);
if (g.viewSha !== sha256(render(rowsArray(state), 0))) throw refuse("log entry 0 viewSha is not the render of its rows");
for (let i = 1; i < log.length; i++) {
const e = log[i];
if (e.verb === "genesis") throw refuse(`log entry ${i} is a second genesis`);
if (!sameJson(canonArgs(e.verb, e.args), e.args)) throw refuse(`log entry ${i} args are not canonical`);
if (e.verb !== "accept-history" && e.op.endsWith(".outcome")) throw refuse(`log entry ${i} uses the reserved .outcome suffix`);
let out;
try {
out = applyEntry(state, e, resolvedFromResult(e.verb, e.args, e.result));
} catch (err) {
if (err instanceof QueueError) throw refuse(`log entry ${i} (${e.op}) does not replay: ${err.message}`);
throw err;
}
if (!sameJson(out.result, e.result)) throw refuse(`log entry ${i} (${e.op}) result differs from its replay`);
state = out.state;
if (e.viewSha !== sha256(render(rowsArray(state), i))) throw refuse(`log entry ${i} viewSha is not the render of its rows`);
}
return state;
}
export function buildDoc(canonicalRoot, state, log) {
return { version: VERSION, canonicalRoot, revision: log.length - 1, rows: rowsArray(state), log };
}
// --- next (8.8) ---
// `briefMatches(row)` says whether the working brief still has the pinned blob.
export function nextFor(rows, seat, briefMatches) {
const list = [...rows.values()].sort((a, b) => a.id - b.id);
const byId = new Map(list.map((r) => [r.id, r]));
const resume = list.find((r) => r.state === "in-progress" && r.claim?.seat === seat);
if (resume) return { action: "resume", row: resume };
const review = list.find((r) => r.state === "in-review" && r.reviewers.includes(seat) && r.owner !== seat);
if (review) return { action: "review", row: review };
const start = list.find((r) => r.state === "briefed" && r.owner === seat && afterSatisfied(byId, r).length === 0);
if (start) return { action: "start", row: start, briefDiffers: !briefMatches(start) };
const wait = list.find((r) => r.state === "in-review" && r.owner === seat);
if (wait) return { action: "wait", row: wait };
return { action: "nothing", row: null };
}
// --- render ---
function cell(text) {
return String(text).replaceAll("|", "\\|");
}
function stateCell(r) {
let s = r.state;
if (r.state === "blocked") s = `blocked (from ${r.previousState}): ${r.blockedReason}`;
else if (r.state === "in-review" && r.review) s = `in-review, round ${r.review.rounds.length}`;
return r.required ? `required; ${s}` : s;
}
function issuesCell(r) {
const list = (ns) => (ns.length ? ns.map((n) => `#${n}`).join(", ") : "none");
let s = r.issues.length ? list(r.issues) : "—";
if (!sameJson(r.closes, r.issues)) s += `; closes ${list(r.closes)}`;
return s;
}
// The table body for one revision. Its SHA-256 is each entry's viewSha.
export function render(rows, revision) {
const lines = [
"",
`Generated from \`docs/plans/queue.json\` revision ${revision} by \`queue render\`. Do not edit between the markers; change the queue with \`scripts/mosaic queue\`.`,
"",
"| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |",
"|---|---|---|---|---|---|---|---|---|",
];
for (const r of [...rows].sort((a, b) => a.id - b.id)) {
const owner = r.reviewers.length ? `${r.owner}; reviewers ${r.reviewers.join(", ")}` : r.owner;
const after = r.after.length ? r.after.map((a) => `${a.id} ${a.when}`).join(", ") : "—";
const brief = r.brief ? `\`${r.brief.path}\` § ${r.brief.anchor}` : "—";
lines.push(`| ${[r.id, r.piece, owner, issuesCell(r), stateCell(r), after, `${r.gate} (${r.gateOwner})`, brief, r.note ?? "—"].map(cell).join(" | ")} |`);
}
lines.push("");
return lines.join("\n") + "\n";
}
// Splits QUEUE.md at the markers. Null when the markers are missing,
// repeated or out of order: the view is unknown.
export function splitView(text) {
const b = `${BEGIN_MARKER}\n`;
const e = `${END_MARKER}\n`;
const bi = text.indexOf(b);
const ei = text.indexOf(e);
if (bi < 0 || ei < 0 || text.indexOf(b, bi + 1) >= 0 || text.indexOf(e, ei + 1) >= 0) return null;
if (bi !== 0 && text[bi - 1] !== "\n") return null;
if (ei !== 0 && text[ei - 1] !== "\n") return null;
const bodyStart = bi + b.length;
if (ei < bodyStart) return null;
return { head: text.slice(0, bodyStart), body: text.slice(bodyStart, ei), tail: text.slice(ei) };
}
// current: the body is this revision's render; stale: an earlier logged
// render (the entries after it are unshown); unknown: anything else.
export function classifyView(text, log) {
const parts = text === null ? null : splitView(text);
if (parts === null) return { state: "unknown", why: "markers missing, repeated or out of order" };
const sha = sha256(parts.body);
const last = log.length - 1;
if (log[last].viewSha === sha) return { state: "current", parts };
for (let i = last - 1; i >= 0; i--) {
if (log[i].viewSha === sha) return { state: "stale", parts, shown: i, unshown: log.slice(i + 1) };
}
return { state: "unknown", parts, why: "the table body matches no logged render" };
}
export function describeUnshown(unshown) {
return unshown.map((e) => `rev ${e.rev} (op ${e.op} by ${e.by} at ${e.at})`).join(", ");
}
// Headings outside fenced code blocks whose text is exactly `anchor`.
export function countHeading(text, anchor) {
let fence = null;
let n = 0;
for (const line of text.split("\n")) {
const f = /^(```+|~~~+)/.exec(line);
if (f) {
if (fence === null) fence = f[1][0];
else if (f[1][0] === fence) fence = null;
continue;
}
if (fence !== null) continue;
const m = /^#{1,6}[ \t]+(.*?)[ \t]*$/.exec(line);
if (m && m[1] === anchor) n++;
}
return n;
}