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:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user