diff --git a/packages/queue/src/store.mjs b/packages/queue/src/store.mjs index a28a60e87..4a26e2058 100644 --- a/packages/queue/src/store.mjs +++ b/packages/queue/src/store.mjs @@ -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 === "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 - // 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 - // 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) { - 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. const resolved = resolveFor(ctx, loc, cur, verb, cargs, cmp); diff --git a/packages/queue/tests/gate-owner.test.mjs b/packages/queue/tests/gate-owner.test.mjs index b3407dbc6..b01e5723b 100644 --- a/packages/queue/tests/gate-owner.test.mjs +++ b/packages/queue/tests/gate-owner.test.mjs @@ -3,10 +3,11 @@ // 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. 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 { - 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"; import { QueueError } from "../src/errors.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.match(r.err, /--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.viewPath), viewBefore); 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.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); +});