Compare commits

...
Author SHA1 Message Date
dev-driver 4e2461c309 fix(queue): count an ask's evidence pointer in its decision revision, and store no blank on a person park
An update can now replace an open ask's evidence pointer, but the decision revision left that pointer out. A person who read the ask with one artifact could have their answer recorded against another. The revision now includes the pointer when the row has one. An answer read under the earlier pointer gets the existing decision_revision_changed conflict, and nothing is written. A row with no evidence keeps the revision it had.

A park on a person no longer stores a blank summary or evidence ref. The row keeps its own and the route reports the value as not stored, the same as on every other state change. A legacy human-seat park given a blank one now parks on the row's own value instead of refusing.
2026-11-13 13:46:20 +00:00
dev-driver 2a1d50a354 fix(queue): keep a blank evidence ref out, and report any field the stored row lacks
A blank evidence ref on a state change overwrote the row's stored pointer,
so a script passing an unset variable erased the evidence link. Outside a
park on a person, a blank evidence ref is now not stored, as a blank
summary already wasn't.

The update route now compares each requested summary and evidence ref with
the stored row and names any it doesn't carry, blank or not
(summary_not_stored, evidence_ref_not_stored), so a field the repository
couldn't write is reported too. The queue.updated event carries the summary
as stored, read back from the row. The CLI help and MCP description say
where a blank value isn't stored.
2026-11-13 08:12:53 +00:00
dev-driver 6aa59a3e4d fix(queue): apply a park or close that carries summary or evidence, and store them
A state change that came with --summary or --evidence-ref was refused whole
(summary_evidence_not_persistable) unless it was a human-seat park, or, for
evidence only, a done close. An agent that finished its work and parked with
a summary saw the refusal and left the row pending.

Every state change now applies and stores both fields: summary through the
same write the standalone update uses, evidence_ref through the one a done
close already used. A blank summary is the one value not stored, because it
would erase the row's subject; the update route reports it as a
summary_not_stored advisory, so a caller never believes it was saved.
Human-seat parks keep their requirement and behaviour.
2026-11-13 07:19:01 +00:00
9 changed files with 257 additions and 184 deletions

No files matched your search

+2 -2
View File
@@ -1402,8 +1402,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
.option("--wake-after <duration>", "For state=blocked: atomically arm a timer (for example 90s, 15m, 2h)", wakeDurationSeconds)
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--summary <text>", "Summary persisted onto the item, with or without a state change (a blank one isn't stored)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item with a state change (a park, a done close; a blank one isn't stored). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--note <text>", "Transition note for the audit log")
.addHelpText("after", `
After reading an existing remote successor, record its exact closure target, including the host:
+1 -1
View File
@@ -1675,7 +1675,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
resolution: z.string().optional().describe("The outcome the item keeps, stored only by the update that closes an open item (as rig queue update --resolution)"),
reopen: z.boolean().optional().describe("Reopen a closed item: give an active state (pending, in-progress or blocked) and a note (as rig queue update --reopen)"),
summary: z.string().optional().describe("Replace the one-line summary"),
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item when closing with state done or parking on a human seat; other updates refuse it. For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item with a state change (a park, a done close; a blank one isn't stored). For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
blockedOn: z.string().optional().describe("What it waits on, when blocking: another qitemId (this item returns to pending when that one closes), or the address of the person this item came from, not a shared channel (they're asked with this item's summary, and their answer returns it to you in progress)"),
wakeAfterSeconds: z.number().int().positive().optional().describe("Wake this seat after this many seconds, when blocking. Delivered only to a seat with a terminal: an attached app can't receive it today, so the item just waits until it is checked."),
}),
@@ -165,7 +165,7 @@ describe("queue writes refused for a host token the daemon doesn't know", () =>
["a remote's refusal", { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "remote-error", remoteStatus: 400, detail: "HTTP 400" } }],
["an auth failure", { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "auth-failed", detail: "401" } }],
["an internal error", { status: 500, data: { error: "internal_error", message: "boom" } }],
["a 400 this list doesn't name", { status: 400, data: { error: "summary_evidence_not_persistable", message: "x" } }],
["a 400 this list doesn't name", { status: 400, data: { error: "closure_fields_not_admitted", message: "x" } }],
];
for (const [label, reply] of UNPROVEN) {
it(`never says it after ${label}`, async () => {
@@ -1,9 +1,7 @@
// 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.
// 51-06 D2, narrowed — REAL CLI acceptance boundary. 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. A state change that carries --summary or
// --evidence-ref is applied and stores them; a blank --summary is reported as not stored, never dropped silently.
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
@@ -107,27 +105,29 @@ describe("51-06 D2 — real CLI->loopback HTTP->queueRoutes/QueueRepository acce
});
afterEach(() => { vi.unstubAllEnvs(); });
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("normal: --summary and --evidence-ref on a state change are applied and stored (exit 0)", async () => {
const beforeTxns = txnCount(id);
const { exit } = await runCli(["update", id, "--state", "in-progress", "--summary", "KEEP", "--evidence-ref", "/proof/e.md"]);
expect(exit).toBeFalsy();
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "KEEP", evidenceRef: "/proof/e.md" });
expect(txnCount(id)).toBe(beforeTxns + 1);
});
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("a finished seat's park on a typed gate with --summary and --evidence-ref parks and stores both (--json)", async () => {
await runCli(["update", id, "--state", "in-progress"]);
const { out, exit } = await runCli(["block", id, "--on", "external:relay-reply", "--summary", "Relayed; waiting on the reply", "--evidence-ref", "/proof/relay.md", "--json"]);
expect(exit).toBeFalsy();
expect(JSON.parse(out)).toMatchObject({ state: "blocked", summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "blocked", blockedOn: "external:relay-reply", summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
});
it.each([false, true])("a blank --summary is applied without it and reported as not stored (json=%s)", async (json) => {
db.prepare("UPDATE queue_items SET summary = ? WHERE qitem_id = ?").run("Original subject", id);
const { out, exit } = await runCli(["update", id, "--state", "in-progress", "--summary", " ", ...(json ? ["--json"] : [])]);
expect(exit).toBeFalsy();
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "Original subject" });
if (json) expect(JSON.parse(out).advisories).toEqual([expect.objectContaining({ code: "summary_not_stored", field: "summary" })]);
else expect(out).toContain(`The summary was blank, so it wasn't stored: ${id} keeps its summary`);
});
it("CONTROL: real-CLI human-seat park PERSISTS summary + evidence (exit 0)", async () => {
+33 -52
View File
@@ -520,14 +520,13 @@ export interface QueueUpdateInput {
wakeMessage?: string;
/**
* OPR.0.4.4.19 FR-6 — park-time inputs. summary + evidence_ref are
* updatable AT THE PARK MOMENT (state=blocked with a human-seat blocker),
* not create-only: `rig queue block --summary --evidence-ref` persists
* them onto the EXISTING item so the attention query + Packet 2 read
* them. A done close also persists evidence_ref, so a finished row carries
* its artifact. OPR.0.5.1 slice-51-06 D2: supplying either where it can't
* be stored is REJECTED (QueueRepositoryError "summary_evidence_not_persistable")
* before any mutation — not silently ignored — so a caller never believes
* unpersistable metadata was saved.
* updatable with any state change, not create-only: `rig queue block
* --summary --evidence-ref` persists them onto the EXISTING item so the
* attention query + Packet 2 read them, and a done close leaves its
* artifact on the finished row. A human-seat park still requires them.
* A blank value is not stored (it would erase the row's subject or
* evidence link); the update route reports any value
* the stored row doesn't carry, so a caller never believes it was saved.
*/
summary?: string | null;
evidenceRef?: string | null;
@@ -3455,6 +3454,7 @@ export class QueueRepository {
}
const hasNote = typeof input.transitionNote === "string" && input.transitionNote.trim().length > 0;
const hasValidSummary = typeof input.summary === "string" && input.summary.trim().length > 0;
const hasValidEvidenceRef = typeof input.evidenceRef === "string" && input.evidenceRef.trim().length > 0;
const isGuardedTerminal = (["done", "canceled", "handed-off"] as const).includes(
qitem.state as "done" | "canceled" | "handed-off",
);
@@ -3664,35 +3664,15 @@ export class QueueRepository {
throw new QueueRepositoryError("invalid_human_notification", "An informational delivery cannot become a human approval park; author a separate decision request.");
}
// A park on a person by any address (a legacy human seat, or a registered person's own address or alias) keeps the
// summary and evidence it names. validateHumanPark still requires them only for the legacy human-seat form.
// summary and evidence it names, or the row's own where it names a blank one. validateHumanPark still requires them
// only for the legacy human-seat form.
const isHumanPark = input.state === "blocked" && !!effectiveBlockedOn && this.isPersonAddress(effectiveBlockedOn);
// A done close can also store the artifact the work produced, so the finished row carries its link.
const isDoneClose = input.state === "done";
// OPR.0.5.1 slice-51-06 D2 refused the whole transition when summary/evidence_ref came with a state it
// didn't store them for, so finished work could stay pending over optional metadata. A state change now
// stores them (below), except a blank value; the route reports what wasn't stored.
// 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 && !isDoneClose) invalidFields.push("evidenceRef");
if (invalidFields.length > 0) {
const flags = invalidFields.map((f) => (f === "summary" ? "--summary" : "--evidence-ref")).join(" / ");
const where = invalidFields.map((f) => f === "summary"
? "summary persists on a human-seat park, or on its own (rig queue update <id> --summary … with no --state)"
: "evidenceRef persists on a human-seat park or a done close").join("; ");
throw new QueueRepositoryError(
"summary_evidence_not_persistable",
`${where}; the '${input.state}' transition cannot store ${invalidFields.length > 1 ? "them" : "it"}. Remove ${flags}, or use one of those.`,
{ invalidFields },
);
}
}
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE, beside the reference
// reject above: an incoherent field must never silently persist (worse than a drop —
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE: an incoherent
// field must never silently persist (worse than a drop —
// the COALESCE below would write it). Admits-map, derived from LIVE schema use:
// closure_reason/closure_target → state "done", OR the PARK-RECORD form
// (state "blocked" with closureReason "blocked_on" — the workflow gate/park
@@ -3770,8 +3750,8 @@ export class QueueRepository {
let effectiveSummary = qitem.summary;
let effectiveEvidenceRef = qitem.evidenceRef;
if (isHumanPark) {
effectiveSummary = input.summary ?? qitem.summary;
effectiveEvidenceRef = input.evidenceRef ?? qitem.evidenceRef;
effectiveSummary = hasValidSummary ? input.summary! : qitem.summary;
effectiveEvidenceRef = hasValidEvidenceRef ? input.evidenceRef! : qitem.evidenceRef;
const park = validateHumanPark({
blockedOn: effectiveBlockedOn,
summary: effectiveSummary,
@@ -3899,15 +3879,13 @@ export class QueueRepository {
input.qitemId
);
// FR-6: park-time summary/evidence_ref are PERSISTED onto the existing
// item (not merely validated-then-dropped) — visible to the attention
// query and to Packet 2. A done close writes evidence_ref the same way.
if (isHumanPark) {
this.persistSummary(input.qitemId, input.summary ?? null);
this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null);
} else if (isDoneClose) {
this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null);
}
// FR-6: summary/evidence_ref are PERSISTED onto the existing item (not
// merely validated-then-dropped) — visible to the attention query, to
// Packet 2 and to every reader of the row. Only non-blank values are
// stored, on a park on a person too: a blank one would erase the row's
// subject or evidence link, and the route reports it as not stored.
if (hasValidSummary) this.persistSummary(input.qitemId, input.summary!);
if (hasValidEvidenceRef) this.persistEvidenceRef(input.qitemId, input.evidenceRef!);
const transition = this.transitionLog.append({
qitemId: input.qitemId,
@@ -3991,9 +3969,9 @@ export class QueueRepository {
closureReason: validation.closureReason ?? null,
closureTarget: validation.closureTarget ?? null,
actorSession: input.actorSession,
// FR-1 × FR-6: the event carries the summary as of THIS mutation
// FR-1 × FR-6: the event carries the summary as stored by THIS mutation
// (park-time summary included) so surfaces refresh without a fetch.
summary: effectiveSummary ?? null,
summary: this.getById(input.qitemId)?.summary ?? null,
});
return { persistedEvent, persistedEvents: [...dependentEvents, persistedEvent] };
}
@@ -4560,9 +4538,11 @@ export class QueueRepository {
}
/** A park's current question: its latest human-required transition, and that notification's final post receipt, if any. */
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions) and its decision state
* (open, blocked or terminal; who it's addressed to or parked on; its answers, a decision made for them, the current park,
* and the replies returned). A label, a wake, a claim or a note on a park doesn't change it; a new park does, on any blocker. */
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions, and the evidence it
* points to, when it has any) and its decision state (open, blocked or terminal; who it's addressed to or parked on; its
* answers, a decision made for them, the current park, and the replies returned). A label, a wake, a claim or a note on a
* park doesn't change it; a new park does, on any blocker, and so does a new evidence pointer. A row with no evidence
* keeps the revision it had before evidence counted. */
decisionRevision(item: QueueItem, opts?: DecisionRevisionOptions): string {
const phase = item.state === "blocked" ? "blocked" : item.state === "pending" || item.state === "in-progress" ? "open" : item.state;
const excludeOp =
@@ -4589,7 +4569,8 @@ export class QueueRepository {
if (transitions[i]!.state === "blocked" && transitions[i - 1]?.state !== "blocked") parkEntry = transitions[i]!.transitionId;
}
const decision = [item.body, item.humanIntent ?? null, item.humanQuestions ?? null, phase, item.destinationSession, item.blockedOn,
item.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies];
item.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies,
...(item.evidenceRef ? [item.evidenceRef] : [])];
return `dr1-${createHash("sha256").update(JSON.stringify(decision)).digest("hex").slice(0, 24)}`;
}
+21 -5
View File
@@ -211,6 +211,24 @@ export function queueRoutes(): Hono {
message: `No wake is set for this park: nothing wakes ${item.destinationSession} on a timer while ${item.qitemId} waits on ${item.blockedOn}. To add one, park it again with --wake-after <duration> (or --wake-watchdog <job-id>). When ${item.blockedOn} clears, rig queue resolve ${item.qitemId} --decision <evidence> unparks it.`,
};
}
/** A summary or evidence ref the update asked for but the stored row doesn't carry (a blank one, which isn't stored
* because it would erase what the row has, or one the repository couldn't write): read from the stored row, so the
* caller is told whatever the reason. */
function unstoredAdvisories(body: { summary?: unknown; evidenceRef?: unknown }, item: QueueItem) {
return ([["summary", "summary", "summary_not_stored"], ["evidenceRef", "evidence ref", "evidence_ref_not_stored"]] as const)
.flatMap(([field, name, code]) => {
const requested = body[field];
const stored = item[field];
if (typeof requested !== "string" || stored === requested) return [];
const why = requested.trim().length === 0 ? `The ${name} was blank, so it wasn't stored` : `The ${name} wasn't stored`;
return [{
code,
qitemId: item.qitemId,
field,
message: `${why}: ${item.qitemId} keeps ${stored ? `its ${name} "${stored}"` : `no ${name}`}. The rest of the update was applied.`,
}];
});
}
function getInbox(c: { get: (key: string) => unknown }): InboxHandler {
return c.get("inboxHandler" as never) as InboxHandler;
}
@@ -312,9 +330,6 @@ export function queueRoutes(): Hono {
: err.code === "reply_to_requires_update" ? 400
: err.code === "reply_to_not_found" ? 400
: err.code === "invalid_human_questions" ? 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.
@@ -922,8 +937,9 @@ export function queueRoutes(): Hono {
evidenceRef: body.evidenceRef,
...resolveRecordedIdentity(c, identity), // P21 §4 era-stamp, plus the MCP adapter's caller on a direct attributed request (principal:v1)
});
const advisory = body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null;
return c.json({ ...item, ...(advisory ? { advisories: [advisory] } : {}) });
const advisories = [body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null, ...unstoredAdvisories(body, item)]
.filter((advisory) => advisory !== null);
return c.json({ ...item, ...(advisories.length > 0 ? { advisories } : {}) });
} catch (err) {
return errorResponse(c, err);
}
@@ -201,6 +201,29 @@ describe("an opt-in decision revision on the settling verbs (#2446)", () => {
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read })).toMatchObject({ status: 200, body: { closedQitem: { state: "done" } } });
});
it("a new evidence pointer on an open ask is a new decision: an answer read under the earlier pointer conflicts, with nothing written", async () => {
const f = fixture();
const id = (await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Approve the release notes", summary: "Notes?",
humanIntent: "decision", evidenceRef: "/proof/notes-v1.md", nudge: false })).qitemId;
const read = await f.revision(id);
f.repo.update({ qitemId: id, actorSession: ASKER, state: "pending", evidenceRef: "/proof/notes-v2.md" });
expect(f.repo.getById(id)).toMatchObject({ state: "pending", evidenceRef: "/proof/notes-v2.md" });
expect(await f.revision(id)).not.toBe(read);
const before = f.written(id);
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read }))
.toMatchObject({ status: 409, body: { error: "decision_revision_changed", currentRevision: await f.revision(id) } });
expect(f.written(id)).toEqual(before);
});
it("a row with no evidence keeps the revision it had before evidence counted", async () => {
// A fixed id, so the revision (which names the row's park episode) is the same on every run; the value is the one
// this ask had before evidence counted.
const f = fixture();
const id = (await f.repo.create({ qitemId: "qitem-revision-control", sourceSession: ASKER, destinationSession: PERSON,
body: "Synthetic two-question ask", summary: "Ship?", humanIntent: "decision", humanQuestions: QUESTIONS, nudge: false })).qitemId;
expect(await f.revision(id)).toBe("dr1-bed30769297c81fe419c3520");
});
it("repairs a direct-answer partial commit on retry without duplicating the reply row, then reconciles subsequent retries", async () => {
const f = fixture();
const row = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false });
@@ -307,26 +307,18 @@ describe("FR-6 park-on-human (leg-1) — enforcement + persistence + attention",
expect(ev!.toState).toBe("blocked");
});
// 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 () => {
// OPR.0.5.1 slice-51-06 D2 first replaced a silent drop with a whole-transition refusal. Narrowed: a
// non-park state change now applies and stores them, so nothing is dropped and finished work isn't held.
it("non-park updates STORE summary/evidence_ref inputs and apply the state change", async () => {
const item = await inProgressItem();
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(); // nothing persisted (reject was before any write)
expect(read.evidenceRef).toBeNull();
const res = repo.update({
qitemId: item.qitemId,
actorSession: "driver@rig",
state: "in-progress",
summary: "now persists",
evidenceRef: "now-persists.md",
});
expect(res).toMatchObject({ state: "in-progress", summary: "now persists", evidenceRef: "now-persists.md" });
expect(repo.getById(item.qitemId)).toMatchObject({ summary: "now persists", evidenceRef: "now-persists.md" });
});
});
@@ -1,7 +1,7 @@
// 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).
// 51-06 D2, narrowed — summary/evidence_ref given with a state change are STORED and the transition applies:
// optional metadata never holds finished work at pending. A blank summary is the one value not stored (it would
// erase the row's subject); the route reports it, so a caller never believes it was saved.
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import type Database from "better-sqlite3";
@@ -17,11 +17,9 @@ import { queueTargetRepoSchema } from "../src/db/migrations/039_queue_target_rep
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 { QueueRepository } 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(); });
@@ -29,7 +27,7 @@ function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
return app;
}
describe("51-06 D2 — non-park queue update metadata reject", () => {
describe("51-06 D2 narrowed — queue update metadata on a state change", () => {
let db: Database.Database;
let bus: EventBus;
let repo: QueueRepository;
@@ -46,52 +44,57 @@ describe("51-06 D2 — non-park queue update metadata reject", () => {
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;
const setSummary = (id: string, summary: string) => db.prepare("UPDATE queue_items SET summary = ? WHERE qitem_id = ?").run(summary, id);
const setEvidence = (id: string, ref: string) => db.prepare("UPDATE queue_items SET evidence_ref = ? WHERE qitem_id = ?").run(ref, id);
const post = (path: string, body: Record<string, unknown>, session: string) => app.request(path, {
method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": session }, body: JSON.stringify(body),
});
async function expectRejectZeroMutation(input: Record<string, unknown>, invalidFields: string[]) {
it.each([
["summary", { summary: "KEEP" }, { summary: "KEEP", evidenceRef: null }],
["evidence_ref", { evidenceRef: "/proof/x.md" }, { summary: null, evidenceRef: "/proof/x.md" }],
["both", { summary: "S", evidenceRef: "/e" }, { summary: "S", evidenceRef: "/e" }],
])("a state change carrying %s applies and stores it, with one transition", async (_label, input, stored) => {
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"]);
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input });
expect(res).toMatchObject({ state: "in-progress", ...stored });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", ...stored });
expect(txnCount(id)).toBe(2);
});
it("also rejects on a terminal non-park transition (in-progress -> done + closure)", async () => {
it("a finished seat's park on another row stores its summary and evidence, and the event carries the summary", 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
const blocker = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const events: Array<Record<string, unknown>> = [];
bus.subscribe((e) => events.push(e as unknown as Record<string, unknown>));
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "blocked", blockedOn: blocker, summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(res).toMatchObject({ state: "blocked", blockedOn: blocker, summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(events.find((e) => e.type === "queue.updated")).toMatchObject({ toState: "blocked", summary: "Relayed; waiting on the reply" });
});
it("metadata-free non-park update is UNCHANGED (null=absent, no reject)", async () => {
it.each(["done", "canceled"] as const)("a %s close stores summary and evidence_ref on the closed row", async (state) => {
const id = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const close = state === "done" ? { closureReason: "no-follow-on" } : {};
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state, ...close, summary: "Finished", evidenceRef: "/proof/result.md" });
expect(res).toMatchObject({ state, summary: "Finished", evidenceRef: "/proof/result.md" });
const shown = await app.request(`/api/queue/${id}`);
expect(shown.status).toBe(200);
expect(await shown.json()).toMatchObject({ state, summary: "Finished", evidenceRef: "/proof/result.md" });
});
it.each(["", " "])("a blank summary (%j) isn't stored: the state still changes and the row keeps its subject", async (blank) => {
const id = await mkItem();
setSummary(id, "Original subject");
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: blank, evidenceRef: "/e" });
expect(res).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: "/e" });
});
it("metadata-free update is UNCHANGED (null = absent)", 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");
expect(res).toMatchObject({ state: "in-progress", summary: null, evidenceRef: null });
});
it("CONTROL: human-seat park still PERSISTS summary + evidence byte-for-byte", async () => {
@@ -114,43 +117,101 @@ describe("51-06 D2 — non-park queue update metadata reject", () => {
expect(people.update({ qitemId: bare, actorSession: "orch@rig", state: "blocked", blockedOn })).toMatchObject({ state: "blocked", summary: null });
});
it("an unregistered name on another daemon's domain still refuses summary on a park", async () => {
it.each(["", " "])("a blank summary and evidence ref (%j) on a park on a registered person aren't stored: the row keeps its own", async (blank) => {
const person = { entityId: "sarah", class: "human" as const, displayName: "Sarah", address: "sarah@external", connectorBindings: [],
prefs: { deliveryClass: "A" as const } };
const people = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [person] }) });
const id = await mkItem();
setSummary(id, "Which layout?"); setEvidence(id, "/proof/layout.md");
expect(people.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@external", summary: blank, evidenceRef: blank }))
.toMatchObject({ state: "blocked", blockedOn: "sarah@external", summary: "Which layout?", evidenceRef: "/proof/layout.md" });
});
it("a legacy human-seat park given a blank summary and evidence ref parks on the row's own, instead of refusing", async () => {
const id = await mkItem();
setSummary(id, "PARK-KEEP"); setEvidence(id, "/proof/park.md");
expect(repo.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: " ", evidenceRef: " " }))
.toMatchObject({ state: "blocked", summary: "PARK-KEEP", evidenceRef: "/proof/park.md" });
});
it("ROUTE: a blank summary on a park on a person -> 200, parked, and the advisory names the summary as not stored", async () => {
const id = await mkItem();
setSummary(id, "Original subject"); setEvidence(id, "/proof/kept.md");
const res = await post(`/api/queue/${id}/update`, { actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: " " }, "orch@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: unknown };
expect(body).toMatchObject({ state: "blocked", summary: "Original subject" });
expect(body.advisories).toEqual([expect.objectContaining({ code: "summary_not_stored", field: "summary" })]);
});
it("a park on an unregistered name on another daemon's domain stores its summary too", async () => {
const nobody = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
const id = await mkItem();
let err: unknown;
try { nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect(nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }))
.toMatchObject({ state: "blocked", blockedOn: "sarah@host", summary: "S" });
});
it("a done close stores evidence_ref, and queue show returns it on the closed row", async () => {
const id = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", evidenceRef: "/proof/result.md" });
expect(res).toMatchObject({ state: "done", evidenceRef: "/proof/result.md" });
const shown = await app.request(`/api/queue/${id}`);
expect(shown.status).toBe(200);
expect(((await shown.json()) as { state: string; evidenceRef?: string | null })).toMatchObject({ state: "done", evidenceRef: "/proof/result.md" });
});
it("a canceled close still refuses evidence_ref, before any mutation", async () => {
const id = await mkItem();
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "canceled", evidenceRef: "/proof/x.md" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["evidenceRef"]);
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "pending", evidenceRef: null });
});
it("ROUTE: POST /:id/update non-park + summary -> HTTP 400 naming invalidFields", async () => {
// P21 I3: create/update derive the sender from the transport header; header==body claim ⇒ tolerated.
const created = await app.request("/api/queue/create", { method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": "orch@rig" }, body: JSON.stringify({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }) });
it("ROUTE: POST /:id/update with a state change + summary -> 200, stored, no advisory", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }, "orch@rig");
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", "X-OpenRig-Session": "dev-x@rig" }, 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"]);
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "KEEP" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: unknown };
expect(body).toMatchObject({ state: "in-progress", summary: "KEEP" });
expect(body.advisories).toBeUndefined();
});
it("ROUTE: a blank summary with a state change -> 200, state applied, and an advisory names the summary as not stored", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", summary: "Original subject" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", summary: "Original subject" });
expect(body.advisories).toEqual([{
code: "summary_not_stored", qitemId: id, field: "summary",
message: `The summary was blank, so it wasn't stored: ${id} keeps its summary "Original subject". The rest of the update was applied.`,
}]);
});
it("a stored evidence pointer survives a blank one on a state change, and a done close keeps it too", async () => {
const id = (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item", evidenceRef: "/proof/kept.md" })).qitemId;
expect(repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", evidenceRef: "" })).toMatchObject({ state: "in-progress", evidenceRef: "/proof/kept.md" });
expect(repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", evidenceRef: " " }))
.toMatchObject({ state: "done", evidenceRef: "/proof/kept.md" });
});
it("ROUTE: a blank evidence ref with a state change -> 200, the pointer kept, and an advisory names it as not stored", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", evidenceRef: "/proof/kept.md" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", evidenceRef: " " }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; evidenceRef: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", evidenceRef: "/proof/kept.md" });
expect(body.advisories).toEqual([{
code: "evidence_ref_not_stored", qitemId: id, field: "evidenceRef",
message: `The evidence ref was blank, so it wasn't stored: ${id} keeps its evidence ref "/proof/kept.md". The rest of the update was applied.`,
}]);
});
it("ROUTE: when the repository can't write either field, the state still changes, the caller is told, and the event agrees with the stored row", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", summary: "Original subject" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
// A column probe that read false at construction (its PRAGMA read failed), while the columns exist.
Object.assign(repo as unknown as Record<string, boolean>, { hasSummaryColumn: false, hasEvidenceRefColumn: false });
const events: Array<Record<string, unknown>> = [];
bus.subscribe((e) => events.push(e as unknown as Record<string, unknown>));
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "New subject", evidenceRef: "/proof/new.md" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; evidenceRef: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: null });
expect(body.advisories).toEqual([
{ code: "summary_not_stored", qitemId: id, field: "summary",
message: `The summary wasn't stored: ${id} keeps its summary "Original subject". The rest of the update was applied.` },
{ code: "evidence_ref_not_stored", qitemId: id, field: "evidenceRef",
message: `The evidence ref wasn't stored: ${id} keeps no evidence ref. The rest of the update was applied.` },
]);
expect(events.find((e) => e.type === "queue.updated")).toMatchObject({ toState: "in-progress", summary: "Original subject" });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: null });
});
});