fix(queue): reject non-persistable summary/evidence on non-park updates (OPR.0.5.1 slice-51-06 D2)

rig queue update accepted --summary/--evidence-ref on any transition, but the daemon persists
them ONLY at a human-seat park (state=blocked on a human seat); on every other transition they
were SILENTLY dropped — the operator believed metadata was saved when it was lost. Guard-bound
design: HARD REJECT before any mutation (not a post-commit marker, which would report a partial
write / data loss).

- daemon queue-repository.ts (before any UPDATE/log/event): a non-park transition carrying
  summary/evidence_ref (null/undefined=absent, ''=present) throws
  QueueRepositoryError('summary_evidence_not_persistable', { invalidFields }).
- route routes/queue.ts: the code maps to HTTP 400; err.meta.invalidFields rides the body.
- CLI: NO change — printResult already exits nonzero on 4xx (no warning branch, per the binding).
- human-park + metadata-free updates byte-for-byte unchanged; NO schema change (+23/-0 production).

RED-first: queue-update-metadata-reject.test.ts (reject + zero-mutation across state/log/event,
null-absent/empty-present, human-park control, route 400; migration-faithful with 044+048).
CLI acceptance is a REAL, HERMETIC loopback boundary: queue-update-metadata-reject-cli.test.ts drives
an actual queueCommand -> real DaemonClient -> @hono/node-server loopback -> real queueRoutes/
QueueRepository (migrations 044+048). It neutralizes ambient OPENRIG_URL/RIGGED_URL/OPENRIG_PORT (saved
+ restored even on failure) so status resolution binds to the fresh loopback state file, and a
clientFactory guard refuses any non-loopback URL BEFORE constructing a client (no accidental canonical-
daemon contact). Covers normal + --json reject (nonzero exit), UNCHANGED item/transition/event state
after the HTTP call, and the successful human-park control — passing with ambient OPENRIG_URL present.
Flipped the one existing pin that encoded the old silent-drop (human-route-enforcer.test.ts). tsc clean
both; queue regression green. Guard PRE-EDIT CLEAR artifact sha256 728690e1.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-05 12:08:23 +00:00
1 parent 20991e3cc1
commit b48c2242f5
5 files changed
+286 -9

No files matched your search

@@ -0,0 +1,132 @@
// 51-06 D2 — REAL CLI acceptance boundary (Guard implementation-verdict correction). A genuine
// `queueCommand` invocation talks over LOOPBACK HTTP to the real candidate queueRoutes +
// QueueRepository, backed by a migration-faithful test DB (incl. 044 summary + 048 evidence_ref).
// No pre-baked response: if the daemon route/repo stopped rejecting (or mutated before responding),
// these tests would fail. Proves normal + --json reject, UNCHANGED item/transition/event state after
// the HTTP call, and the successful human-park control — all end to end.
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import type Database from "better-sqlite3";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { coreSchema } from "../../daemon/src/db/migrations/001_core_schema.js";
import { eventsSchema } from "../../daemon/src/db/migrations/003_events.js";
import { queueItemsSchema } from "../../daemon/src/db/migrations/024_queue_items.js";
import { queueTransitionsSchema } from "../../daemon/src/db/migrations/025_queue_transitions.js";
import { inboxEntriesSchema } from "../../daemon/src/db/migrations/026_inbox_entries.js";
import { outboxEntriesSchema } from "../../daemon/src/db/migrations/027_outbox_entries.js";
import { queueTargetRepoSchema } from "../../daemon/src/db/migrations/039_queue_target_repo.js";
import { queueItemSummarySchema } from "../../daemon/src/db/migrations/044_queue_item_summary.js";
import { queueItemEvidenceRefSchema } from "../../daemon/src/db/migrations/048_queue_item_evidence_ref.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { queueRoutes } from "../../daemon/src/routes/queue.js";
import { DaemonClient } from "../src/client.js";
import { queueCommand } from "../src/commands/queue.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
let db: Database.Database;
let bus: EventBus;
let repo: QueueRepository;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let server: any;
let port = 0;
// HERMETIC ISOLATION (Guard test-isolation binding): getDaemonStatus() resolves ambient
// OPENRIG_URL / RIGGED_URL (and OPENRIG_PORT) BEFORE the injected state file. The managed seat
// exports OPENRIG_URL=http://127.0.0.1:7433 (the canonical daemon), so without neutralizing these
// the CLI would talk to the real daemon, not this test's loopback repo. Save the originals, delete
// them so the injected state file supplies the fresh loopback port, and restore them in teardown
// even on failure. The env is neutralized only for the CLI-invocation window (runCli).
const AMBIENT_KEYS = ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_PORT"] as const;
const savedEnv: Record<string, string | undefined> = {};
function neutralizeAmbient(): void { for (const k of AMBIENT_KEYS) { savedEnv[k] = process.env[k]; delete process.env[k]; } }
function restoreAmbient(): void { for (const k of AMBIENT_KEYS) { if (savedEnv[k] === undefined) delete process.env[k]; else process.env[k] = savedEnv[k]; } }
beforeAll(async () => {
db = createDb();
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, queueTargetRepoSchema, queueItemSummarySchema, queueItemEvidenceRefSchema]);
bus = new EventBus(db);
repo = new QueueRepository(db, bus, { validateRig: () => true });
const app = new Hono();
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); await next(); });
app.route("/api/queue", queueRoutes());
await new Promise<void>((resolve) => { server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { port = info.port; resolve(); }); });
});
afterAll(async () => {
restoreAmbient(); // belt: ensure ambient aliases are never left neutralized
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
db?.close();
});
function loopbackUrl(): string { return `http://127.0.0.1:${port}`; }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function deps(): any {
return {
// state file carries an explicit 127.0.0.1 host + the fresh loopback port, so getDaemonUrl()
// yields exactly loopbackUrl() (with the aliases neutralized during runCli).
lifecycleDeps: { spawn: () => ({ pid: 1, unref() {} }), fetch: async () => ({ ok: true }), kill: () => true, readFile: (p: string) => (p === STATE_FILE ? JSON.stringify({ pid: 1, port, host: "127.0.0.1", db: "x", startedAt: "x" }) : null), writeFile() {}, removeFile() {}, exists: (p: string) => p === STATE_FILE, mkdirp() {}, openForAppend: () => 3, isProcessAlive: () => true },
clientFactory: (url: string) => {
// SAFETY GUARD: refuse any URL that is not this test's fresh loopback, BEFORE constructing a
// client or touching the network — makes accidental canonical-daemon contact impossible.
if (url !== loopbackUrl()) throw new Error(`test-isolation guard: clientFactory url '${url}' !== loopback '${loopbackUrl()}'`);
return new DaemonClient(url);
},
};
}
async function runCli(args: string[]): Promise<{ out: string; exit: number | undefined }> {
const out: string[] = [];
const ol = console.log, oe = console.error;
console.log = (...a: unknown[]) => { out.push(a.join(" ")); };
console.error = (...a: unknown[]) => { out.push(a.join(" ")); };
process.exitCode = undefined;
neutralizeAmbient(); // bind CLI status resolution to the injected loopback state file
// eslint-disable-next-line @typescript-eslint/no-explicit-any
try { await queueCommand(deps() as any).parseAsync(["node", "rig", ...args]); }
finally { restoreAmbient(); console.log = ol; console.error = oe; }
const exit = process.exitCode; process.exitCode = 0;
return { out: out.join("\n"), exit };
}
const txnCount = (id: string) => (db.prepare("SELECT count(*) c FROM queue_transitions WHERE qitem_id = ?").get(id) as { c: number }).c;
const eventCount = () => (db.prepare("SELECT count(*) c FROM events").get() as { c: number }).c;
describe("51-06 D2 — real CLI->loopback HTTP->queueRoutes/QueueRepository acceptance", () => {
let id: string;
beforeEach(async () => { id = (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "e2e" })).qitemId; });
it("normal: --summary on a non-park update -> real 400, nonzero exit, item/transition/event UNCHANGED", async () => {
const beforeTxns = txnCount(id), beforeEvents = eventCount();
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--summary", "DROPME"]);
expect(exit).toBe(1);
expect(out).toContain("summary_evidence_not_persistable");
expect(out).toContain("invalidFields");
// unchanged item/metadata/transition/event state AFTER the real HTTP call
const item = repo.getByIdOrThrow(id);
expect(item.state).toBe("pending");
expect(item.summary).toBeNull();
expect(txnCount(id)).toBe(beforeTxns);
expect(eventCount()).toBe(beforeEvents);
});
it("--json: same reject as structured JSON with nonzero exit, state UNCHANGED", async () => {
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--evidence-ref", "/e.md", "--json"]);
expect(exit).toBe(1);
const body = JSON.parse(out) as { error: string; invalidFields?: string[] };
expect(body.error).toBe("summary_evidence_not_persistable");
expect(body.invalidFields).toEqual(["evidenceRef"]);
expect(repo.getByIdOrThrow(id).evidenceRef).toBeNull();
});
it("CONTROL: real-CLI human-seat park PERSISTS summary + evidence (exit 0)", async () => {
const { exit } = await runCli(["update", id, "--actor", "orch@rig", "--state", "blocked", "--blocked-on", "human@kernel", "--summary", "PARK-KEEP", "--evidence-ref", "/proof/park.md"]);
expect(exit).toBeFalsy(); // success
const item = repo.getByIdOrThrow(id);
expect(item.summary).toBe("PARK-KEEP");
expect(item.evidenceRef).toBe("/proof/park.md");
});
});
@@ -1283,6 +1283,26 @@ export class QueueRepository {
// enforcement). Blocking on another qitem requires nothing new (BR-1).
const effectiveBlockedOn = input.blockedOn ?? qitem.blockedOn;
const isHumanPark = input.state === "blocked" && isHumanSeatSession(effectiveBlockedOn);
// OPR.0.5.1 slice-51-06 D2 — summary/evidence_ref are persist-able ONLY at a human-seat park
// (see the FR-6 note below). Silently ignoring them on any other transition is a data-loss trap
// (the operator believes the metadata was saved). HARD-REJECT before ANY UPDATE/log/event so the
// caller learns immediately and nothing is half-applied. null/undefined = absent (allowed);
// empty string = present (a deliberate value → rejected on a non-park transition).
if (!isHumanPark) {
const invalidFields: Array<"summary" | "evidenceRef"> = [];
if (input.summary != null) invalidFields.push("summary");
if (input.evidenceRef != null) invalidFields.push("evidenceRef");
if (invalidFields.length > 0) {
const flags = invalidFields.map((f) => (f === "summary" ? "--summary" : "--evidence-ref")).join(" / ");
throw new QueueRepositoryError(
"summary_evidence_not_persistable",
`${invalidFields.join(" + ")} persist only on a human-seat park (state=blocked on a human seat); the '${input.state}' transition cannot store them. Remove ${flags}, or park the item (rig queue block --on <human-seat> --summary … --evidence-ref …).`,
{ invalidFields },
);
}
}
let effectiveSummary = qitem.summary;
let effectiveEvidenceRef = qitem.evidenceRef;
if (isHumanPark) {
+3
View File
@@ -148,6 +148,9 @@ export function queueRoutes(): Hono {
: err.code === "cross_host_close_conflict" ? 409
: err.code === "unknown_destination_rig" ? 400
: err.code === "human_route_fields_required" ? 400
// OPR.0.5.1 slice-51-06 D2: summary/evidence_ref on a non-park transition — a client
// input error surfaced as a structured 400 (the daemon rejects before any mutation).
: err.code === "summary_evidence_not_persistable" ? 400
// OPR.0.4.6.WF3 FR-6: the frontier close-path guard — operator
// misuse of a queue verb on a live workflow packet; structured
// 400 with the what/why/fix message, never a 500.
@@ -307,17 +307,26 @@ describe("FR-6 park-on-human (leg-1) — enforcement + persistence + attention",
expect(ev!.toState).toBe("blocked");
});
it("NEGATIVE: non-park updates never persist summary/evidence_ref inputs (surface stays tight)", async () => {
// OPR.0.5.1 slice-51-06 D2: non-park summary/evidence used to be SILENTLY dropped (a data-loss
// trap); it is now a HARD REJECT before any mutation. The surface still stays tight (nothing
// persists on a non-park item) — now enforced loudly instead of silently.
it("NEGATIVE: non-park updates REJECT summary/evidence_ref inputs (surface stays tight, loudly)", async () => {
const item = await inProgressItem();
repo.update({
qitemId: item.qitemId,
actorSession: "driver@rig",
state: "in-progress",
summary: "should NOT persist",
evidenceRef: "should-not-persist.md",
});
let err: unknown;
try {
repo.update({
qitemId: item.qitemId,
actorSession: "driver@rig",
state: "in-progress",
summary: "should NOT persist",
evidenceRef: "should-not-persist.md",
});
} catch (e) { err = e; }
expect(err).toBeInstanceOf(QueueRepositoryError);
expect((err as QueueRepositoryError).code).toBe("summary_evidence_not_persistable");
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["summary", "evidenceRef"]);
const read = repo.getById(item.qitemId)!;
expect(read.summary).toBeNull();
expect(read.summary).toBeNull(); // nothing persisted (reject was before any write)
expect(read.evidenceRef).toBeNull();
});
});
@@ -0,0 +1,113 @@
// 51-06 atom D2 — non-persistable queue update metadata (summary/evidence_ref) must HARD-REJECT
// BEFORE any mutation, per the Guard-bound design (no post-commit marker; reject-before-write).
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref) so a drop is
// provably the non-park rule, NOT a missing column (the test-suite parity gap this atom flags).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
import { inboxEntriesSchema } from "../src/db/migrations/026_inbox_entries.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
import { queueTargetRepoSchema } from "../src/db/migrations/039_queue_target_repo.js";
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
import { queueItemEvidenceRefSchema } from "../src/db/migrations/048_queue_item_evidence_ref.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository, QueueRepositoryError } from "../src/domain/queue-repository.js";
import { queueRoutes } from "../src/routes/queue.js";
const REJECT_CODE = "summary_evidence_not_persistable";
function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
const app = new Hono();
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, queueRepo); await next(); });
app.route("/api/queue", queueRoutes());
return app;
}
describe("51-06 D2 — non-park queue update metadata reject", () => {
let db: Database.Database;
let bus: EventBus;
let repo: QueueRepository;
let app: Hono;
beforeEach(() => {
db = createDb();
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, queueTargetRepoSchema, queueItemSummarySchema, queueItemEvidenceRefSchema]);
bus = new EventBus(db);
repo = new QueueRepository(db, bus, { validateRig: () => true });
app = buildApp(bus, repo);
});
afterEach(() => db.close());
const mkItem = async () => (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item" })).qitemId;
const txnCount = (id: string) => (db.prepare("SELECT count(*) c FROM queue_transitions WHERE qitem_id = ?").get(id) as { c: number }).c;
const eventCount = () => (db.prepare("SELECT count(*) c FROM events").get() as { c: number }).c;
async function expectRejectZeroMutation(input: Record<string, unknown>, invalidFields: string[]) {
const id = await mkItem();
const beforeState = repo.getByIdOrThrow(id).state;
const beforeTxns = txnCount(id);
const beforeEvents = eventCount();
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input }); }
catch (e) { err = e; }
expect(err).toBeInstanceOf(QueueRepositoryError);
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(invalidFields);
// zero mutation: no UPDATE (state), no log (transition), no event
expect(repo.getByIdOrThrow(id).state).toBe(beforeState);
expect(txnCount(id)).toBe(beforeTxns);
expect(eventCount()).toBe(beforeEvents);
}
it("rejects --summary on a non-park transition, before any mutation", async () => {
await expectRejectZeroMutation({ summary: "DROPME" }, ["summary"]);
});
it("rejects --evidence-ref on a non-park transition, before any mutation", async () => {
await expectRejectZeroMutation({ evidenceRef: "/proof/x.md" }, ["evidenceRef"]);
});
it("rejects BOTH, naming both invalidFields, before any mutation", async () => {
await expectRejectZeroMutation({ summary: "S", evidenceRef: "/e" }, ["summary", "evidenceRef"]);
});
it("treats empty-string as PRESENT (null=absent, ''=present) -> rejects", async () => {
await expectRejectZeroMutation({ summary: "" }, ["summary"]);
});
it("also rejects on a terminal non-park transition (in-progress -> done + closure)", async () => {
const id = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" }); // claim (metadata-free, ok)
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", summary: "S" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect(repo.getByIdOrThrow(id).state).toBe("in-progress"); // unchanged by the rejected close
});
it("metadata-free non-park update is UNCHANGED (null=absent, no reject)", async () => {
const id = await mkItem();
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: null, evidenceRef: null });
expect(res.state).toBe("in-progress");
});
it("CONTROL: human-seat park still PERSISTS summary + evidence byte-for-byte", async () => {
const id = await mkItem();
const res = repo.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: "PARK-KEEP", evidenceRef: "/proof/park.md" });
expect(res.summary).toBe("PARK-KEEP");
expect(res.evidenceRef).toBe("/proof/park.md");
});
it("ROUTE: POST /:id/update non-park + summary -> HTTP 400 naming invalidFields", async () => {
const created = await app.request("/api/queue/create", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }) });
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await app.request(`/api/queue/${id}/update`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ actorSession: "dev-x@rig", state: "in-progress", summary: "DROPME" }) });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; invalidFields?: string[] };
expect(body.error).toBe(REJECT_CODE);
expect(body.invalidFields).toEqual(["summary"]);
});
});