Skip to content

fix(storage_engine): never reissue revisions across restarts so a stale expect_revision is refused - #390

Merged
jsam merged 8 commits into
mainfrom
fm/il-380-expect-rev
Oct 6, 2026
Merged

jsam merged 8 commits into
mainfrom
fm/il-380-expect-rev

Conversation

@jsam

@jsam jsam commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Fix GitHub issue #380 in inputlayer/inputlayer (production readiness, correctness): "Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused".

Found by the scenario suite (S12, #336): after a crash/restart the engine starts a new stream epoch and revisions restart, so a write carrying a pre-crash expect_revision but no expect_epoch can match a new revision with the same number and be accepted silently. A precondition must never pass on a different history. Fix: make revisions monotonic across restarts (persist the counter), or refuse expect_revision without a matching epoch after a restart. Add the bare pre-crash case to S12 as a required assertion.

Deliver it as a small PR through the merge train; done means merged with CI green.

What Changed

  • Revisions now continue across engine restarts instead of restarting from 1. A new storage_engine::revisions module keeps a durable upper bound on issued revisions in revisions.json at the data-directory root, raised a block (2^20) at a time; a starting engine continues above the bound it finds (or from a high floor of 2^40 when the directory has state but no recorded bound). A pre-crash expect_revision therefore predates the knowledge graph in the new run and fails with precondition_failed, with or without expect_epoch.
  • Write paths reserve headroom before changing state, so a write is refused when the bound cannot be made durable, and an unreadable bound fails engine open. The engine releases its reservation on drop, before the data-directory lock, so a closed directory is not written to.
  • S12 (tests/scenarios/restart.rs) now requires the bare pre-crash expect_revision to be refused (the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 KnownDefect marker is removed) and asserts that the first post-restart revision exceeds the last pre-crash one. Unit tests are added for the revision counter and bound; the WebSocket epoch-mismatch error text, protocol doc comments, AsyncAPI spec, guides, TESTING.md and the regenerated docs bundle are updated to match.

Risk Assessment

✅ Low: Both round 6 decisions are applied as instructed (bound moved to revisions.json under the data directory root, where no graph name can collide and offline backup still copies it; dead closed-engine branch and its test removed), and tracing open, raise, close, write-failure and restart sequences found no path that issues a revision above the durable bound; the only remaining gap is the restore-from-backup limit the operator already deferred.

Testing

Ran the S12 restart scenario, which spawns and crash-restarts the real server binary, and it passed as a required assertion. Then drove a hand-written WebSocket client against the real server on a throwaway data directory through five runs separated by SIGKILL: 20 of 20 checks passed, covering bare, epoch-pinned and unscoped stale preconditions, a sweep of every pre-crash revision number, a current precondition still committing, a dropped graph named metadata, and a deleted revisions.json. No UI surface is involved, so the evidence is protocol transcripts. The temp data directories were removed and the worktree is clean.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
S12: an agent's claim expecting a pre-crash revision is refused after a crash/restart, both pinned to its epoch and bare, and the first post-restart revision exceeds the last pre-crash one ✅ pass live s12-scenario.log: restart::s12_restart_mid_scenario_preserves_revisions ... ok
A client sends a write with a bare pre-crash expect_revision (no epoch, scoped to claim) after SIGKILL and restart; the server replies precondition_failed and applies nothing ✅ pass live live-restart-transcript.log: revision 8 predates this knowledge graph's state in this engine run (from revision 1048579) ... Nothing was applied.; ?claim returns only the two run-1 rows
The same pre-crash revision is refused when pinned to the old stream epoch and when bare with whole-graph scope ✅ pass live live-restart-transcript.log: run 2 pinned and unscoped replies, both precondition_failed
Adversarial: after the new run commits 12 more writes, a client retries every pre-crash revision number (1..11) as a bare expect_revision; none is accepted ✅ pass live live-restart-transcript.log: run 2 sweep, all refused; run 2 issued 1048580..1048591 against a pre-crash last of 8
A bare expect_revision at a current revision still commits, before the crash and in the restarted run ✅ pass live live-restart-transcript.log: run 1 commits at revision 8, run 2 commits at 1048592
A second crash/restart keeps revisions rising and refuses a bare revision from the previous run ✅ pass live live-restart-transcript.log: run 3 first write at 2097156, bare run-2 revision refused
Adversarial: a knowledge graph named metadata is created and dropped, then the server is killed and restarted; the bound file survives and no revision is reissued ✅ pass live live-restart-transcript.log: Knowledge graph 'metadata' dropped., revisions.json reserved=3145728 still present, run 4 first write at 3145731
Adversarial: revisions.json is deleted from a data directory that holds state (the upgrade-from-older-version case); the restarted server still issues higher revisions and refuses a bare earlier one ✅ pass live live-restart-transcript.log: run 5 write at 1099511627781, revision 3145733 predates ... (from revision 1099511627780)
Evidence: Live crash/restart WebSocket transcript (20/20 checks)
run 1 epoch b7e98de0d6e0a7ff bound on disk 1048576
.kg create shop -> {"type":"result","id":"r1","columns":["message"],"rows":[["Knowledge graph 'shop' created."],["Switched to knowledge graph: shop"]],"row_count":2,"total_count":2,"truncated":false,"execution_time_ms":933,"row_provenance":["unknown","unknown"],"switched_kg":"shop","errors":[],"revision":5}
run 1 write -> {"type":"result","id":"r1","columns":["message"],"rows":[["Inserted 1 fact(s) into 'stock'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":1353,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}],"revision":6}
run 1 claim -> {"type":"result","id":"r2","columns":["message"],"rows":[["Inserted 1 fact(s) into 'claim'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":730,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}],"revision":7}
PASS  run 1: a write reports a revision  -- revision 7
PASS  run 1: the data directory records a bound at or above every issued revision  -- revisions.json reserved=1048576, last revision 7
run 1 current expect_revision -> {"type":"result","id":"r3","columns":["message"],"rows":[["Inserted 1 fact(s) into 'claim'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":745,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}],"revision":8}
PASS  run 1: a current bare expect_revision commits  -- {"type":"result","id":"r3","columns":["message"],"rows":[["Inserted 1 fact(s) into 'claim'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":745,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}],"revision":8}

-- SIGKILL, restart --
run 2 epoch c6dea19ae39da1f3 bound on disk 2097152
PASS  run 2: a new stream epoch  -- b7e98de0d6e0a7ff -> c6dea19ae39da1f3
run 2 bare stale expect_revision -> {"type":"error","id":"r1","message":"Precondition failed: revision 8 predates this knowledge graph's state in this engine run (from revision 1048579); read it again. Nothing was applied.","code":"precondition_failed"}
PASS  run 2: a bare pre-crash expect_revision is refused with precondition_failed  -- {"type":"error","id":"r1","message":"Precondition failed: revision 8 predates this knowledge graph's state in this engine run (from revision 1048579); read it again. Nothing was applied.","code":"precondition_failed"}
run 2 pinned stale expect_revision -> {"type":"error","id":"r2","message":"Precondition failed: expect_epoch is not this engine run's stream epoch (c6dea19ae39da1f3); the revision is from another engine run. Nothing was applied.","code":"precondition_failed"}
PASS  run 2: a pre-crash expect_revision pinned to its epoch is refused  -- {"type":"error","id":"r2","message":"Precondition failed: expect_epoch is not this engine run's stream epoch (c6dea19ae39da1f3); the revision is from another engine run. Nothing was applied.","code":"precondition_failed"}
run 2 bare unscoped stale expect_revision -> {"type":"error","id":"r3","message":"Precondition failed: revision 8 predates this knowledge graph's state in this engine run (from revision 1048579); read it again. Nothing was applied.","code":"precondition_failed"}
PASS  run 2: a bare unscoped pre-crash expect_revision is refused  -- {"type":"error","id":"r3","message":"Precondition failed: revision 8 predates this knowledge graph's state in this engine run (from revision 1048579); read it again. Nothing was applied.","code":"precondition_failed"}
run 2 writes issued revisions 1048580..1048591
PASS  run 2: the first revision exceeds every pre-crash revision  -- pre-crash last 8, post-restart first 1048580
PASS  run 2: after more writes, every bare expect_revision in 1..11 is still refused  -- all refused with precondition_failed
run 2 claim rows -> {"type":"result","id":"r27","columns":["O","I"],"rows":[["o-1","i1"],["o-1","i2"]],"row_count":2,"total_count":2,"truncated":false,"execution_time_ms":3,"row_provenance":["unknown","unknown"],"timing_breakdown":{"total_us":2976,"parse_us":260,"sip_us":102,"magic_sets_us":48,"ir_build_us":110,"optimize_us":86,"shared_views_us":0},"errors":[]}
PASS  run 2: refused claims applied nothing (only the two run-1 claims exist)  -- [["o-1","i1"],["o-1","i2"]]
run 2 current expect_revision -> {"type":"result","id":"r28","columns":["message"],"rows":[["Inserted 1 fact(s) into 'claim'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":88,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}],"revision":1048592}
PASS  run 2: a current bare expect_revision commits  -- {"type":"result","id":"r28","columns":["message"],"rows":[["Inserted 1 fact(s) into 'claim'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":88,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}],"revision":1048592}
PASS  run 2: the bound on disk rose  -- 1048576 -> 2097152

-- SIGKILL, restart (third run) --
run 3 write -> {"type":"result","id":"r1","columns":["message"],"rows":[["Inserted 1 fact(s) into 'stock'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":10,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}],"revision":2097156}
PASS  run 3: revisions continue above run 2  -- 1048592 -> 2097156
PASS  run 3: a bare run-2 expect_revision is refused  -- {"type":"error","id":"r2","message":"Precondition failed: revision 1048592 predates this knowledge graph's state in this engine run (from revision 2097155); read it again. Nothing was applied.","code":"precondition_failed"}
run 3 .kg create metadata -> {"type":"result","id":"r4","columns":["message"],"rows":[["Knowledge graph 'metadata' created."],["Switched to knowledge graph: metadata"]],"row_count":2,"total_count":2,"truncated":false,"execution_time_ms":7,"row_provenance":["unknown","unknown"],"switched_kg":"metadata","errors":[],"revision":2097158}
run 3 .kg use shop -> {"type":"result","id":"r5","columns":["message"],"rows":[["Switched to knowledge graph: shop"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"switched_kg":"shop","errors":[]}
run 3 .kg drop metadata -> {"type":"result","id":"r6","columns":["message"],"rows":[["Knowledge graph 'metadata' dropped."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":35,"row_provenance":["unknown"],"errors":[]}
PASS  run 3: the graph named metadata was dropped  -- {"type":"result","id":"r6","columns":["message"],"rows":[["Knowledge graph 'metadata' dropped."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":35,"row_provenance":["unknown"],"errors":[]}
data dir after the drop: LOCK _internal default metadata persist revisions.json shop
PASS  run 3: dropping a graph named metadata leaves the bound file in place  -- revisions.json reserved=3145728

-- SIGKILL, restart (fourth run) --
run 4 write -> {"type":"result","id":"r1","columns":["message"],"rows":[["Inserted 1 fact(s) into 'stock'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":8,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}],"revision":3145731}
PASS  run 4: revisions continue above the bound  -- bound 3145728 -> 3145731

-- SIGKILL, revisions.json removed, restart (fifth run) --
run 5 write -> {"type":"result","id":"r1","columns":["message"],"rows":[["Inserted 1 fact(s) into 'stock'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":9,"row_provenance":["unknown"],"errors":[],"statements":[{"index":0,"kind":"insert","inserted":1,"deleted":0}],"revision":1099511627779} bound on disk 1099512676352
PASS  run 5: with state but no recorded bound, revisions still continue above every earlier one  -- 3145731 -> 1099511627779
run 5 bare stale expect_revision -> {"type":"error","id":"r2","message":"Precondition failed: revision 3145731 predates this knowledge graph's state in this engine run (from revision 1099511627778); read it again. Nothing was applied.","code":"precondition_failed"}
PASS  run 5: a bare run-4 expect_revision is refused  -- {"type":"error","id":"r2","message":"Precondition failed: revision 3145731 predates this knowledge graph's state in this engine run (from revision 1099511627778); read it again. Nothing was applied.","code":"precondition_failed"}
PASS  run 5: only committed claims exist  -- [["o-1","fresh"],["o-1","i1"],["o-1","i2"]]

20/20 checks passed
Evidence: Driver script used for the live transcript
// Live driver for #380: starts the real inputlayer-server on a throwaway data
// directory, kills it with SIGKILL, restarts it, and checks expect_revision
// over the WebSocket API. Usage: node drive.mjs <server-binary> <work-dir>
import { spawn } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";

const [binary, work] = process.argv.slice(2);
const data = path.join(work, "data");
const PASSWORD = "drive-admin-password";
const results = [];
let failed = false;

function log(...a) {
  console.log(...a);
}
function check(name, ok, detail) {
  results.push({ name, ok, detail });
  if (!ok) failed = true;
  log(`${ok ? "PASS" : "FAIL"}  ${name}${detail ? "  -- " + detail : ""}`);
}

function freePort() {
  return new Promise((res, rej) => {
    const s = net.createServer();
    s.listen(0, "127.0.0.1", () => {
      const p = s.address().port;
      s.close(() => res(p));
    });
    s.on("error", rej);
  });
}

let child = null;
let port = 0;
async function start() {
  port = await freePort();
  const config = `[storage]
data_dir = ${JSON.stringify(data)}

[http]
enabled = true
host = "127.0.0.1"
port = ${port}

[http.auth]
bootstrap_admin_password = "${PASSWORD}"
credentials_file = ${JSON.stringify(path.join(work, "credentials.toml"))}

[http.rate_limit]
ws_max_messages_per_sec = 0
per_ip_max_rps = 0

[logging]
level = "warn"
`;
  fs.writeFileSync(path.join(work, "engine.toml"), config);
  const out = fs.openSync(path.join(work, "server.log"), "a");
  const env = Object.fromEntries(
    Object.entries(process.env).filter(([k]) => !k.startsWith("INPUTLAYER_")),
  );
  child = spawn(binary, ["--config", path.join(work, "engine.toml")], {
    cwd: work,
    env,
    stdio: ["ignore", out, out],
  });
  let exited = null;
  child.on("exit", (code, sig) => (exited = { code, sig }));
  for (let i = 0; i < 600; i++) {
    if (exited) throw new Error(`server exited at startup: ${JSON.stringify(exited)}`);
    try {
      const r = await fetch(`http://127.0.0.1:${port}/health`);
      if (r.ok) return;
    } catch {}
    await new Promise((r) => setTimeout(r, 100));
  }
  throw new Error("server not healthy");
}
function crash() {
  return new Promise((res) => {
    child.once("exit", () => res());
    child.kill("SIGKILL");
  });
}

// One request at a time.
async function connect(kg) {
  const ws = new WebSocket(`ws://127.0.0.1:${port}/ws${kg ? "?kg=" + kg : ""}`);
  // Replies are matched by id; pushes (persistent_update, deltas) are skipped.
  const waiting = new Map();
  ws.onmessage = (m) => {
    const frame = JSON.parse(m.data);
    const key = frame.id ?? (frame.type === "authenticated" || frame.type === "auth_error" ? "auth" : null);
    const waiter = key && waiting.get(key);
    if (waiter) {
      waiting.delete(key);
      waiter(frame);
    }
  };
  await new Promise((res, rej) => {
    ws.onopen = res;
    ws.onerror = (e) => rej(new Error("ws error " + (e.message ?? "")));
  });
  const send = (frame) =>
    new Promise((res) => {
      waiting.set(frame.id ?? "auth", res);
      ws.send(JSON.stringify(frame));
    });
  const auth = await send({ type: "login", username: "admin", password: PASSWORD });
  if (auth.type !== "authenticated") throw new Error("auth failed: " + JSON.stringify(auth));
  let n = 0;
  return {
    epoch: auth.stream_epoch,
    exec: (program, extra = {}) => send({ type: "execute", id: `r${++n}`, program, ...extra }),
    close: () => ws.close(),
  };
}

const bound = () => {
  const p = path.join(data, "revisions.json");
  return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")).reserved : null;
};
const short = (f) => JSON.stringify(f).slice(0, 400);

try {
  // ---- run 1 -------------------------------------------------------------
  await start();
  let c = await connect();
  log("run 1 epoch", c.epoch, "bound on disk", bound());
  log(".kg create shop ->", short(await c.exec(".kg create shop")));
  c.close();
  c = await connect("shop");
  const epoch1 = c.epoch;
  let r = await c.exec('+stock("i1", 5)');
  log("run 1 write ->", short(r));
  r = await c.exec('+claim("o-1", "i1")');
  log("run 1 claim ->", short(r));
  const preCrash = r.revision;
  const bound1 = bound();
  check("run 1: a write reports a revision", Number.isInteger(preCrash), `revision ${preCrash}`);
  check(
    "run 1: the data directory records a bound at or above every issued revision",
    bound1 !== null && bound1 >= preCrash,
    `revisions.json reserved=${bound1}, last revision ${preCrash}`,
  );
  // Sanity: within one run a current expect_revision is accepted.
  r = await c.exec('+claim("o-1", "i2")', { expect_revision: preCrash, expect_relations: ["claim"] });
  log("run 1 current expect_revision ->", short(r));
  check("run 1: a current bare expect_revision commits", r.type === "result" && !r.code, short(r));
  const lastRun1 = r.revision;
  c.close();

  // ---- crash, run 2 ------------------------------------------------------
  await crash();
  log("\n-- SIGKILL, restart --");
  await start();
  c = await connect("shop");
  const epoch2 = c.epoch;
  log("run 2 epoch", epoch2, "bound on disk", bound());
  check("run 2: a new stream epoch", epoch2 !== epoch1, `${epoch1} -> ${epoch2}`);

  // The #380 case: bare pre-crash revision, scoped to claim, no epoch.
  r = await c.exec('+claim("o-1", "stale-bare")', {
    expect_revision: lastRun1,
    expect_relations: ["claim"],
  });
  log("run 2 bare stale expect_revision ->", short(r));
  check(
    "run 2: a bare pre-crash expect_revision is refused with precondition_failed",
    r.type === "error" && r.code === "precondition_failed",
    short(r),
  );
  // Same, pinned to the old epoch.
  r = await c.exec('+claim("o-1", "stale-pinned")', {
    expect_revision: lastRun1,
    expect_relations: ["claim"],
    expect_epoch: epoch1,
  });
  log("run 2 pinned stale expect_revision ->", short(r));
  check(
    "run 2: a pre-crash expect_revision pinned to its epoch is refused",
    r.type === "error" && r.code === "precondition_failed",
    short(r),
  );
  // Bare, whole-graph scope.
  r = await c.exec('+claim("o-1", "stale-unscoped")', { expect_revision: lastRun1 });
  log("run 2 bare unscoped stale expect_revision ->", short(r));
  check(
    "run 2: a bare unscoped pre-crash expect_revision is refused",
    r.type === "error" && r.code === "precondition_failed",
    short(r),
  );

  // Advance run 2 and retry every pre-crash revision number: none may pass.
  let first2 = null;
  let last2 = null;
  for (let i = 0; i < 12; i++) {
    r = await c.exec(`+stock("n${i}", ${i})`);
    if (first2 === null) first2 = r.revision;
    last2 = r.revision;
  }
  log(`run 2 writes issued revisions ${first2}..${last2}`);
  check(
    "run 2: the first revision exceeds every pre-crash revision",
    first2 > lastRun1,
    `pre-crash last ${lastRun1}, post-restart first ${first2}`,
  );
  let accepted = [];
  for (let rev = 1; rev <= lastRun1 + 3; rev++) {
    r = await c.exec(`+claim("o-1", "sweep-${rev}")`, {
      expect_revision: rev,
      expect_relations: ["claim"],
    });
    if (!(r.type === "error" && r.code === "precondition_failed")) accepted.push([rev, short(r)]);
  }
  check(
    `run 2: after more writes, every bare expect_revision in 1..${lastRun1 + 3} is still refused`,
    accepted.length === 0,
    accepted.length ? JSON.stringify(accepted) : "all refused with precondition_failed",
  );
  r = await c.exec("?claim(O, I)");
  log("run 2 claim rows ->", short(r));
  const rows = (r.rows ?? []).map((x) => x[1]).sort();
  check(
    "run 2: refused claims applied nothing (only the two run-1 claims exist)",
    JSON.stringify(rows) === JSON.stringify(["i1", "i2"]),
    JSON.stringify(r.rows),
  );
  // A current revision of this run still commits, bare.
  r = await c.exec('+claim("o-1", "fresh")', { expect_revision: last2, expect_relations: ["claim"] });
  log("run 2 current expect_revision ->", short(r));
  check("run 2: a current bare expect_revision commits", r.type === "result" && r.revision > last2, short(r));
  const lastRun2 = r.revision;
  const bound2 = bound();
  check("run 2: the bound on disk rose", bound2 > bound1 && bound2 >= lastRun2, `${bound1} -> ${bound2}`);
  c.close();

  // ---- clean-ish second restart: still monotonic ---------------------------
  await crash();
  log("\n-- SIGKILL, restart (third run) --");
  await start();
  c = await connect("shop");
  r = await c.exec('+stock("third", 1)');
  log("run 3 write ->", short(r));
  check("run 3: revisions continue above run 2", r.revision > lastRun2, `${lastRun2} -> ${r.revision}`);
  r = await c.exec('+claim("o-1", "stale-run2")', { expect_revision: lastRun2, expect_relations: ["claim"] });
  check(
    "run 3: a bare run-2 expect_revision is refused",
    r.type === "error" && r.code === "precondition_failed",
    short(r),
  );
  const lastRun3 = (await c.exec('+stock("third", 2)')).revision;

  // ---- adversarial: a graph named `metadata` created and dropped -----------
  log("run 3 .kg create metadata ->", short(await c.exec(".kg create metadata")));
  log("run 3 .kg use shop ->", short(await c.exec(".kg use shop")));
  r = await c.exec(".kg drop metadata");
  log("run 3 .kg drop metadata ->", short(r));
  check("run 3: the graph named metadata was dropped", r.type === "result", short(r));
  log("data dir after the drop:", fs.readdirSync(data).join(" "));
  const bound3 = bound();
  check(
    "run 3: dropping a graph named metadata leaves the bound file in place",
    bound3 !== null && bound3 >= lastRun3,
    `revisions.json reserved=${bound3}`,
  );
  c.close();
  await crash();
  log("\n-- SIGKILL, restart (fourth run) --");
  await start();
  c = await connect("shop");
  r = await c.exec('+stock("fourth", 1)');
  log("run 4 write ->", short(r));
  check("run 4: revisions continue above the bound", r.revision > bound3, `bound ${bound3} -> ${r.revision}`);
  const lastRun4 = r.revision;
  c.close();

  // ---- adversarial: the bound file is lost (directory from an older version)
  await crash();
  fs.rmSync(path.join(data, "revisions.json"));
  log("\n-- SIGKILL, revisions.json removed, restart (fifth run) --");
  await start();
  c = await connect("shop");
  r = await c.exec('+stock("fifth", 1)');
  log("run 5 write ->", short(r), "bound on disk", bound());
  check(
    "run 5: with state but no recorded bound, revisions still continue above every earlier one",
    r.revision > lastRun4,
    `${lastRun4} -> ${r.revision}`,
  );
  r = await c.exec('+claim("o-1", "stale-run4")', { expect_revision: lastRun4, expect_relations: ["claim"] });
  log("run 5 bare stale expect_revision ->", short(r));
  check(
    "run 5: a bare run-4 expect_revision is refused",
    r.type === "error" && r.code === "precondition_failed",
    short(r),
  );
  r = await c.exec("?claim(O, I)");
  const rows5 = (r.rows ?? []).map((x) => x[1]).sort();
  check(
    "run 5: only committed claims exist",
    JSON.stringify(rows5) === JSON.stringify(["fresh", "i1", "i2"]),
    JSON.stringify(r.rows),
  );
  c.close();
} catch (e) {
  failed = true;
  log("DRIVER ERROR", e.stack ?? e);
} finally {
  if (child && child.exitCode === null) await crash();
}
log(`\n${results.filter((r) => r.ok).length}/${results.length} checks passed`);
process.exit(failed ? 1 : 0);
Evidence: S12 scenario run
   Compiling inputlayer v0.1.0 (~/.no-mistakes/worktrees/9eaf1dfa27af/01M472W6DAC3ZM0BD95X6YA92H)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 10.57s
     Running tests/scenarios/main.rs (target/debug/deps/scenarios-4ef9fa2b7779c052)

running 1 test
test restart::s12_restart_mid_scenario_preserves_revisions ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 36 filtered out; finished in 4.05s

exit=0
Evidence: Server replies to stale preconditions after SIGKILL and restart
run 1: last revision 8, epoch aeaabd26da1a47af (first driver run; later runs differ only in epoch)
-- SIGKILL, restart --
bare, scoped to claim: {"type":"error","message":"Precondition failed: revision 8 predates this knowledge graph's state in this engine run (from revision 1048579); read it again. Nothing was applied.","code":"precondition_failed"}
pinned to old epoch: {"type":"error","message":"Precondition failed: expect_epoch is not this engine run's stream epoch (...); the revision is from another engine run. Nothing was applied.","code":"precondition_failed"}
run 2 writes issued revisions 1048580..1048591
current bare expect_revision 1048591: committed at revision 1048592
?claim(O, I) -> [["o-1","i1"],["o-1","i2"]]

Perf gate and known limits

  • Perf gate (make perf-gate-remote REV=1c9f1035 PERF_GATE_ARGS="--rounds 30", bench host, run 20261006T001854Z-1c9f1035): PASS. Measured on this PR's head 1c9f1035b3af9f61af885e9d1778e414fc3eb3b9 (after all review fixes) against baseline e732cd52, 30 rounds per arm, host load ~2. Every required metric passed: 46 PASS, 0 FAIL, 0 INCONCLUSIVE; the 8 INVALID series are non-required p99/subscribe metrics with too few baseline samples. The two metrics that were inconclusive in the earlier 10-round run on d7490e47 now pass: bound_query.queries_per_sec cost ratio 0.85 (interval 0.84..0.86) and cheap_query.queries_per_sec 1.03 (interval 1.02..1.04, budget 1.05); the closest to its budget is cheap_query.queries_per_sec. Commit-path metrics: insert_single.ack_us.p50 0.20, insert_batch.ack_us.p50 0.40, interference.delta_us.p99 0.74 (interval 0.68..0.87). The large improvements against the baseline come from main since e732cd52, not from this PR. Reports: target/perf-gate/remote/20261006T001854Z-1c9f1035 on the runner's copy.
  • Restore from backup is out of scope here: a restored data directory carries an older bound (offline backup) or none (online export), so revisions issued after the backup can be reissued and a bare stale expect_revision can pass; Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 covers crash/restart only and this goes up as a follow-up.
  • Far corner, not handled: if the bound cannot be written and 2^19 revisions are then consumed with no write in between (every write is refused once the headroom is gone), the next revision-consuming path panics instead of returning an error.

Pipeline

Updates from git push no-mistakes

... (3 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)

🔧 **Review** - 3 issues found → auto-fixed (5) ✅

🔧 Fix applied.
5 issues (4 warnings, 1 info) still open:

  • ⚠️ src/storage_engine/revisions.rs:101 - A restore from backup can still reissue revisions, so a bare stale expect_revision can pass on a different history. The bound is read only from the data directory, and a restored directory carries an older bound or none. (1) Offline backup copies metadata/revisions.json as it was at backup time (src/storage/backup/tree.rs:27 skips only LOCK and the manifest). Sequence: back up at bound 2^20; the engine restarts and issues 2^20+1 onwards; a client observes 2^20+5; restore the backup; the restored engine continues at 2^20 and reissues 2^20+5 for a rolled-back history. (2) The online export (src/storage/backup/export.rs:141-143) writes persist/ but no revisions.json, so every restored export starts at UNRECORDED_FLOOR (2^40). Two restores of an export, or a restore of a directory that was itself upgraded from an earlier version, collide from 2^40+1. This path was equally open before the change and the issue names crash/restart only, so it is not a regression. The remedy needs authorization rather than the defect: closing it means extending the change to the backup/restore path (for example, excluding or raising the bound on restore, or requiring expect_epoch after one).
  • ⚠️ src/storage_engine/revisions.rs:88 - When the durable write of the bound fails, the revision is issued anyway. reserve_through logs revision_reservation_failed and next() still returns a revision above the bound on disk. Sequence: save_json_atomic fails (ENOSPC, EIO, or a permission error on metadata/) when the counter crosses a block; commits that still succeed return revisions above the recorded bound to clients; the engine crashes; the restarted engine continues at the old bound and reissues those revisions. That is the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 failure again, limited to the window in which the write is failing. Each revision in that window also retries the temp-file write and fsync under the global mutex. The remedy needs authorization because it changes behavior: next_revision() is infallible and is called from snapshot construction, so failing closed means refusing the commit or aborting the process.
  • ⚠️ src/storage_engine/revisions.rs:82 - Unrequired component: the branch in reserve_through that drops a reservation when its metadata/ directory no longer exists (commented "A removed data directory (a test's)"). No intent requirement needs it. It exists so that leaked test engines do not recreate deleted temp directories. In production it fails open: an open engine whose metadata/ is missing at that instant is removed from the list for good, Counter.reserved returns to u64::MAX, and its bound is never raised again without any log line. Recommend removing the branch. Dead engines are already pruned through the Weak upgrade.
  • ⚠️ src/storage_engine/residency.rs:406 - Unrequired component, added by fix round 1: the reserve_ahead()? gate in activate(). activate is reached through kg_handle, which every read uses, so this gate refuses reads as well as writes. Sequence: the disk fills (or metadata/ becomes unwritable) while the counter is within HEADROOM of the bound; a client queries a dormant knowledge graph; kg_handle -> activate -> reserve_ahead returns the write error and the query fails. Before this round that read succeeded. The operator's decision asked only that "the commit that needs the new block must fail"; the gates in lock_kg (residency.rs:339) and create_graph (mod.rs:405) already cover every commit. Without the gate an activation still cannot issue a revision above the bound, because next_revision() panics rather than return one. Recommend removing the gate in activate. This needs a decision because it trades read availability during a storage fault against an activation failing by panic in the far corner where 2^19 revisions are used with no write in between.
  • ℹ️ src/storage_engine/revisions.rs:86 - One revision-consuming path does not pass reserve_ahead, so the claim on next_revision that "Writes are refused long before that" (revisions.rs:58-59) does not hold for it. Fix round 1 gated lock_kg, activate and create_graph and left this sibling behind. WriteProgram::view (src/storage_engine/write_program.rs:254) builds a new snapshot, and so takes a revision, for each fact statement that reads state after a rule change. It is called while staging (src/protocol/handler/write_run.rs:290), before the commit reaches lock_kg; staging_base (src/storage_engine/mod.rs:834) uses kg_handle only. Sequence: the bound cannot be written and the counter is in the headroom zone; clients retry programs that change a rule and then read; each retry consumes revisions and its commit is refused; after 2^19 such revisions the next staging view panics in the handler task instead of returning an error. No revision above the bound is returned, so the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 invariant holds; the cost is a panic where an error was intended. Smallest fix: call revisions::reserve_ahead()? in staging_base, which only write programs use.

🔧 Fix applied.
5 issues (4 warnings, 1 info) still open:

  • ⚠️ src/storage_engine/revisions.rs:101 - A restore from backup can still reissue revisions, so a bare stale expect_revision can pass on a different history. The bound is read only from the data directory, and a restored directory carries an older bound or none. (1) Offline backup copies metadata/revisions.json as it was at backup time (src/storage/backup/tree.rs:27 skips only LOCK and the manifest). Sequence: back up at bound 2^20; the engine restarts and issues 2^20+1 onwards; a client observes 2^20+5; restore the backup; the restored engine continues at 2^20 and reissues 2^20+5 for a rolled-back history. (2) The online export (src/storage/backup/export.rs:141-143) writes persist/ but no revisions.json, so every restored export starts at UNRECORDED_FLOOR (2^40). Two restores of an export, or a restore of a directory that was itself upgraded from an earlier version, collide from 2^40+1. This path was equally open before the change and the issue names crash/restart only, so it is not a regression. The remedy needs authorization rather than the defect: closing it means extending the change to the backup/restore path (for example, excluding or raising the bound on restore, or requiring expect_epoch after one).
  • ⚠️ src/storage_engine/revisions.rs:88 - When the durable write of the bound fails, the revision is issued anyway. reserve_through logs revision_reservation_failed and next() still returns a revision above the bound on disk. Sequence: save_json_atomic fails (ENOSPC, EIO, or a permission error on metadata/) when the counter crosses a block; commits that still succeed return revisions above the recorded bound to clients; the engine crashes; the restarted engine continues at the old bound and reissues those revisions. That is the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 failure again, limited to the window in which the write is failing. Each revision in that window also retries the temp-file write and fsync under the global mutex. The remedy needs authorization because it changes behavior: next_revision() is infallible and is called from snapshot construction, so failing closed means refusing the commit or aborting the process.
  • ⚠️ src/storage_engine/revisions.rs:82 - Unrequired component: the branch in reserve_through that drops a reservation when its metadata/ directory no longer exists (commented "A removed data directory (a test's)"). No intent requirement needs it. It exists so that leaked test engines do not recreate deleted temp directories. In production it fails open: an open engine whose metadata/ is missing at that instant is removed from the list for good, Counter.reserved returns to u64::MAX, and its bound is never raised again without any log line. Recommend removing the branch. Dead engines are already pruned through the Weak upgrade.
  • ℹ️ src/storage_engine/revisions.rs:86 - One revision-consuming path does not pass reserve_ahead, so the claim on next_revision that "Writes are refused long before that" (revisions.rs:58-59) does not hold for it. Fix round 1 gated lock_kg, activate and create_graph and left this sibling behind. WriteProgram::view (src/storage_engine/write_program.rs:254) builds a new snapshot, and so takes a revision, for each fact statement that reads state after a rule change. It is called while staging (src/protocol/handler/write_run.rs:290), before the commit reaches lock_kg; staging_base (src/storage_engine/mod.rs:834) uses kg_handle only. Sequence: the bound cannot be written and the counter is in the headroom zone; clients retry programs that change a rule and then read; each retry consumes revisions and its commit is refused; after 2^19 such revisions the next staging view panics in the handler task instead of returning an error. No revision above the bound is returned, so the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 invariant holds; the cost is a panic where an error was intended. Smallest fix: call revisions::reserve_ahead()? in staging_base, which only write programs use.
  • ⚠️ src/storage_engine/revisions.rs:120 - Unrequired component, added by fix round 2: the branch in reserve_through that drops a reservation and swallows its write error when the engine closed during the write (if open.strong_count() == 0 { return false; }, with the restructuring around it at lines 113-127). No intent requirement needs it, and the operator's round 2 decision asked only for removing the activate gate and adding the staging_base gate ("Nothing more"). A production process has one engine, so the branch can only fire at shutdown, where the alternative is one in-flight commit returning an error. Its practical use is parallel tests sharing the static counter, where one test's engine and temp directory go away while another test's commit raises bounds. That is the same role as the test-only prune branch removed in round 1. I found no wrong result from it: an engine with no strong reference issues no more revisions, so ignoring its failed write cannot let a revision pass a bound. No test covers it. Recommend reverting reserve_through to its round 1 form. This needs a decision because removal may bring back a cross-test race that the fixer presumably added it for.

🔧 Fix applied.
9 issues (1 error, 6 warnings, 2 infos) still open:

  • ⚠️ src/storage_engine/revisions.rs:101 - A restore from backup can still reissue revisions, so a bare stale expect_revision can pass on a different history. The bound is read only from the data directory, and a restored directory carries an older bound or none. (1) Offline backup copies metadata/revisions.json as it was at backup time (src/storage/backup/tree.rs:27 skips only LOCK and the manifest). Sequence: back up at bound 2^20; the engine restarts and issues 2^20+1 onwards; a client observes 2^20+5; restore the backup; the restored engine continues at 2^20 and reissues 2^20+5 for a rolled-back history. (2) The online export (src/storage/backup/export.rs:141-143) writes persist/ but no revisions.json, so every restored export starts at UNRECORDED_FLOOR (2^40). Two restores of an export, or a restore of a directory that was itself upgraded from an earlier version, collide from 2^40+1. This path was equally open before the change and the issue names crash/restart only, so it is not a regression. The remedy needs authorization rather than the defect: closing it means extending the change to the backup/restore path (for example, excluding or raising the bound on restore, or requiring expect_epoch after one).
  • ⚠️ src/storage_engine/revisions.rs:88 - When the durable write of the bound fails, the revision is issued anyway. reserve_through logs revision_reservation_failed and next() still returns a revision above the bound on disk. Sequence: save_json_atomic fails (ENOSPC, EIO, or a permission error on metadata/) when the counter crosses a block; commits that still succeed return revisions above the recorded bound to clients; the engine crashes; the restarted engine continues at the old bound and reissues those revisions. That is the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 failure again, limited to the window in which the write is failing. Each revision in that window also retries the temp-file write and fsync under the global mutex. The remedy needs authorization because it changes behavior: next_revision() is infallible and is called from snapshot construction, so failing closed means refusing the commit or aborting the process.
  • ⚠️ src/storage_engine/revisions.rs:82 - Unrequired component: the branch in reserve_through that drops a reservation when its metadata/ directory no longer exists (commented "A removed data directory (a test's)"). No intent requirement needs it. It exists so that leaked test engines do not recreate deleted temp directories. In production it fails open: an open engine whose metadata/ is missing at that instant is removed from the list for good, Counter.reserved returns to u64::MAX, and its bound is never raised again without any log line. Recommend removing the branch. Dead engines are already pruned through the Weak upgrade.
  • ℹ️ src/storage_engine/revisions.rs:86 - One revision-consuming path does not pass reserve_ahead, so the claim on next_revision that "Writes are refused long before that" (revisions.rs:58-59) does not hold for it. Fix round 1 gated lock_kg, activate and create_graph and left this sibling behind. WriteProgram::view (src/storage_engine/write_program.rs:254) builds a new snapshot, and so takes a revision, for each fact statement that reads state after a rule change. It is called while staging (src/protocol/handler/write_run.rs:290), before the commit reaches lock_kg; staging_base (src/storage_engine/mod.rs:834) uses kg_handle only. Sequence: the bound cannot be written and the counter is in the headroom zone; clients retry programs that change a rule and then read; each retry consumes revisions and its commit is refused; after 2^19 such revisions the next staging view panics in the handler task instead of returning an error. No revision above the bound is returned, so the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 invariant holds; the cost is a panic where an error was intended. Smallest fix: call revisions::reserve_ahead()? in staging_base, which only write programs use.
  • ⚠️ src/storage_engine/revisions.rs:120 - Unrequired component, added by fix round 2: the branch in reserve_through that drops a reservation and swallows its write error when the engine closed during the write (if open.strong_count() == 0 { return false; }, with the restructuring around it at lines 113-127). No intent requirement needs it, and the operator's round 2 decision asked only for removing the activate gate and adding the staging_base gate ("Nothing more"). A production process has one engine, so the branch can only fire at shutdown, where the alternative is one in-flight commit returning an error. Its practical use is parallel tests sharing the static counter, where one test's engine and temp directory go away while another test's commit raises bounds. That is the same role as the test-only prune branch removed in round 1. I found no wrong result from it: an engine with no strong reference issues no more revisions, so ignoring its failed write cannot let a revision pass a bound. No test covers it. Recommend reverting reserve_through to its round 1 form. This needs a decision because removal may bring back a cross-test race that the fixer presumably added it for.
  • 🚨 tests/scenarios/restart.rs:34 - The rebase brought S12 (tests/scenarios/restart.rs) onto this branch. It still wraps the bare pre-crash expect_revision case in the KnownDefect STALE_REVISION_ACCEPTED (Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380, judged at line 190), and its module docs say revisions restart with the engine and that the bare claim is accepted today. This change fixes Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380, so that expected failure now passes, and the scenario suite fails a run on an unexpected pass. The intent requires the bare pre-crash case in S12 as a required assertion. Fix: remove the KnownDefect and assert the refusal directly as a required pass in S12; correct the S12 module docs and any TESTING.md known-defect listing for Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 to match (a restarted engine continues above every revision of earlier runs, so the bare pre-crash revision is refused as predating the knowledge graph in this run); check that the S12 step that advances the new run until it has issued the pre-crash revision still terminates and still means something now that the new run starts above it, and simplify it if not. S12 then covers what tests/scenarios/stream.rs::a_bare_expect_revision_from_before_a_restart_is_refused was added for before S12 existed on main: remove that duplicate scenario and its mentions in the stream.rs module docs and TESTING.md.
  • ⚠️ tests/scenarios/restart.rs:176 - Fix round 3 introduced this: the required S12 bare assertion no longer distinguishes the fix from the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 bug. The round replaced the writes.iter().cycle() loop, which advanced the new run until it had issued claimed_at, with exactly two writes (line 104). The assertion refused(reply, &#34;precondition_failed&#34;, &#34;revision&#34;) matches both refusal messages in src/storage_engine/precondition.rs:65-75: Unissued ("revision N has not been issued") and Predates ("revision N predates"). Sequence with the fix reverted: the restarted run counts from 1 and after two writes is still below claimed_at (the old loop had to cycle to reach it), so the bare claim is refused as Unissued and S12 passes with the bug present. The intent requires the bare pre-crash case as a required assertion, and the test-quality rule requires a regression test that fails before the fix. Smallest fix, test only: assert the property the fix provides, for example snapshot &gt; claimed_at after the resubscribe at line 70 (the strategy's original ask that the first post-restart revision exceeds the last pre-crash one), and match "predates" instead of "revision" at line 176.
  • ⚠️ src/storage_engine/revisions.rs:109 - Fix round 3 reverted the closed-engine branch as decided but left the race it covered open; its commit title says "cross-test race unresolved". The operator's decision placed that fix in the tests. Sequence in the lib test binary, where all engines share the static REVISIONS: test A reopens a data directory, so open (line 144) raises last to that directory's recorded bound, which is above every other open engine's last + HEADROOM. The next write in any test then runs reserve_through and rewrites metadata/revisions.json for every live engine. If test B drops its engine and TempDir between the upgrade() at line 110 and the write at line 115, save_json_atomic either recreates B's removed directory through create_dir_all (a leaked temp directory) or fails against the concurrent removal. In the failing case the unrelated test's write returns an error from reserve_ahead, or panics in next() at line 86. The result is an intermittent failure in tests that have no defect. Production has one engine and is not affected. The remedy needs a decision on scope, not on the defect: serialising or isolating every engine test that shares the counter is a broad test change, and the fixer did not attempt it.
  • ℹ️ gui/lib/docs-bundle.ts:3920 - The tracked, generated docs bundle still carries the old websocket-api text "Revisions restart with the engine, so a revision from another run fails the precondition". The change rewrote that sentence in docs/content/docs/guides/websocket-api.mdx:198, and earlier commits that touched that page (for example 9aa2275) regenerated the bundle in the same commit. Regenerate it with node gui/scripts/bundle-docs.mjs so the in-app docs match the new contract.

🔧 Fix applied.
2 issues (1 warning, 1 info) still open:

  • ⚠️ src/storage_engine/revisions.rs:101 - A restore from backup can still reissue revisions, so a bare stale expect_revision can pass on a different history. The bound is read only from the data directory, and a restored directory carries an older bound or none. (1) Offline backup copies metadata/revisions.json as it was at backup time (src/storage/backup/tree.rs:27 skips only LOCK and the manifest). Sequence: back up at bound 2^20; the engine restarts and issues 2^20+1 onwards; a client observes 2^20+5; restore the backup; the restored engine continues at 2^20 and reissues 2^20+5 for a rolled-back history. (2) The online export (src/storage/backup/export.rs:141-143) writes persist/ but no revisions.json, so every restored export starts at UNRECORDED_FLOOR (2^40). Two restores of an export, or a restore of a directory that was itself upgraded from an earlier version, collide from 2^40+1. This path was equally open before the change and the issue names crash/restart only, so it is not a regression. The remedy needs authorization rather than the defect: closing it means extending the change to the backup/restore path (for example, excluding or raising the bound on restore, or requiring expect_epoch after one).

  • ℹ️ src/storage_engine/revisions_tests.rs:131 - Fix round 4 added an_engine_closing_while_bounds_are_raised_fails_no_write for the restored closed-engine branch (revisions.rs:118-126). The branch only covers a bound write that fails. If the spawned thread drops the engine and its TempDir after upgrade() but before save_json_atomic reaches create_dir_all (src/storage/metadata.rs:66), the write succeeds and recreates the removed directory, leaving a stray temp directory. The test passes either way, because it does not assert on the directory. The operator's "no directory is recreated" is asserted only in the deterministic test at line 110, which exercises the existing Weak prune and not the new branch. No wrong revision results: the engine has closed, and production has one engine. Recorded as a known limit of the branch; no change recommended.

  • ⚠️ src/storage_engine/revisions.rs:23 - The durable bound lives at metadata/revisions.json, a path a knowledge graph drop can delete, after which a restart can reissue revisions (the Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380 failure). Traced from source, not executed. validate_kg_name (src/naming.rs:17) accepts metadata as a knowledge graph name and reserves no names. A knowledge graph's directory is data_dir.join(name) (src/storage_engine/mod.rs:446), and dropping it runs cleanup_dropped_kg -> delete_kg_files, which calls remove_dir_all(data_dir/&lt;name&gt;) (mod.rs:575, mod.rs:166). Sequence: (1) a data directory upgraded from an earlier version opens with no record, so it continues at UNRECORDED_FLOOR (2^40) and issues 2^40+1 onwards; a client observes 2^40+5. (2) A knowledge graph named metadata is created and dropped, which removes metadata/ including revisions.json. The running engine rewrites the file only at the next block raise, up to 2^20 revisions later. (3) The engine restarts before that raise. open finds no record and persist/ present, so it starts at 2^40 again (revisions.rs:152) and reissues 2^40+5 on a different history. A bare expect_revision at 2^40+5 can then pass. A directory that was never upgraded needs the drop-and-restart to happen twice. The name collision with metadata/ existed before this change (the drop also removes the knowledge graph list and tombstones, which are re-saved); this change adds a file there that is not re-saved and whose loss fails open. The remedy needs a decision, not the defect: either reserve metadata and persist as knowledge graph names (a behavior change outside Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused #380), or keep the bound at a path no knowledge graph name can collide with, for example revisions.json directly under the data directory (names allow only [a-z0-9_], so a dotted file name cannot collide), which also touches docs/content/docs/guides/backup.mdx:30.

  • ⚠️ src/storage_engine/revisions.rs:124 - Component no longer needed by any real engine: the closed-engine branch in reserve_through (if open.strong_count() == 0 { return false; }, lines 120-126). This is a new situation, not a repeat of closed-mid-reservation-branch-unrequested: the operator requested the branch in round 4 to cover the cross-test race, and the later CI fix (419473c) closed that race a second way. OpenReservation::drop now calls Counter::close, which takes the reservations lock, and the Arc is released only after close returns. reserve_through holds that lock for the whole raise, so a real engine's strong count cannot reach 0 while its bound is being written. Only the Counter-level tests that drop a bare Arc reach the branch (an_engine_closing_while_bounds_are_raised_fails_no_write, revisions_tests.rs:131). The fixer's commit message records the same conflict and left the branch as decided. I found no wrong result from keeping it. Recommend removing the branch and that one test, and keeping close and its test (revisions_tests.rs:160) as the single mechanism. This needs a decision because it reverses a recorded operator instruction.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
S12: an agent's claim is committed, the engine is killed and restarted, and a write carrying the pre-crash revision without an epoch is refused as predating this run ✅ pass live s12-scenario.log (cargo test --test scenarios restart::, 1 passed)
Client writes, engine is killed with -9 and restarted; other writes advance the new run; a bare expect_revision from before the crash gets precondition_failed and nothing is applied ✅ pass live manual-restart-target.log: revision 7 refused with 'predates this knowledge graph's state in this engine run (from revision 1048578)'; the claim rows are unchanged
Regression proof: the same steps on the base commit accept the stale bare write ✅ pass live manual-restart-base.log: after the restart revisions count from 3 again, and +claim(&#34;o-1&#34;,&#34;i4&#34;,&#34;a1&#34;) with expect_revision: 7 commits at revision 10 and the row appears
Revisions never go backwards across repeated restarts, crash or clean ✅ pass live manual-restart-target.log: last revision of each run 9 < 1048584 < 2097156 < 3145732 < 4194308; each previous run's last revision is refused bare in the next run
A pre-crash revision pinned to its old epoch is still refused, and writes expecting a revision of the current run still commit, with and without an epoch ✅ pass live manual-restart-target.log: 'expect_epoch is not this engine run's stream epoch' refusal; expect_revision 1048580 (bare) and 1048582 (with epoch) both commit
A second knowledge graph's pre-crash revision is refused bare after a restart ✅ pass live manual-restart-target.log: knowledge graph other, revision 4194310 refused after kill -9
Upgrade: a data directory written by the pre-fix engine is opened by the fixed engine, which continues above every old revision and refuses an old bare revision ✅ pass live manual-upgrade-from-base.log: old engine's last revision 7, fixed engine's first 1099511627779; expect_revision: 6 refused as predating; the old facts are intact
A corrupt bound file makes the engine refuse to start instead of counting from 1; restoring it lets the engine start and continue above ✅ pass live manual-restart-target.log: exit code 1 with 'Failed to create storage: JSON error'; after restoring the file, revision 1099512676355
When the durable bound must be raised and cannot be written, writes are refused, no revision passes the bound, reads still answer, the engine stays up, and writes resume once the bound is writable ✅ pass live manual-bound-unwritable.log: at revision 524289 with metadata/ read-only, writes get an error (code internal, 'Permission denied'); a query still answers and /health is ok; after chmod back, revis…
  • cargo test --test scenarios restart:: -- --nocapture (S12 against a real engine process; 1 passed)
  • node drive.mjs target/debug/inputlayer-server &lt;tmp&gt; &#34;target 8c82c38b&#34;: five restarts (kill -9 and SIGTERM), bare and epoch-pinned stale expect_revision, in-run happy paths, second knowledge graph, missing and corrupt metadata/revisions.json (18 of 18 passed)
  • SKIP_FIX_ONLY=1 node drive.mjs &lt;base build&gt;/inputlayer-server &lt;tmp&gt; &#34;base 306df026&#34;: same driver on the base commit (6 of 15 failed; the bare stale write was accepted and applied)
  • node drive-upgrade.mjs &lt;base binary&gt; &lt;target binary&gt; &lt;tmp&gt;: data directory written by the pre-fix engine, opened by the fixed engine (4 of 4 passed)
  • node drive-bound.mjs target/debug/inputlayer-server &lt;tmp&gt;: pumped to revision 524268, chmod 555 metadata/, observed refusal, restored permissions (7 of 7 passed)
  • Base binary built from git archive 306df026 in a temp directory with its own CARGO_TARGET_DIR, removed afterwards

✅ No issues found.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
S12: an agent's claim expecting a pre-crash revision is refused after a crash/restart, both pinned to its epoch and bare, and the first post-restart revision exceeds the last pre-crash one ✅ pass live s12-scenario.log: restart::s12_restart_mid_scenario_preserves_revisions ... ok
A client sends a write with a bare pre-crash expect_revision (no epoch, scoped to claim) after SIGKILL and restart; the server replies precondition_failed and applies nothing ✅ pass live live-restart-transcript.log: revision 8 predates this knowledge graph&#39;s state in this engine run (from revision 1048579) ... Nothing was applied.; ?claim returns only the two run-1 rows
The same pre-crash revision is refused when pinned to the old stream epoch and when bare with whole-graph scope ✅ pass live live-restart-transcript.log: run 2 pinned and unscoped replies, both precondition_failed
Adversarial: after the new run commits 12 more writes, a client retries every pre-crash revision number (1..11) as a bare expect_revision; none is accepted ✅ pass live live-restart-transcript.log: run 2 sweep, all refused; run 2 issued 1048580..1048591 against a pre-crash last of 8
A bare expect_revision at a current revision still commits, before the crash and in the restarted run ✅ pass live live-restart-transcript.log: run 1 commits at revision 8, run 2 commits at 1048592
A second crash/restart keeps revisions rising and refuses a bare revision from the previous run ✅ pass live live-restart-transcript.log: run 3 first write at 2097156, bare run-2 revision refused
Adversarial: a knowledge graph named metadata is created and dropped, then the server is killed and restarted; the bound file survives and no revision is reissued ✅ pass live live-restart-transcript.log: Knowledge graph &#39;metadata&#39; dropped., revisions.json reserved=3145728 still present, run 4 first write at 3145731
Adversarial: revisions.json is deleted from a data directory that holds state (the upgrade-from-older-version case); the restarted server still issues higher revisions and refuses a bare earlier one ✅ pass live live-restart-transcript.log: run 5 write at 1099511627781, revision 3145733 predates ... (from revision 1099511627780)
  • cargo test --test scenarios s12_restart -- --nocapture (S12 against the real server binary, 1 passed)
  • node drive.mjs target/debug/inputlayer-server &lt;tmpdir&gt;: real server over ws://127.0.0.1:&lt;port&gt;/ws, five runs separated by SIGKILL, 20/20 checks passed
  • Manual check in the driver: revisions.json sits directly under the data directory and its reserved value is at or above every issued revision (1048576, then 2097152, then 3145728)
  • Manual check in the driver: ?claim(O, I) after the refused writes returns only the committed rows
✅ **Document** - passed

✅ No issues found.

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

jsam added 8 commits October 5, 2026 23:46
…tale expect_revision fails without expect_epoch (#380)

Revisions restarted from 1 with the engine, so a write carrying a pre-crash
expect_revision and no expect_epoch could match a revision of the new run and
commit against a different history.

The engine now keeps a durable bound on the revisions it has issued
(metadata/revisions.json, raised a block of 2^20 at a time) and a starting
engine continues above the bound its data directory holds. Every revision of
an earlier run is then older than each knowledge graph's first snapshot in the
new run and fails the precondition as predating it. A directory with state
but no recorded bound (written by an earlier version) continues at 2^40.

The wire protocol is unchanged: expect_epoch stays optional.
…ck. The full `make unit-test` run on the rebased tree was started but had not finished when this result was requested, so the full suite is unverified. Changes are uncommitted in the worktree on top of the rebased commits. **ci-1 (Tests: `failed_restore_into_an_empty_mount_point_leaves_it_empty`)** — caused by this PR, a race rather than a broken test. - Invariant: once an engine has closed and released its data-directory lock, the process writes nothing more into that directory. - Cause: `reserve_through` upgrades a `Weak` to each open engine's reservation and rewrites `metadata/revisions.json`. Another test's write could still be mid-write to a just-dropped engine's directory, so `backup::create` listed the temp file and then found it gone. - Sites writing the bound: `open` (runs under the directory lock, already holds) and `reserve_through` (the violating one). - Fix: the engine now holds `revisions::OpenReservation`, whose `Drop` calls `Counter::close`. That takes the reservations lock, so it waits out a raise in progress, and removes the engine from the list. The field is declared before `_data_dir_lock`, so the close happens before the lock is released. - Files: `src/storage_engine/revisions.rs`, `src/storage_engine/mod.rs`. - Regression test: `a_closed_engine_directory_is_not_written_by_a_raise_in_progress` in `revisions_tests.rs`. It fails with `close` made a no-op (bound 2621440 written after close vs 1048576) and passes with the fix. **ci-2 (merge conflicts)** — two conflicts resolved. - `TESTING.md`: base removed the #364 expected failure and this branch removed #380, so the sentence now names neither. - `gui/lib/docs-bundle.ts`: regenerated with `node gui/scripts/bundle-docs.mjs`, no hand edits. **Verification run** - `cargo test --lib storage_engine::revisions`: 10 passed. - `cargo test --test backup_safety_tests`: 12 passed, 8 runs in a row. - `cargo clippy --all-targets`: finished clean. `cargo fmt` applied. **Conflict with a recorded decision, left as decided:** with engines now closing under the reservations lock, the operator-requested closed-engine branch in `reserve_through` (`strong_count() == 0`) can no longer fire from a real engine. Only the `Counter`-level tests reach it. I kept the branch and its tests unchanged
@jsam
jsam force-pushed the fm/il-380-expect-rev branch from 0a8e167 to 1c9f103 Compare October 6, 2026 00:07
@jsam jsam changed the title fix(storage): never reissue revisions across restarts so a stale expect_revision is refused fix(storage_engine): never reissue revisions across restarts so a stale expect_revision is refused Oct 6, 2026
@jsam
jsam merged commit c3d08ae into main Oct 6, 2026
25 checks passed
@jsam
jsam deleted the fm/il-380-expect-rev branch October 6, 2026 05:00
jsam added a commit that referenced this pull request Oct 6, 2026
… docs

make doc-check failed on main since #390: rustdoc denies a public doc
linking to a private item.
jsam added a commit that referenced this pull request Oct 6, 2026
…n-finite float literals (#301, #397) (#405)

* test: cargo-fuzz targets for the IQL parser and /ws client frames (#301)

Three targets run the server's own code through a test-support seam
(src/fuzzing.rs) on an engine-sized stack at the nesting ceiling:
iql_statement, iql_program (split, parse, bind params, authorize) and
ws_client_frame (pre-auth decode, id probe, request classification).
Beyond crashes they check that a persistent rule reloads unchanged and
that a request run read-only holds only queries.

fuzz/seeds.py seeds them from the example programs, every /ws frame type
and the #295 deep-nesting and oversized-body cases; fuzz/run.sh (make
fuzz) runs a campaign that keeps going past findings.

* docs(storage_engine): unlink the private revisions module from public docs

make doc-check failed on main since #390: rustdoc denies a public doc
linking to a private item.

* fix(parser): refuse float literals beyond f64's range (#397)

A persistent rule holding 1e400 (or inf, NaN) was stored with a JSON
null, which failed to decode on the next start: WalUnreadable, and the
engine would not start. Every float literal now goes through
float_literal: decimal syntax only, and a literal beyond f64's range is a
validation error. Rust's inf/NaN spellings are identifiers again.
Negating i64::MIN in a literal no longer overflows.

Found by the iql_statement fuzz target; its inputs are kept in
fuzz/regressions/.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale expect_revision may pass after restart: revisions restart, bare revision without epoch is not refused

1 participant