queue: pin the add retry order and drop row history from the gate-owner refusal (row 107, #1601)

A retried privileged add recorded with no gate owner answers with its
receipt before the --gate-owner check runs; a test pins that order and
a hoisting mutant fails it. The refusal no longer cites row 100.
SEMANTICS stays 2. Packet: agents/researcher/work/queue-107/ (manifest
b70dfe4a). Filbert approved round 1 in comment 28286. Sage's
canonical-root gate: queue 156/0, test-queue 30/0 with queue verify.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 17:16:51 -05:00
co-authored by Claude Opus 5.5
parent ae4ae7102a
commit fe27adea1c
2 changed files with 44 additions and 5 deletions
+4 -3
View File
@@ -528,11 +528,12 @@ function mutateAs(ctx, { verb, op, args, by, yes = false, derived = false }) {
if (view.state === "unknown") throw refuse(unknownMessage(view)); if (view.state === "unknown") throw refuse(unknownMessage(view));
if (view.state === "stale" && verb !== "accept-history") throw refuse(staleMessage(view, cur.doc.log)); if (view.state === "stale" && verb !== "accept-history") throw refuse(staleMessage(view, cur.doc.log));
// A privileged add names its gate owner. There is no default for it: the // A privileged add names its gate owner. There is no default for it: the
// Jason default put a gate nobody meant on row 100. This check lives in the // Jason default put a gate nobody meant on a row. This check lives in the
// write path, not in applyEntry, so replay of old entries is unchanged and an // write path, not in applyEntry, so replay of old entries is unchanged and an
// older reader still loads the log. // older reader still loads the log. It stays below step 3: a retry of an add
// recorded before this check answers with its receipt.
if (verb === "add" && PRIVILEGED.has(actor) && cargs.gateOwner === null) { if (verb === "add" && PRIVILEGED.has(actor) && cargs.gateOwner === null) {
throw refuse(`${actor} must pass --gate-owner SEAT on add: a privileged add has no default gate owner, so a row never gets a gate nobody meant (row 100 got jason by default)`); throw refuse(`${actor} must pass --gate-owner SEAT on add: a privileged add has no default gate owner, so a row never gets a gate nobody meant`);
} }
// Step 5. // Step 5.
const resolved = resolveFor(ctx, loc, cur, verb, cargs, cmp); const resolved = resolveFor(ctx, loc, cur, verb, cargs, cmp);
+40 -2
View File
@@ -3,10 +3,11 @@
// scratch repository. Replay is exercised through loadDoc, which runs the real // scratch repository. Replay is exercised through loadDoc, which runs the real
// replay path over a log that holds an old privileged add with no gate owner. // replay path over a log that holds an old privileged add with no gate owner.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { import {
applyEntry, buildDoc, canonArgs, genesisReceipt, genesisRows, loadDoc, parseMigrationMap, render, rowsArray, serialize, sha256, applyEntry, buildDoc, canonArgs, genesisReceipt, genesisRows, loadDoc, logDigest, parseMigrationMap, render, rowsArray, serialize, sha256, splitView,
} from "../src/queue.mjs"; } from "../src/queue.mjs";
import { QueueError } from "../src/errors.mjs"; import { QueueError } from "../src/errors.mjs";
import { MAP_ROWS, cli, genesisCommitted, mapText, scratchRepo } from "./helpers.mjs"; import { MAP_ROWS, cli, genesisCommitted, mapText, scratchRepo } from "./helpers.mjs";
@@ -82,6 +83,7 @@ test("the CLI refuses a privileged add without --gate-owner, for jason and sage,
assert.equal(r.code, 2, r.err); assert.equal(r.code, 2, r.err);
assert.match(r.err, /--gate-owner/); assert.match(r.err, /--gate-owner/);
assert.match(r.err, /no default gate owner/); assert.match(r.err, /no default gate owner/);
assert.doesNotMatch(r.err, /row \d+/, "the refusal states the rule, not a row's history");
assert.deepEqual(readFileSync(repo.queuePath), queueBefore); assert.deepEqual(readFileSync(repo.queuePath), queueBefore);
assert.deepEqual(readFileSync(repo.viewPath), viewBefore); assert.deepEqual(readFileSync(repo.viewPath), viewBefore);
assert.equal(JSON.parse(readFileSync(repo.queuePath, "utf8")).revision, revBefore); assert.equal(JSON.parse(readFileSync(repo.queuePath, "utf8")).revision, revBefore);
@@ -113,3 +115,39 @@ test("the CLI add by a non-privileged seat gets jason and can't name a gate owne
assert.match(refusedRun.err, /only a privileged actor may set gateOwner on add/); assert.match(refusedRun.err, /only a privileged actor may set gateOwner on add/);
assert.deepEqual(readFileSync(repo.queuePath), before); assert.deepEqual(readFileSync(repo.queuePath), before);
}); });
// Step 3 of the write path answers a recorded op before the gate-owner check
// runs. An add recorded before row 101 has a null gate owner, so a retry of it
// carries none either; hoisting the check above step 3 would refuse that retry.
// Every other retry test passes --gate-owner, so this one pins the order.
test("a retried sage add recorded with no gate owner answers (already recorded at rev N) and writes nothing", (t) => {
const repo = scratchRepo(t);
genesisCommitted(repo);
const before = JSON.parse(readFileSync(repo.queuePath, "utf8"));
const doc = addUnder(before, "sage", addArgs(), 2);
const entry = doc.log.at(-1);
assert.equal(entry.args.gateOwner, null);
assert.equal(entry.by, "sage");
// The state the old write path left: queue.json, the view and the witness all at the add's rev.
const bytes = Buffer.from(serialize(doc));
const parts = splitView(readFileSync(repo.viewPath, "utf8"));
writeFileSync(repo.queuePath, bytes);
writeFileSync(repo.viewPath, parts.head + render(doc.rows, entry.rev) + parts.tail);
writeFileSync(join(repo.gitDir, "mosaic-queue.head"), `${JSON.stringify({ revision: entry.rev, logDigest: logDigest(doc.log, entry.rev), fileSha: sha256(bytes), at: entry.at })}\n`);
const queueBefore = readFileSync(repo.queuePath);
const viewBefore = readFileSync(repo.viewPath);
const retry = ["add", "--op", entry.op, "--piece", "Gate row", "--gate", "g", "--brief", "docs/plans/brief-b.md#Queue"];
const r = cli(repo, retry, { by: "sage" });
assert.equal(r.code, 0, r.err);
assert.match(r.out, new RegExp(`\\(already recorded at rev ${entry.rev}\\)`));
assert.deepEqual(readFileSync(repo.queuePath), queueBefore);
assert.deepEqual(readFileSync(repo.viewPath), viewBefore);
assert.equal(JSON.parse(readFileSync(repo.queuePath, "utf8")).revision, entry.rev);
// The check still bites a new op with the same arguments.
const fresh = cli(repo, ["add", "--op", "add-gate-fresh-1", ...retry.slice(3)], { by: "sage" });
assert.equal(fresh.code, 2, fresh.err);
assert.match(fresh.err, /--gate-owner/);
assert.deepEqual(readFileSync(repo.queuePath), queueBefore);
});