@staix/agent-hub 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +11 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +178 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +23 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +355 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +24 -5
  21. package/docs/security.md +178 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  30. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  31. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  32. package/docs/verified.json +14 -14
  33. package/package.json +3 -4
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +4393 -3609
  36. package/src/adapters/acp.ts +5 -4
  37. package/src/adapters/channel-peer.ts +72 -3
  38. package/src/adapters/claude-channel-evidence.ts +52 -0
  39. package/src/adapters/claude-channel.ts +61 -18
  40. package/src/adapters/claude-implementer.ts +29 -7
  41. package/src/adapters/claude-print-child.ts +12 -2
  42. package/src/adapters/codex-appserver.ts +13 -12
  43. package/src/adapters/compat.ts +3 -3
  44. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  45. package/src/adapters/definitions/qwen.ts +1 -0
  46. package/src/adapters/local-worker.ts +61 -18
  47. package/src/adapters/pi.ts +48 -6
  48. package/src/adapters/registry-types.ts +12 -0
  49. package/src/adapters/registry.ts +2 -0
  50. package/src/adapters/reviewer-print.ts +50 -11
  51. package/src/broker/server.ts +44 -13
  52. package/src/broker/store.ts +38 -8
  53. package/src/cli/answer-args.ts +50 -0
  54. package/src/cli/attention.ts +17 -2
  55. package/src/cli/backlog-return-args.ts +37 -0
  56. package/src/cli/console-question.ts +110 -0
  57. package/src/cli/console-state.ts +78 -17
  58. package/src/cli/console.ts +54 -1
  59. package/src/cli/handoff-args.ts +15 -5
  60. package/src/cli/handoff-output.ts +15 -2
  61. package/src/cli/help.ts +8 -5
  62. package/src/cli/identity.ts +13 -3
  63. package/src/cli/main.ts +128 -16
  64. package/src/cli/memory.ts +107 -3
  65. package/src/cli/output.ts +29 -6
  66. package/src/cli/pet.ts +4 -4
  67. package/src/cli/results-args.ts +30 -0
  68. package/src/cli/room.ts +20 -10
  69. package/src/cli/runtime-version-warning.ts +50 -0
  70. package/src/cli/status-lines.ts +41 -1
  71. package/src/cli/tail-render.ts +1 -0
  72. package/src/cli/upgrade-interactive.ts +14 -2
  73. package/src/hub/action-class.ts +9 -1
  74. package/src/hub/board.ts +369 -18
  75. package/src/hub/bus.ts +511 -53
  76. package/src/hub/completion-detail.ts +50 -0
  77. package/src/hub/completions-store.ts +168 -0
  78. package/src/hub/completions.ts +120 -0
  79. package/src/hub/config-apply.ts +7 -5
  80. package/src/hub/config-reload.ts +10 -1
  81. package/src/hub/control-client.ts +40 -4
  82. package/src/hub/daemon.ts +1576 -139
  83. package/src/hub/delivery-failure.ts +14 -0
  84. package/src/hub/delivery-hints.ts +14 -4
  85. package/src/hub/delivery-journal.ts +751 -42
  86. package/src/hub/delivery-superseded.ts +4 -1
  87. package/src/hub/envelope.ts +3 -3
  88. package/src/hub/events.ts +6 -0
  89. package/src/hub/handoff.ts +145 -6
  90. package/src/hub/hub-tools.ts +56 -7
  91. package/src/hub/manager.ts +59 -3
  92. package/src/hub/memory-policy.ts +44 -1
  93. package/src/hub/memory-sync-policy.ts +109 -0
  94. package/src/hub/memory-sync-state.ts +145 -0
  95. package/src/hub/no-writes.ts +1 -1
  96. package/src/hub/operational-settings.ts +11 -2
  97. package/src/hub/operational-view.ts +12 -1
  98. package/src/hub/peers.ts +25 -2
  99. package/src/hub/planning.ts +6 -1
  100. package/src/hub/question-desk.ts +201 -0
  101. package/src/hub/question-detach.ts +6 -0
  102. package/src/hub/questions.ts +260 -0
  103. package/src/hub/research-evidence.ts +106 -0
  104. package/src/hub/restart.ts +14 -0
  105. package/src/hub/review-anchor.ts +181 -0
  106. package/src/hub/review-brief.ts +13 -0
  107. package/src/hub/review-comments.ts +115 -0
  108. package/src/hub/review-findings.ts +7 -2
  109. package/src/hub/review-material.ts +76 -0
  110. package/src/hub/routing.ts +3 -1
  111. package/src/hub/task-sweep.ts +2 -0
  112. package/src/hub/tasks.ts +577 -63
  113. package/src/memory/capture.ts +5 -2
  114. package/src/pet/config.ts +25 -13
  115. package/src/rooms/commands.ts +61 -16
  116. package/src/rooms/link.ts +338 -36
  117. package/src/rooms/liveness.ts +11 -0
  118. package/src/rooms/remote-peer.ts +186 -19
  119. package/src/rooms/service.ts +475 -35
  120. package/src/rooms/status.ts +8 -1
  121. package/src/rooms/store.ts +447 -65
  122. package/src/rooms/wire.ts +24 -6
  123. package/src/ui/index.html +142 -9
  124. package/src/ui/needs.ts +10 -4
  125. package/src/ui/plugin-questions.ts +56 -0
  126. package/src/ui/plugin-results.ts +81 -0
  127. package/src/ui/plugin.ts +207 -27
  128. package/src/pet/window.html +0 -114
  129. package/src/pet/window.jxa.js +0 -122
  130. package/src/pet/window.ts +0 -154
@@ -1,14 +1,17 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { dirname } from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
3
5
  import type { Database } from "./db-schema.ts";
4
6
  import { openHubDatabase } from "./db-schema.ts";
5
7
  import type { BusSnapshot } from "./bus.ts";
6
8
  import type { QueuePressure } from "./queue-backpressure.ts";
7
- import { HUB, KINDS, USER, type Envelope, type Kind } from "./envelope.ts";
9
+ import { HUB, KINDS, USER, isInvocationKind, type Envelope, type Kind } from "./envelope.ts";
8
10
  import { heldDeliveryHints } from "./delivery-hints.ts";
9
11
  import { processLiveness } from "../pi/process-signature.ts";
10
12
  import { parseRemotePeerId } from "../rooms/application.ts";
11
13
  import { validateDeferredHub } from "./deferred-hub.ts";
14
+ import { isDeliveryFailureCause, type DeliveryFailureCause } from "./delivery-failure.ts";
12
15
 
13
16
  export type JournalDeliveryState =
14
17
  | "queued"
@@ -21,8 +24,11 @@ export type JournalDeliveryState =
21
24
  export type JournalResolution = "completed" | "retry" | "discard";
22
25
  export const MAX_ENVELOPE_BODY = 1_000_000;
23
26
  export const TERMINAL_CAP = 2048;
27
+ export const ACCEPTED_SETTLE_TIMEOUT_REASON = "channel delivery confirmation timed out; inspect effects before resolving";
24
28
  const SCHEMA_VERSION = 1,
25
29
  MAX_ROWS = 100_000,
30
+ MAX_REVIEW_INPUTS = 4096,
31
+ REVIEW_INPUT_VERSION = 2,
26
32
  MAX_JSON = 20 * 1024 * 1024;
27
33
  const STATES = new Set<JournalDeliveryState>([
28
34
  "queued",
@@ -47,9 +53,29 @@ export interface JournalDelivery {
47
53
  originals: Envelope[];
48
54
  out: Envelope[];
49
55
  reason?: string;
56
+ failureCause?: DeliveryFailureCause;
50
57
  attempt?: number;
51
58
  previousId?: string;
52
59
  }
60
+ export interface ReviewInputHold { peer: string; sessionHash: string; inputHash: string | null }
61
+ export interface ReviewInputProvenance { version: 2; instanceId: string; complete: boolean; coverageComplete: boolean; count: number; digest: string; holds: ReviewInputHold[]; incompletePeers: string[]; incompleteSessions: Array<{ peer: string; sessionHash: string }> }
62
+ export type ReviewInputState = { state: "clean" } | { state: "held"; inputHash: string } | { state: "unknown" };
63
+ export interface ReviewInputEvidence {
64
+ provenance: "available" | "corrupt";
65
+ coverageComplete: boolean;
66
+ incompletePeer: boolean;
67
+ incompleteSession: boolean;
68
+ sessionState: "clean" | "held" | "unknown";
69
+ retainedSessionHold: boolean;
70
+ retainedPeerSessionHold: boolean;
71
+ }
72
+ export type ReviewInputHealthCause = "legacy_coverage" | "incomplete_markers" | "provenance_incomplete" | "provenance_corrupt";
73
+ export interface ReviewInputHealth {
74
+ state: "healthy" | "incomplete" | "corrupt";
75
+ cause: ReviewInputHealthCause | null;
76
+ nextStep: string;
77
+ }
78
+ export interface AcceptedDeliveryCandidate { id: string; peer: string; revision: number; updatedAt: number; notifyConductor: boolean }
53
79
  export interface DeliveryJournalOptions {
54
80
  file: string;
55
81
  projectRoot: string;
@@ -66,7 +92,9 @@ export interface JournalSnapshot {
66
92
  bus: BusSnapshot;
67
93
  deliveries: JournalDelivery[];
68
94
  manualPaused: string[];
95
+ reviewInputProvenance?: ReviewInputProvenance;
69
96
  }
97
+ export type JournalTransportSnapshot = Omit<JournalSnapshot, "reviewInputProvenance">;
70
98
 
71
99
  function fail(message: string): never {
72
100
  throw new Error(`invalid delivery journal: ${message}`);
@@ -81,6 +109,71 @@ function text(value: unknown, name: string, max = 4096): string {
81
109
  fail(`${name} must be a non-empty string`);
82
110
  return value;
83
111
  }
112
+ function hashText(value: unknown, name: string): string {
113
+ const hash = text(value, name, 64);
114
+ if (!/^[a-f0-9]{64}$/.test(hash)) fail(`${name} is invalid`);
115
+ return hash;
116
+ }
117
+ function closedOwnDataFields(value: unknown, name: string, expected: readonly string[]): Record<string, unknown> {
118
+ try {
119
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) fail(`${name} must be a plain object`);
120
+ const keys = Reflect.ownKeys(value);
121
+ if (keys.length !== expected.length || !expected.every(key => keys.includes(key))) fail(`${name} has unexpected fields`);
122
+ const descriptors = Object.getOwnPropertyDescriptors(value);
123
+ if (keys.some(key => typeof key !== "string" || !descriptors[key]?.enumerable || !("value" in descriptors[key]!))) fail(`${name} must contain own data fields`);
124
+ return Object.fromEntries(keys.map(key => [key as string, descriptors[key as string]!.value]));
125
+ } catch (error) {
126
+ if (error instanceof Error && error.message.startsWith("invalid delivery journal:")) throw error;
127
+ fail(`${name} is unreadable`);
128
+ }
129
+ }
130
+ function reviewInputHold(value: unknown, name: string): ReviewInputHold {
131
+ const fields = closedOwnDataFields(value, name, ["peer", "sessionHash", "inputHash"]);
132
+ const peer = text(fields.peer, `${name}.peer`, 256);
133
+ const sessionHash = hashText(fields.sessionHash, `${name}.sessionHash`);
134
+ const inputHash = fields.inputHash === null ? null : hashText(fields.inputHash, `${name}.inputHash`);
135
+ return { peer, sessionHash, inputHash };
136
+ }
137
+ function reviewInputDigest(instanceId: string, version: number, complete: boolean, coverageComplete: boolean, count: number, holds: readonly ReviewInputHold[], incompletePeers: readonly string[], incompleteSessions: readonly { peer: string; sessionHash: string }[]): string {
138
+ const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
139
+ const normalizedHolds = [...holds].map(hold => ({ peer: hold.peer, sessionHash: hold.sessionHash, inputHash: hold.inputHash }))
140
+ .sort((a, b) => compare(a.peer, b.peer) || compare(a.sessionHash, b.sessionHash));
141
+ const normalizedPeers = [...incompletePeers].sort();
142
+ const normalizedSessions = [...incompleteSessions].map(row => ({ peer: row.peer, sessionHash: row.sessionHash }))
143
+ .sort((a, b) => compare(a.peer, b.peer) || compare(a.sessionHash, b.sessionHash));
144
+ return createHash("sha256").update(JSON.stringify({ instanceId, version, complete, coverageComplete, count, holds: normalizedHolds, incompletePeers: normalizedPeers, incompleteSessions: normalizedSessions })).digest("hex");
145
+ }
146
+ function legacyReviewInputMarkerAbsent(meta: Record<string, unknown>): boolean {
147
+ return Number(meta.review_input_version ?? 0) === 0 && Number(meta.review_input_complete ?? 0) === 0
148
+ && Number(meta.review_input_coverage_complete ?? 0) === 0 && Number(meta.review_input_count ?? 0) === 0
149
+ && String(meta.review_input_digest ?? "") === "" && String(meta.review_input_instance_id ?? "") === "";
150
+ }
151
+ function validateReviewInputProvenance(value: unknown, name = "review input provenance"): ReviewInputProvenance {
152
+ const fields = closedOwnDataFields(value, name, ["version", "instanceId", "complete", "coverageComplete", "count", "digest", "holds", "incompletePeers", "incompleteSessions"]);
153
+ if (fields.version !== REVIEW_INPUT_VERSION || typeof fields.complete !== "boolean" || typeof fields.coverageComplete !== "boolean") fail(`${name} version or completeness is invalid`);
154
+ const instanceId = text(fields.instanceId, `${name}.instanceId`, 256);
155
+ if (Array.isArray(fields.holds) && fields.holds.length > MAX_REVIEW_INPUTS || Array.isArray(fields.incompletePeers) && fields.incompletePeers.length > MAX_REVIEW_INPUTS || Array.isArray(fields.incompleteSessions) && fields.incompleteSessions.length > MAX_REVIEW_INPUTS) fail(`${name} exceeds ${MAX_REVIEW_INPUTS}`);
156
+ const holds = list(fields.holds, `${name}.holds`, MAX_REVIEW_INPUTS).map((row, index) => reviewInputHold(row, `${name}.holds[${index}]`));
157
+ const incompletePeers = list(fields.incompletePeers, `${name}.incompletePeers`, MAX_REVIEW_INPUTS).map((peer, index) => text(peer, `${name}.incompletePeers[${index}]`, 256));
158
+ const incompleteSessions = list(fields.incompleteSessions, `${name}.incompleteSessions`, MAX_REVIEW_INPUTS).map((raw, index) => {
159
+ const row = closedOwnDataFields(raw, `${name}.incompleteSessions[${index}]`, ["peer", "sessionHash"]);
160
+ return { peer: text(row.peer, `${name}.incompleteSessions[${index}].peer`, 256), sessionHash: hashText(row.sessionHash, `${name}.incompleteSessions[${index}].sessionHash`) };
161
+ });
162
+ if (holds.length + incompletePeers.length + incompleteSessions.length > MAX_REVIEW_INPUTS) fail(`${name} exceeds ${MAX_REVIEW_INPUTS}`);
163
+ const keys = new Set<string>();
164
+ for (const hold of holds) {
165
+ const key = JSON.stringify([hold.peer, hold.sessionHash]);
166
+ if (keys.has(key)) fail(`${name} has duplicate sessions`);
167
+ keys.add(key);
168
+ }
169
+ if (new Set(incompletePeers).size !== incompletePeers.length) fail(`${name} has duplicate incomplete peers`);
170
+ const incompleteKeys = new Set(incompleteSessions.map(row => JSON.stringify([row.peer, row.sessionHash])));
171
+ if (incompleteKeys.size !== incompleteSessions.length) fail(`${name} has duplicate incomplete sessions`);
172
+ const count = integer(fields.count, `${name}.count`, 0, MAX_REVIEW_INPUTS);
173
+ const digest = hashText(fields.digest, `${name}.digest`);
174
+ if (count !== holds.length + incompletePeers.length + incompleteSessions.length || digest !== reviewInputDigest(instanceId, REVIEW_INPUT_VERSION, fields.complete, fields.coverageComplete, count, holds, incompletePeers, incompleteSessions)) fail(`${name} integrity does not match its rows`);
175
+ return { version: REVIEW_INPUT_VERSION, instanceId, complete: fields.complete, coverageComplete: fields.coverageComplete, count, digest, holds, incompletePeers, incompleteSessions };
176
+ }
84
177
  function integer(
85
178
  value: unknown,
86
179
  name: string,
@@ -204,6 +297,11 @@ function validateBus(value: unknown, name = "bus"): BusSnapshot {
204
297
  if (b.journal !== undefined) fail("nested journal snapshot is invalid");
205
298
  return b as unknown as BusSnapshot;
206
299
  }
300
+ function checkedFailureCause(value: unknown): DeliveryFailureCause | undefined {
301
+ if (value === undefined) return undefined;
302
+ if (!isDeliveryFailureCause(value)) fail("delivery.failureCause is invalid");
303
+ return value;
304
+ }
207
305
  function validateDelivery(value: unknown, name = "delivery"): JournalDelivery {
208
306
  const d = obj(value, name);
209
307
  text(d.id, `${name}.id`, 256);
@@ -220,6 +318,7 @@ function validateDelivery(value: unknown, name = "delivery"): JournalDelivery {
220
318
  validateEnvelope(e, `${name}.out[${i}]`),
221
319
  );
222
320
  if (d.reason !== undefined) text(d.reason, `${name}.reason`, 1_000_000);
321
+ const failureCause = checkedFailureCause(d.failureCause);
223
322
  if (d.attempt !== undefined) integer(d.attempt, `${name}.attempt`, 0, 100);
224
323
  if (d.previousId !== undefined) text(d.previousId, `${name}.previousId`, 256);
225
324
  return {
@@ -232,6 +331,7 @@ function validateDelivery(value: unknown, name = "delivery"): JournalDelivery {
232
331
  originals,
233
332
  out,
234
333
  ...(d.reason === undefined ? {} : { reason: d.reason as string }),
334
+ ...(failureCause === undefined ? {} : { failureCause }),
235
335
  ...(d.attempt === undefined ? {} : { attempt: d.attempt as number }),
236
336
  ...(d.previousId === undefined
237
337
  ? {}
@@ -259,6 +359,10 @@ export function validateJournalSnapshot(
259
359
  if (ids.has(d.id)) fail(`duplicate delivery ${d.id}`);
260
360
  ids.add(d.id);
261
361
  }
362
+ if (Object.hasOwn(s, "reviewInputProvenance")) {
363
+ const provenance = validateReviewInputProvenance(s.reviewInputProvenance, "snapshot.reviewInputProvenance");
364
+ if (provenance.instanceId !== s.instanceId) fail("snapshot review input instance binding is invalid");
365
+ }
262
366
  return true;
263
367
  }
264
368
 
@@ -272,6 +376,11 @@ export class DeliveryJournal {
272
376
  private queueMetadataTracking = false;
273
377
  private queueMetadataInvalidated = false;
274
378
  private pruningDeferred = 0;
379
+ private legacyReviewCandidateIds = new Set<string>();
380
+ private legacyReviewCandidatesCaptured = false;
381
+ private legacyReviewCandidatesOverflow = false;
382
+ private legacyReviewCandidateFenceIds: string[] | undefined;
383
+ private legacyReviewCandidateCaptureRevision = -1;
275
384
  private readonly operationId?: string;
276
385
  private readonly terminalHistoryLimit?: () => number;
277
386
  constructor(options: DeliveryJournalOptions) {
@@ -288,29 +397,52 @@ export class DeliveryJournal {
288
397
  this.db.run("PRAGMA synchronous = FULL");
289
398
  this.createSchema();
290
399
  const meta = this.readMeta();
291
- if (!meta)
400
+ let openDeliveryFenceIds: string[] | undefined;
401
+ if (!meta) {
402
+ const holds = this.reviewInputRows(), incompletePeers = this.reviewInputIncompletePeerRows(), incompleteSessions = this.reviewInputIncompleteSessionRows();
403
+ const legacyTable = this.db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='review_input_holds'").get();
404
+ const legacyCount = legacyTable ? Number((this.db.query("SELECT COUNT(*) AS n FROM review_input_holds WHERE project_id=?").get(this.projectId) as { n: number }).n) : 0;
405
+ const provenanceCount = holds.length + incompletePeers.length + incompleteSessions.length;
406
+ const complete = provenanceCount === 0 || legacyCount === provenanceCount, coverageComplete = complete && legacyCount === 0;
292
407
  this.db
293
408
  .query(
294
- "INSERT INTO delivery_meta (project_id, project_root, revision, instance_id, operation_id, retry_history_cursor) VALUES (?, ?, 0, ?, ?, 0)",
409
+ "INSERT INTO delivery_meta (project_id, project_root, revision, instance_id, operation_id, retry_history_cursor, review_input_version, review_input_complete, review_input_coverage_complete, review_input_count, review_input_digest, review_input_instance_id) VALUES (?, ?, 0, ?, ?, 0, 2, ?, ?, ?, ?, ?)",
295
410
  )
296
411
  .run(
297
412
  this.projectId,
298
413
  this.projectRoot,
299
414
  this.instanceId,
300
415
  this.operationId ?? null,
416
+ complete ? 1 : 0,
417
+ coverageComplete ? 1 : 0,
418
+ provenanceCount,
419
+ reviewInputDigest(this.instanceId, REVIEW_INPUT_VERSION, true, coverageComplete, holds.length + incompletePeers.length + incompleteSessions.length, holds, incompletePeers, incompleteSessions),
420
+ this.instanceId,
301
421
  );
302
- else {
303
- this.validateStored(meta);
422
+ } else {
423
+ openDeliveryFenceIds = this.validateStored(meta, true);
304
424
  this.rejectLiveOwner(meta);
425
+ let reviewInput: ReviewInputProvenance | undefined;
426
+ try {
427
+ const prior = this.readReviewInputProvenance(meta);
428
+ reviewInput = { ...prior, instanceId: this.instanceId, digest: reviewInputDigest(this.instanceId, REVIEW_INPUT_VERSION, prior.complete, prior.coverageComplete, prior.count, prior.holds, prior.incompletePeers, prior.incompleteSessions) };
429
+ }
430
+ catch { /* corrupt provenance remains unknown; ordinary delivery state stays available */ }
305
431
  this.db.transaction(() => {
306
432
  this.db
307
433
  .query(
308
- "UPDATE delivery_meta SET project_root = ?, instance_id = ?, operation_id = COALESCE(?, operation_id) WHERE project_id = ?",
434
+ "UPDATE delivery_meta SET project_root = ?, instance_id = ?, operation_id = COALESCE(?, operation_id), review_input_version = ?, review_input_instance_id = ?, review_input_complete = ?, review_input_coverage_complete = ?, review_input_count = ?, review_input_digest = ? WHERE project_id = ?",
309
435
  )
310
436
  .run(
311
437
  this.projectRoot,
312
438
  this.instanceId,
313
439
  this.operationId ?? null,
440
+ REVIEW_INPUT_VERSION,
441
+ this.instanceId,
442
+ reviewInput?.complete === true ? 1 : 0,
443
+ reviewInput?.coverageComplete === true ? 1 : 0,
444
+ reviewInput?.count ?? Number(meta.review_input_count ?? 0),
445
+ reviewInput?.digest ?? String(meta.review_input_digest ?? ""),
314
446
  this.projectId,
315
447
  );
316
448
  // A new daemon cannot infer completion of an old process's handoffs, including
@@ -318,9 +450,10 @@ export class DeliveryJournal {
318
450
  for (const row of this.db.query("SELECT * FROM task_offer_reads WHERE project_id = ?").all(this.projectId) as Record<string, unknown>[]) this.validateOfferRead(row);
319
451
  this.recoverRetryHistory(meta);
320
452
  this.recoverRetryEvidence();
321
- this.recoverInterrupted();
453
+ this.recoverInterrupted(meta);
322
454
  })();
323
455
  }
456
+ this.captureLegacyReviewCandidates(false, openDeliveryFenceIds);
324
457
  }
325
458
  private harden(): void {
326
459
  chmodSync(this.file, 0o600);
@@ -335,7 +468,7 @@ export class DeliveryJournal {
335
468
  }
336
469
  private createSchema(): void {
337
470
  this.db.run(
338
- `CREATE TABLE IF NOT EXISTS delivery_meta (project_id TEXT PRIMARY KEY, project_root TEXT, schema_version INTEGER NOT NULL DEFAULT 1, revision INTEGER NOT NULL, instance_id TEXT NOT NULL, operation_id TEXT, manual_paused TEXT NOT NULL DEFAULT '[]', bus_snapshot TEXT NOT NULL DEFAULT '{}')`,
471
+ `CREATE TABLE IF NOT EXISTS delivery_meta (project_id TEXT PRIMARY KEY, project_root TEXT, schema_version INTEGER NOT NULL DEFAULT 1, revision INTEGER NOT NULL, instance_id TEXT NOT NULL, operation_id TEXT, manual_paused TEXT NOT NULL DEFAULT '[]', bus_snapshot TEXT NOT NULL DEFAULT '{}', review_input_version INTEGER NOT NULL DEFAULT 0, review_input_complete INTEGER NOT NULL DEFAULT 0, review_input_coverage_complete INTEGER NOT NULL DEFAULT 0, review_input_count INTEGER NOT NULL DEFAULT 0, review_input_digest TEXT NOT NULL DEFAULT '', review_input_instance_id TEXT NOT NULL DEFAULT '')`,
339
472
  );
340
473
  for (const [column, definition] of [
341
474
  ["project_root", "TEXT"],
@@ -343,6 +476,12 @@ export class DeliveryJournal {
343
476
  ["operation_id", "TEXT"],
344
477
  ["retry_unknown_ids", "TEXT NOT NULL DEFAULT '{}'"],
345
478
  ["retry_history_cursor", "INTEGER NOT NULL DEFAULT 0"],
479
+ ["review_input_version", "INTEGER NOT NULL DEFAULT 0"],
480
+ ["review_input_complete", "INTEGER NOT NULL DEFAULT 0"],
481
+ ["review_input_coverage_complete", "INTEGER NOT NULL DEFAULT 0"],
482
+ ["review_input_count", "INTEGER NOT NULL DEFAULT 0"],
483
+ ["review_input_digest", "TEXT NOT NULL DEFAULT ''"],
484
+ ["review_input_instance_id", "TEXT NOT NULL DEFAULT ''"],
346
485
  ] as const) {
347
486
  const columns = this.db
348
487
  .query("PRAGMA table_info(delivery_meta)")
@@ -355,6 +494,43 @@ export class DeliveryJournal {
355
494
  this.db.run(
356
495
  `CREATE TABLE IF NOT EXISTS deliveries (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, peer TEXT NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, originals TEXT NOT NULL, out_json TEXT NOT NULL, reason TEXT, attempt INTEGER, previous_id TEXT)`,
357
496
  );
497
+ const deliveryColumns = this.db.query("PRAGMA table_info(deliveries)").all() as { name: string }[];
498
+ if (!deliveryColumns.some(column => column.name === "failure_cause")) this.db.run("ALTER TABLE deliveries ADD COLUMN failure_cause TEXT");
499
+ this.db.run("CREATE TABLE IF NOT EXISTS review_input_sessions (project_id TEXT NOT NULL, peer TEXT NOT NULL, session_hash TEXT NOT NULL, input_hash TEXT, PRIMARY KEY(project_id,peer,session_hash))");
500
+ this.db.run("CREATE TABLE IF NOT EXISTS review_input_incomplete_peers (project_id TEXT NOT NULL, peer TEXT NOT NULL, PRIMARY KEY(project_id,peer))");
501
+ this.db.run("CREATE TABLE IF NOT EXISTS review_input_incomplete_sessions (project_id TEXT NOT NULL, peer TEXT NOT NULL, session_hash TEXT NOT NULL, PRIMARY KEY(project_id,peer,session_hash))");
502
+ const legacyColumns = this.db.query("PRAGMA table_info(review_input_holds)").all() as { name: string }[];
503
+ const meta = this.readMeta();
504
+ const hasCurrentProvenance = !!this.db.query("SELECT 1 FROM review_input_sessions WHERE project_id=? UNION ALL SELECT 1 FROM review_input_incomplete_peers WHERE project_id=? UNION ALL SELECT 1 FROM review_input_incomplete_sessions WHERE project_id=? LIMIT 1")
505
+ .get(this.projectId, this.projectId, this.projectId);
506
+ if (legacyColumns.length && (!meta || legacyReviewInputMarkerAbsent(meta) && !hasCurrentProvenance)) {
507
+ this.db.transaction(() => {
508
+ const hasSession = legacyColumns.some(column => column.name === "session_hash"), hasHash = legacyColumns.some(column => column.name === "input_hash");
509
+ if (hasSession && hasHash) {
510
+ const legacy = this.db.query("SELECT project_id,peer,session_hash,input_hash FROM review_input_holds WHERE project_id=? ORDER BY peer LIMIT ?").all(this.projectId, MAX_REVIEW_INPUTS) as Record<string, unknown>[];
511
+ for (const row of legacy) {
512
+ const project = this.projectId;
513
+ try {
514
+ const hold = reviewInputHold({ peer: row.peer, sessionHash: row.session_hash, inputHash: row.input_hash }, "legacy review input");
515
+ this.db.query("INSERT OR IGNORE INTO review_input_sessions(project_id,peer,session_hash,input_hash) VALUES(?,?,?,?)").run(project, hold.peer, hold.sessionHash, hold.inputHash);
516
+ } catch {
517
+ if (typeof row.peer === "string" && row.peer.length > 0 && row.peer.length <= 256) {
518
+ if (typeof row.session_hash === "string" && /^[a-f0-9]{64}$/.test(row.session_hash)) this.db.query("INSERT OR IGNORE INTO review_input_incomplete_sessions(project_id,peer,session_hash) VALUES(?,?,?)").run(project, row.peer, row.session_hash);
519
+ else this.db.query("INSERT OR IGNORE INTO review_input_incomplete_peers(project_id,peer) VALUES(?,?)").run(project, row.peer);
520
+ }
521
+ }
522
+ }
523
+ }
524
+ const holds = this.db.query("SELECT peer,session_hash,input_hash FROM review_input_sessions WHERE project_id=?").all(this.projectId) as Record<string, unknown>[];
525
+ const normalized = holds.map((row, index) => reviewInputHold({ peer: row.peer, sessionHash: row.session_hash, inputHash: row.input_hash }, `migrated review input ${index}`));
526
+ const incompletePeers = this.reviewInputIncompletePeerRows(), incompleteSessions = this.reviewInputIncompleteSessionRows();
527
+ if (meta) {
528
+ const instance = String(meta.instance_id), count = normalized.length + incompletePeers.length + incompleteSessions.length, digest = reviewInputDigest(instance, REVIEW_INPUT_VERSION, true, false, count, normalized, incompletePeers, incompleteSessions);
529
+ this.db.query("UPDATE delivery_meta SET review_input_version=?,review_input_complete=1,review_input_coverage_complete=0,review_input_count=?,review_input_digest=?,review_input_instance_id=? WHERE project_id=?")
530
+ .run(REVIEW_INPUT_VERSION, count, digest, instance, this.projectId);
531
+ }
532
+ })();
533
+ }
358
534
  this.db.run("CREATE TABLE IF NOT EXISTS delivery_queue_meta_state (project_id TEXT PRIMARY KEY, revision INTEGER NOT NULL)");
359
535
  this.db.run("CREATE TABLE IF NOT EXISTS delivery_queue_meta (project_id TEXT NOT NULL, peer TEXT NOT NULL, queued INTEGER NOT NULL, oldest_at INTEGER, unknown_age INTEGER NOT NULL, PRIMARY KEY(project_id,peer))");
360
536
  this.db.run("CREATE TABLE IF NOT EXISTS task_offer_reads (project_id TEXT NOT NULL, peer TEXT NOT NULL, envelope_id TEXT NOT NULL, delivery_id TEXT NOT NULL, source TEXT NOT NULL CHECK(source IN ('adapter','reply','pull')), offer_revision INTEGER NOT NULL CHECK(offer_revision >= 1), PRIMARY KEY(project_id,peer,envelope_id))");
@@ -443,7 +619,7 @@ export class DeliveryJournal {
443
619
  if (e instanceof Error && e.message.includes("owned by a live")) throw e;
444
620
  }
445
621
  }
446
- private validateStored(meta: Record<string, unknown>): void {
622
+ private validateStored(meta: Record<string, unknown>, returnDeliveryIds = false): string[] | undefined {
447
623
  if (
448
624
  meta.project_root != null &&
449
625
  String(meta.project_root) !== this.projectRoot
@@ -480,13 +656,204 @@ export class DeliveryJournal {
480
656
  .get(this.projectId, MAX_ROWS)
481
657
  )
482
658
  fail("too many delivery rows");
659
+ return returnDeliveryIds ? rows.map(row => text(row.id, "stored delivery id", 256)) : undefined;
483
660
  }
484
- private recoverInterrupted(): void {
485
- this.db
486
- .query(
487
- "UPDATE deliveries SET state = 'needs_review', revision = revision + 1, updated_at = ?, reason = COALESCE(reason, 'daemon stopped during delivery') WHERE project_id = ? AND state IN ('dispatching', 'accepted')",
488
- )
489
- .run(Date.now(), this.projectId);
661
+ private reviewInputRows(): ReviewInputHold[] {
662
+ const rows = this.db.query("SELECT peer,session_hash,input_hash FROM review_input_sessions WHERE project_id=? ORDER BY peer,session_hash LIMIT ?")
663
+ .all(this.projectId, MAX_REVIEW_INPUTS + 1) as Record<string, unknown>[];
664
+ if (rows.length > MAX_REVIEW_INPUTS) fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
665
+ return rows.map((row, index) => reviewInputHold({ peer: row.peer, sessionHash: row.session_hash, inputHash: row.input_hash }, `stored review input ${index}`));
666
+ }
667
+ private reviewInputIncompletePeerRows(): string[] {
668
+ const rows = this.db.query("SELECT peer FROM review_input_incomplete_peers WHERE project_id=? ORDER BY peer LIMIT ?")
669
+ .all(this.projectId, MAX_REVIEW_INPUTS + 1) as { peer: unknown }[];
670
+ if (rows.length > MAX_REVIEW_INPUTS) fail(`too many incomplete review input peers (limit ${MAX_REVIEW_INPUTS})`);
671
+ return rows.map((row, index) => text(row.peer, `stored incomplete review input peer ${index}`, 256));
672
+ }
673
+ private reviewInputIncompleteSessionRows(): Array<{ peer: string; sessionHash: string }> {
674
+ const rows = this.db.query("SELECT peer,session_hash FROM review_input_incomplete_sessions WHERE project_id=? ORDER BY peer,session_hash LIMIT ?")
675
+ .all(this.projectId, MAX_REVIEW_INPUTS + 1) as Record<string, unknown>[];
676
+ if (rows.length > MAX_REVIEW_INPUTS) fail(`too many incomplete review input sessions (limit ${MAX_REVIEW_INPUTS})`);
677
+ return rows.map((row, index) => ({ peer: text(row.peer, `stored incomplete review input session peer ${index}`, 256), sessionHash: hashText(row.session_hash, `stored incomplete review input session hash ${index}`) }));
678
+ }
679
+ private readReviewInputProvenance(meta = this.readMeta()): ReviewInputProvenance {
680
+ if (!meta) throw new Error("delivery journal metadata missing");
681
+ const holds = this.reviewInputRows(), incompletePeers = this.reviewInputIncompletePeerRows(), incompleteSessions = this.reviewInputIncompleteSessionRows();
682
+ if (holds.length + incompletePeers.length + incompleteSessions.length > MAX_REVIEW_INPUTS) fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
683
+ const instanceId = text(String(meta.instance_id), "review input owner instance", 256), count = holds.length + incompletePeers.length + incompleteSessions.length;
684
+ const version = Number(meta.review_input_version ?? 0), completeRaw = Number(meta.review_input_complete ?? 0), coverageRaw = Number(meta.review_input_coverage_complete ?? 0);
685
+ if (version === 0 && completeRaw === 0 && coverageRaw === 0 && Number(meta.review_input_count ?? 0) === 0 && String(meta.review_input_digest ?? "") === "" && String(meta.review_input_instance_id ?? "") === "") {
686
+ if (count) fail("legacy review input marker is missing for stored provenance rows");
687
+ return { version: REVIEW_INPUT_VERSION, instanceId, complete: true, coverageComplete: false, count, digest: reviewInputDigest(instanceId, REVIEW_INPUT_VERSION, true, false, count, holds, incompletePeers, incompleteSessions), holds, incompletePeers, incompleteSessions };
688
+ }
689
+ if (version !== REVIEW_INPUT_VERSION || ![0, 1].includes(completeRaw)) fail("review input coverage metadata is invalid");
690
+ if (![0, 1].includes(coverageRaw)) fail("review input coverage metadata is invalid");
691
+ const storedInstance = text(String(meta.review_input_instance_id ?? ""), "review input coverage instance", 256);
692
+ const storedCount = integer(Number(meta.review_input_count), "review input coverage count", 0, MAX_REVIEW_INPUTS);
693
+ const storedDigest = hashText(meta.review_input_digest, "review input coverage digest");
694
+ if (storedCount !== count || storedDigest !== reviewInputDigest(storedInstance, version, completeRaw === 1, coverageRaw === 1, storedCount, holds, incompletePeers, incompleteSessions)) fail("review input coverage integrity mismatch");
695
+ const markerMatches = storedInstance === instanceId, complete = completeRaw === 1 && markerMatches, coverageComplete = coverageRaw === 1 && markerMatches;
696
+ const digest = reviewInputDigest(instanceId, REVIEW_INPUT_VERSION, complete, coverageComplete, count, holds, incompletePeers, incompleteSessions);
697
+ return { version: REVIEW_INPUT_VERSION, instanceId, complete, coverageComplete, count, digest, holds, incompletePeers, incompleteSessions };
698
+ }
699
+ /** Snapshot the pre-existing handed review rows once while legacy coverage is incomplete. */
700
+ // ponytail: reopen/import recaptures conservatively; if measured restart timing makes guarded rows prolong coverage, persist a bounded IDs-only candidate fence in recovery snapshots, never envelope bodies or a metadata-version change.
701
+ private captureLegacyReviewCandidates(force = false, importedFenceIds?: readonly string[]): void {
702
+ this.legacyReviewCandidateIds = new Set();
703
+ this.legacyReviewCandidatesCaptured = true;
704
+ this.legacyReviewCandidatesOverflow = false;
705
+ const revision = Number(this.readMeta()?.revision ?? -1);
706
+ this.legacyReviewCandidateCaptureRevision = revision;
707
+ let provenance: ReviewInputProvenance;
708
+ try {
709
+ provenance = this.readReviewInputProvenance();
710
+ } catch {
711
+ // Corrupt provenance is not repaired by retrying a coverage read.
712
+ this.legacyReviewCandidatesOverflow = true;
713
+ return;
714
+ }
715
+ if (!provenance.complete) return;
716
+ if (provenance.coverageComplete && !force) {
717
+ this.legacyReviewCandidateFenceIds = undefined;
718
+ return;
719
+ }
720
+ if (importedFenceIds !== undefined) {
721
+ this.legacyReviewCandidateFenceIds = [...importedFenceIds];
722
+ } else if (force || this.legacyReviewCandidateFenceIds === undefined) {
723
+ try {
724
+ const rows = this.db.query("SELECT id FROM deliveries WHERE project_id=? LIMIT ?").all(this.projectId, MAX_ROWS + 1) as { id: unknown }[];
725
+ if (rows.length > MAX_ROWS) { this.legacyReviewCandidatesOverflow = true; return; }
726
+ this.legacyReviewCandidateFenceIds = rows.map(row => text(row.id, "legacy delivery fence id", 256));
727
+ } catch {
728
+ // Without a frozen id fence, later reads cannot distinguish old rows from current writes.
729
+ this.legacyReviewCandidatesOverflow = true;
730
+ return;
731
+ }
732
+ }
733
+ const candidateIds = new Set<string>();
734
+ try {
735
+ const fenceIds = this.legacyReviewCandidateFenceIds ?? [];
736
+ for (let offset = 0; offset < fenceIds.length; offset += 256) {
737
+ const batch = fenceIds.slice(offset, offset + 256);
738
+ const rows = this.db.query(`WITH RECURSIVE roots(id) AS (
739
+ SELECT d.id FROM json_each(?) fence
740
+ JOIN deliveries d ON d.project_id=? AND d.id=fence.value
741
+ WHERE (d.state IN ('dispatching','accepted','needs_review')
742
+ OR (d.state='failed' AND EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id=d.project_id AND c.previous_id=d.id)))
743
+ AND EXISTS (SELECT 1 FROM json_each(d.originals) e
744
+ WHERE (json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review')
745
+ OR (json_extract(e.value,'$.from') NOT IN ('hub','user') AND json_extract(e.value,'$.kind') IN ('chat','status')
746
+ AND json_type(e.value,'$.refs.reviewResponse')='text'))
747
+ ), lineage(id) AS (
748
+ SELECT id FROM roots
749
+ UNION
750
+ SELECT child.id FROM deliveries child JOIN lineage parent ON child.previous_id=parent.id
751
+ WHERE child.project_id=? AND (child.state IN ('queued','dispatching','accepted','needs_review')
752
+ OR (child.state='failed' AND EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id=child.project_id AND c.previous_id=child.id)))
753
+ )
754
+ SELECT id FROM lineage LIMIT ?`).all(JSON.stringify(batch), this.projectId, this.projectId, MAX_REVIEW_INPUTS + 1) as { id: unknown }[];
755
+ for (const row of rows) candidateIds.add(text(row.id, "legacy review delivery id", 256));
756
+ if (candidateIds.size > MAX_REVIEW_INPUTS) { this.legacyReviewCandidatesOverflow = true; return; }
757
+ }
758
+ } catch {
759
+ // Keep the frozen epoch fence and retry only after the journal advances.
760
+ this.legacyReviewCandidatesOverflow = true;
761
+ return;
762
+ }
763
+ this.legacyReviewCandidateIds = candidateIds;
764
+ this.legacyReviewCandidateFenceIds = undefined;
765
+ }
766
+ /** Check only captured pre-upgrade rows; current guarded deliveries carry their own provenance. */
767
+ private hasUnsettledReviewInput(): boolean {
768
+ if (!this.legacyReviewCandidatesCaptured) this.captureLegacyReviewCandidates();
769
+ if (this.legacyReviewCandidatesOverflow && Number(this.readMeta()?.revision ?? -1) !== this.legacyReviewCandidateCaptureRevision) this.captureLegacyReviewCandidates();
770
+ if (this.legacyReviewCandidatesOverflow) return true;
771
+ const ids = [...this.legacyReviewCandidateIds];
772
+ for (let offset = 0; offset < ids.length; offset += 256) {
773
+ const batch = ids.slice(offset, offset + 256);
774
+ try {
775
+ const rows = this.db.query(`SELECT d.state FROM json_each(?) candidate
776
+ LEFT JOIN deliveries d ON d.project_id=? AND d.id=candidate.value`).all(JSON.stringify(batch), this.projectId) as { state: string | null }[];
777
+ if (rows.length !== batch.length || rows.some(row => !row.state || !["completed", "failed", "discarded"].includes(row.state))) return true;
778
+ } catch { return true; }
779
+ }
780
+ return false;
781
+ }
782
+ private markReviewInputCoverageIncomplete(prior: ReviewInputProvenance): void {
783
+ if (prior.coverageComplete) this.captureLegacyReviewCandidates(true);
784
+ this.transaction(() => {
785
+ this.bump();
786
+ this.writeReviewInputProvenance(prior.complete, false);
787
+ this.harden();
788
+ });
789
+ }
790
+ /** Complete legacy coverage after every handed review delivery is settled; then NULL means clean by absence. */
791
+ private refreshReviewInputCoverage(inTransaction = false): void {
792
+ let prior: ReviewInputProvenance;
793
+ try { prior = this.readReviewInputProvenance(); }
794
+ catch { return; } // Corrupt provenance stays held without blocking unrelated journal work.
795
+ if (!prior.complete) return;
796
+ const unsettled = !prior.coverageComplete && this.hasUnsettledReviewInput();
797
+ const cleanRows = prior.coverageComplete ? Number((this.db.query("SELECT COUNT(*) AS n FROM review_input_sessions WHERE project_id=? AND input_hash IS NULL").get(this.projectId) as { n: number }).n) : 0;
798
+ if (unsettled) return;
799
+ if (!prior.coverageComplete || cleanRows > 0) {
800
+ const update = () => {
801
+ this.bump();
802
+ if (!prior.coverageComplete) this.db.query("UPDATE delivery_meta SET review_input_coverage_complete=1 WHERE project_id=?").run(this.projectId);
803
+ if (!prior.coverageComplete || cleanRows > 0) this.db.query("DELETE FROM review_input_sessions WHERE project_id=? AND input_hash IS NULL").run(this.projectId);
804
+ this.writeReviewInputProvenance(prior.complete, true);
805
+ this.harden();
806
+ };
807
+ if (inTransaction) update(); else this.transaction(update);
808
+ }
809
+ }
810
+ private writeReviewInputProvenance(complete: boolean, coverageComplete: boolean): void {
811
+ const holds = this.reviewInputRows(), incompletePeers = this.reviewInputIncompletePeerRows(), incompleteSessions = this.reviewInputIncompleteSessionRows();
812
+ if (holds.length + incompletePeers.length + incompleteSessions.length > MAX_REVIEW_INPUTS) fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
813
+ const count = holds.length + incompletePeers.length + incompleteSessions.length;
814
+ this.db.query("UPDATE delivery_meta SET review_input_version=?,review_input_complete=?,review_input_coverage_complete=?,review_input_count=?,review_input_digest=?,review_input_instance_id=? WHERE project_id=?")
815
+ .run(REVIEW_INPUT_VERSION, complete ? 1 : 0, coverageComplete ? 1 : 0, count, reviewInputDigest(this.instanceId, REVIEW_INPUT_VERSION, complete, coverageComplete, count, holds, incompletePeers, incompleteSessions), this.instanceId, this.projectId);
816
+ }
817
+ /** Persist the scoped unknown marker before changing this session's held/clear receipt. */
818
+ private writePendingReviewInputSession(peer: string, sessionHash: string, complete: boolean, coverageComplete: boolean, clearIncompletePeer = false): void {
819
+ this.transaction(() => {
820
+ this.bump();
821
+ this.db.query("INSERT OR IGNORE INTO review_input_incomplete_sessions(project_id,peer,session_hash) VALUES(?,?,?)")
822
+ .run(this.projectId, peer, sessionHash);
823
+ if (clearIncompletePeer) this.db.query("DELETE FROM review_input_incomplete_peers WHERE project_id=? AND peer=?").run(this.projectId, peer);
824
+ this.writeReviewInputProvenance(complete, coverageComplete);
825
+ this.harden();
826
+ });
827
+ }
828
+ private recoverInterrupted(meta: Record<string, unknown>): void {
829
+ const resumable = new Set<string>();
830
+ try {
831
+ const raw = JSON.parse(String(meta.bus_snapshot || "{}"));
832
+ const bus = raw && typeof raw === "object" && Object.keys(raw).length > 0 ? validateBus(raw) : undefined;
833
+ if (bus) {
834
+ const now = Date.now();
835
+ const active = this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND state IN ('dispatching', 'accepted')").all(this.projectId) as Record<string, unknown>[];
836
+ for (const stored of active) {
837
+ const row = parseDelivery(stored), peer = row.peer;
838
+ if (!parseRemotePeerId(peer) || row.originals.length !== 1) continue;
839
+ const original = row.originals[0]!;
840
+ const room = bus.remoteQueueRooms?.[peer], member = bus.remoteQueueMembers?.[peer];
841
+ const expiry = bus.remoteQueueExpires?.[peer]?.[original.id];
842
+ if (isInvocationKind(original) || original.to?.includes(peer) !== true ||
843
+ typeof room !== "string" ||
844
+ typeof member !== "string" || !/^[0-9a-f]{64}$/.test(member) ||
845
+ !Number.isSafeInteger(expiry) || expiry! <= now) continue;
846
+ resumable.add(row.id);
847
+ }
848
+ }
849
+ } catch { /* Unreadable or unknown binding metadata keeps the conservative interruption rule. */ }
850
+
851
+ const now = Date.now();
852
+ const update = this.db.query(
853
+ "UPDATE deliveries SET state = 'needs_review', revision = revision + 1, updated_at = ?, reason = COALESCE(reason, 'daemon stopped during delivery'), failure_cause = COALESCE(failure_cause, 'turn aborted') WHERE project_id = ? AND id = ? AND state IN ('dispatching', 'accepted')",
854
+ );
855
+ const active = this.db.query("SELECT id FROM deliveries WHERE project_id = ? AND state IN ('dispatching', 'accepted')").all(this.projectId) as { id: string }[];
856
+ for (const row of active) if (!resumable.has(row.id)) update.run(now, this.projectId, row.id);
490
857
  }
491
858
  private ensureOpen(): void {
492
859
  if (this.closed) throw new Error("delivery journal is closed");
@@ -571,13 +938,204 @@ export class DeliveryJournal {
571
938
  transaction<T>(fn: () => T): T {
572
939
  this.ensureOpen();
573
940
  const invalidated = this.queueMetadataInvalidated;
941
+ const legacyReviewCandidateIds = this.legacyReviewCandidateIds;
942
+ const legacyReviewCandidatesCaptured = this.legacyReviewCandidatesCaptured;
943
+ const legacyReviewCandidatesOverflow = this.legacyReviewCandidatesOverflow;
944
+ const legacyReviewCandidateFenceIds = this.legacyReviewCandidateFenceIds;
945
+ const legacyReviewCandidateCaptureRevision = this.legacyReviewCandidateCaptureRevision;
574
946
  try { return this.db.transaction(fn)(); }
575
- catch (error) { this.queueMetadataInvalidated = invalidated; throw error; }
947
+ catch (error) {
948
+ this.queueMetadataInvalidated = invalidated;
949
+ this.legacyReviewCandidateIds = legacyReviewCandidateIds;
950
+ this.legacyReviewCandidatesCaptured = legacyReviewCandidatesCaptured;
951
+ this.legacyReviewCandidatesOverflow = legacyReviewCandidatesOverflow;
952
+ this.legacyReviewCandidateFenceIds = legacyReviewCandidateFenceIds;
953
+ this.legacyReviewCandidateCaptureRevision = legacyReviewCandidateCaptureRevision;
954
+ throw error;
955
+ }
576
956
  }
577
- snapshot(): JournalSnapshot {
957
+ holdReviewInput(value: unknown): void {
578
958
  this.ensureOpen();
579
- const meta = this.readMeta();
580
- if (!meta) throw new Error("delivery journal metadata missing");
959
+ const hold = reviewInputHold(value, "review input hold");
960
+ if (hold.inputHash === null) fail("review input hold requires a positive input hash");
961
+ const prior = this.readReviewInputProvenance();
962
+ if (!prior.complete) fail("review input provenance is incomplete");
963
+ const existing = prior.holds.find(row => row.peer === hold.peer && row.sessionHash === hold.sessionHash);
964
+ const pending = prior.incompleteSessions.some(row => row.peer === hold.peer && row.sessionHash === hold.sessionHash);
965
+ if (existing?.inputHash === hold.inputHash) return;
966
+ if (!existing && !pending && prior.count >= MAX_REVIEW_INPUTS) {
967
+ this.markReviewInputCoverageIncomplete(prior);
968
+ fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
969
+ }
970
+ try {
971
+ this.writePendingReviewInputSession(hold.peer, hold.sessionHash, prior.complete, prior.coverageComplete);
972
+ } catch (error) {
973
+ try { this.markReviewInputIncomplete(hold.peer, hold.sessionHash); } catch { /* a failed marker write still prevents the host handout */ }
974
+ throw error;
975
+ }
976
+ this.transaction(() => {
977
+ this.bump();
978
+ this.db.query("INSERT INTO review_input_sessions(project_id,peer,session_hash,input_hash) VALUES(?,?,?,?) ON CONFLICT(project_id,peer,session_hash) DO UPDATE SET input_hash=excluded.input_hash")
979
+ .run(this.projectId, hold.peer, hold.sessionHash, hold.inputHash);
980
+ this.db.query("DELETE FROM review_input_incomplete_sessions WHERE project_id=? AND peer=? AND session_hash=?")
981
+ .run(this.projectId, hold.peer, hold.sessionHash);
982
+ this.writeReviewInputProvenance(prior.complete, prior.coverageComplete);
983
+ this.harden();
984
+ });
985
+ }
986
+ reviewInputState(peerValue: string, sessionHashValue?: string): ReviewInputState {
987
+ try {
988
+ const peer = text(peerValue, "review input peer", 256);
989
+ const sessionHash = sessionHashValue === undefined ? undefined : hashText(sessionHashValue, "review input session hash");
990
+ const provenance = this.readReviewInputProvenance();
991
+ if (!provenance.complete || provenance.incompletePeers.includes(peer)) return { state: "unknown" };
992
+ const peerRows = provenance.holds.filter(row => row.peer === peer);
993
+ const incompleteSessions = provenance.incompleteSessions.filter(row => row.peer === peer);
994
+ if (sessionHash === undefined) return peerRows.length || incompleteSessions.length || !provenance.coverageComplete ? { state: "unknown" } : { state: "clean" };
995
+ const row = peerRows.find(value => value.sessionHash === sessionHash);
996
+ if (row?.inputHash !== null && row?.inputHash !== undefined) return { state: "held", inputHash: row.inputHash };
997
+ if (incompleteSessions.some(value => value.sessionHash === sessionHash)) return { state: "unknown" };
998
+ if (row?.inputHash === null) return { state: "clean" };
999
+ if (!provenance.coverageComplete) return { state: "unknown" };
1000
+ if (!row) return { state: "clean" };
1001
+ return { state: "unknown" };
1002
+ } catch { return { state: "unknown" }; }
1003
+ }
1004
+ /** Cause projection for host admission; preserves reviewInputState's stable public shape. */
1005
+ reviewInputEvidence(peerValue: string, sessionHashValue?: string): ReviewInputEvidence {
1006
+ this.ensureOpen();
1007
+ try {
1008
+ this.refreshReviewInputCoverage();
1009
+ const peer = text(peerValue, "review input peer", 256), sessionHash = sessionHashValue === undefined ? undefined : hashText(sessionHashValue, "review input session hash");
1010
+ const provenance = this.readReviewInputProvenance();
1011
+ if (!provenance.complete) return { provenance: "corrupt", coverageComplete: false, incompletePeer: false, incompleteSession: false, sessionState: "unknown", retainedSessionHold: false, retainedPeerSessionHold: false };
1012
+ const sessionState = this.reviewInputState(peer, sessionHash).state;
1013
+ return {
1014
+ provenance: "available",
1015
+ coverageComplete: provenance.coverageComplete,
1016
+ incompletePeer: provenance.incompletePeers.includes(peer),
1017
+ incompleteSession: sessionHash !== undefined && provenance.incompleteSessions.some(row => row.peer === peer && row.sessionHash === sessionHash),
1018
+ sessionState,
1019
+ retainedSessionHold: sessionHash !== undefined && provenance.holds.some(row => row.peer === peer && row.sessionHash === sessionHash && row.inputHash !== null),
1020
+ retainedPeerSessionHold: provenance.holds.some(row => row.peer === peer && row.inputHash !== null) || provenance.incompleteSessions.some(row => row.peer === peer),
1021
+ };
1022
+ } catch {
1023
+ return { provenance: "corrupt", coverageComplete: false, incompletePeer: false, incompleteSession: false, sessionState: "unknown", retainedSessionHold: false, retainedPeerSessionHold: false };
1024
+ }
1025
+ }
1026
+ /** Public-safe journal health; positive held receipts are normal, expected state. */
1027
+ reviewInputHealth(): ReviewInputHealth {
1028
+ this.ensureOpen();
1029
+ try {
1030
+ this.refreshReviewInputCoverage();
1031
+ const provenance = this.readReviewInputProvenance();
1032
+ if (!provenance.complete) return { state: "corrupt", cause: "provenance_incomplete", nextStep: "Keep affected peers held and inspect or repair only review-input provenance, preserving delivery and task state." };
1033
+ if (provenance.incompletePeers.length || provenance.incompleteSessions.length) return { state: "incomplete", cause: "incomplete_markers", nextStep: "Verify the peer's next ordinary input or confirmed session change to clear its incomplete marker." };
1034
+ if (!provenance.coverageComplete) return { state: "incomplete", cause: "legacy_coverage", nextStep: "Allow existing review-marked deliveries to settle; coverage clears when the journal confirms none remain." };
1035
+ return { state: "healthy", cause: null, nextStep: "No action required." };
1036
+ } catch {
1037
+ return { state: "corrupt", cause: "provenance_corrupt", nextStep: "Keep affected peers held and inspect or repair only review-input provenance, preserving delivery and task state." };
1038
+ }
1039
+ }
1040
+ /** Compatibility reader: only positive held hashes are returned; clean and unknown stay absent. */
1041
+ readReviewInput(peerValue: string, sessionHashValue: string): string | undefined {
1042
+ const state = this.reviewInputState(peerValue, sessionHashValue);
1043
+ return state.state === "held" ? state.inputHash : undefined;
1044
+ }
1045
+ clearReviewInput(peerValue: string, sessionHashValue: string): void {
1046
+ this.ensureOpen();
1047
+ const peer = text(peerValue, "review input peer", 256), sessionHash = hashText(sessionHashValue, "review input session hash");
1048
+ const prior = this.readReviewInputProvenance(), existing = prior.holds.find(row => row.peer === peer && row.sessionHash === sessionHash);
1049
+ if (!prior.complete) fail("review input provenance is incomplete");
1050
+ const pending = prior.incompleteSessions.some(row => row.peer === peer && row.sessionHash === sessionHash);
1051
+ const incompletePeer = prior.incompletePeers.includes(peer);
1052
+ const legacyConfirmation = !prior.coverageComplete && !existing && !pending;
1053
+ if ((!existing || existing.inputHash === null) && !pending && !legacyConfirmation && !incompletePeer) return;
1054
+ if (!existing && !pending && !incompletePeer && prior.count >= MAX_REVIEW_INPUTS) {
1055
+ this.markReviewInputCoverageIncomplete(prior);
1056
+ fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
1057
+ }
1058
+ this.writePendingReviewInputSession(peer, sessionHash, prior.complete, prior.coverageComplete, true);
1059
+ this.transaction(() => {
1060
+ this.bump();
1061
+ this.db.query("INSERT INTO review_input_sessions(project_id,peer,session_hash,input_hash) VALUES(?,?,?,NULL) ON CONFLICT(project_id,peer,session_hash) DO UPDATE SET input_hash=NULL")
1062
+ .run(this.projectId, peer, sessionHash);
1063
+ this.db.query("DELETE FROM review_input_incomplete_sessions WHERE project_id=? AND peer=? AND session_hash=?")
1064
+ .run(this.projectId, peer, sessionHash);
1065
+ this.db.query("DELETE FROM review_input_incomplete_peers WHERE project_id=? AND peer=?")
1066
+ .run(this.projectId, peer);
1067
+ this.writeReviewInputProvenance(prior.complete, prior.coverageComplete);
1068
+ this.harden();
1069
+ });
1070
+ this.refreshReviewInputCoverage();
1071
+ }
1072
+ /** A verified ordinary input or confirmed SID change clears only this peer-level incomplete marker. */
1073
+ clearReviewInputIncomplete(peerValue: string): void {
1074
+ this.ensureOpen();
1075
+ const peer = text(peerValue, "incomplete review input peer", 256), prior = this.readReviewInputProvenance();
1076
+ if (!prior.complete) fail("review input provenance is incomplete");
1077
+ if (!prior.incompletePeers.includes(peer)) return;
1078
+ this.transaction(() => {
1079
+ const current = this.readReviewInputProvenance();
1080
+ if (!current.incompletePeers.includes(peer)) return;
1081
+ this.bump();
1082
+ this.db.query("DELETE FROM review_input_incomplete_peers WHERE project_id=? AND peer=?").run(this.projectId, peer);
1083
+ this.writeReviewInputProvenance(current.complete, current.coverageComplete);
1084
+ this.harden();
1085
+ });
1086
+ }
1087
+ /** Forget one closed ephemeral observer receipt only if its exact positive/clear hash still matches. */
1088
+ forgetClosedObserverInput(peerValue: string, sessionHashValue: string, expectedInputHash: string | null): boolean {
1089
+ this.ensureOpen();
1090
+ try {
1091
+ const peer = text(peerValue, "review input peer", 256), sessionHash = hashText(sessionHashValue, "review input session hash");
1092
+ if (expectedInputHash !== null) hashText(expectedInputHash, "expected review input hash");
1093
+ const prior = this.readReviewInputProvenance();
1094
+ if (!prior.complete || prior.incompletePeers.includes(peer) || prior.incompleteSessions.some(row => row.peer === peer && row.sessionHash === sessionHash)) return false;
1095
+ if (!prior.holds.some(row => row.peer === peer && row.sessionHash === sessionHash && row.inputHash === expectedInputHash)) return false;
1096
+ return this.transaction(() => {
1097
+ const current = this.readReviewInputProvenance();
1098
+ if (!current.complete || current.incompletePeers.includes(peer) || current.incompleteSessions.some(row => row.peer === peer && row.sessionHash === sessionHash)
1099
+ || !current.holds.some(row => row.peer === peer && row.sessionHash === sessionHash && row.inputHash === expectedInputHash)) return false;
1100
+ this.bump();
1101
+ this.db.query("DELETE FROM review_input_sessions WHERE project_id=? AND peer=? AND session_hash=? AND input_hash IS ?")
1102
+ .run(this.projectId, peer, sessionHash, expectedInputHash);
1103
+ if (Number((this.db.query("SELECT changes() AS n").get() as { n: number }).n) !== 1) throw new Error("review input receipt changed during closed-session cleanup");
1104
+ this.writeReviewInputProvenance(current.complete, current.coverageComplete);
1105
+ this.harden();
1106
+ return true;
1107
+ });
1108
+ } catch { return false; }
1109
+ }
1110
+ markReviewInputIncomplete(peerValue: string, sessionHashValue?: string): void {
1111
+ this.ensureOpen();
1112
+ const peer = text(peerValue, "incomplete review input peer", 256);
1113
+ const prior = this.readReviewInputProvenance();
1114
+ if (!prior.complete) fail("review input provenance is incomplete");
1115
+ const sessionHash = sessionHashValue === undefined ? undefined : hashText(sessionHashValue, "incomplete review input session hash");
1116
+ if (sessionHash !== undefined && prior.incompleteSessions.some(row => row.peer === peer && row.sessionHash === sessionHash)) return;
1117
+ if (sessionHash === undefined && prior.incompletePeers.includes(peer)) return;
1118
+ if (prior.count >= MAX_REVIEW_INPUTS) {
1119
+ this.markReviewInputCoverageIncomplete(prior);
1120
+ fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
1121
+ }
1122
+ this.transaction(() => {
1123
+ this.bump();
1124
+ if (sessionHash === undefined) this.db.query("INSERT OR IGNORE INTO review_input_incomplete_peers(project_id,peer) VALUES(?,?)").run(this.projectId, peer);
1125
+ else this.db.query("INSERT OR IGNORE INTO review_input_incomplete_sessions(project_id,peer,session_hash) VALUES(?,?,?)").run(this.projectId, peer, sessionHash);
1126
+ this.writeReviewInputProvenance(prior.complete, prior.coverageComplete);
1127
+ this.harden();
1128
+ });
1129
+ }
1130
+ hasReviewInputState(): boolean {
1131
+ this.ensureOpen();
1132
+ try {
1133
+ this.refreshReviewInputCoverage();
1134
+ const provenance = this.readReviewInputProvenance();
1135
+ return !provenance.complete || !provenance.coverageComplete || provenance.holds.some(row => row.inputHash !== null) || provenance.incompletePeers.length > 0 || provenance.incompleteSessions.length > 0;
1136
+ } catch { return true; }
1137
+ }
1138
+ private snapshotTransportBase(meta: Record<string, unknown>): JournalTransportSnapshot {
581
1139
  this.validateStored(meta);
582
1140
  const rows = this.db
583
1141
  .query(
@@ -596,7 +1154,7 @@ export class DeliveryJournal {
596
1154
  withdrawn: [],
597
1155
  }
598
1156
  : storedBus;
599
- const result = {
1157
+ const result: JournalTransportSnapshot = {
600
1158
  schemaVersion: SCHEMA_VERSION,
601
1159
  revision: Number(meta.revision),
602
1160
  instanceId: String(meta.instance_id),
@@ -607,6 +1165,23 @@ export class DeliveryJournal {
607
1165
  validateJournalSnapshot(result);
608
1166
  return result;
609
1167
  }
1168
+ /** Transport restoration may proceed from the validated bus/delivery projection while review provenance remains unknown. */
1169
+ transportSnapshot(): JournalTransportSnapshot {
1170
+ this.ensureOpen();
1171
+ const meta = this.readMeta();
1172
+ if (!meta) throw new Error("delivery journal metadata missing");
1173
+ return this.snapshotTransportBase(meta);
1174
+ }
1175
+ /** Strict recovery snapshot: malformed or incomplete review provenance refuses recovery. */
1176
+ snapshot(): JournalSnapshot {
1177
+ this.ensureOpen();
1178
+ const meta = this.readMeta();
1179
+ if (!meta) throw new Error("delivery journal metadata missing");
1180
+ const base = this.snapshotTransportBase(meta);
1181
+ const result: JournalSnapshot = { ...base, reviewInputProvenance: this.readReviewInputProvenance(meta) };
1182
+ validateJournalSnapshot(result);
1183
+ return result;
1184
+ }
610
1185
  persistBus(bus: unknown, manualPaused: string[], trackQueuePressure = false): number {
611
1186
  this.ensureOpen();
612
1187
  let checkedBus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
@@ -632,6 +1207,7 @@ export class DeliveryJournal {
632
1207
  this.projectId,
633
1208
  );
634
1209
  this.writeQueueMetadata(checkedBus, revision, trackQueuePressure);
1210
+ this.refreshReviewInputCoverage(true);
635
1211
  this.harden();
636
1212
  return revision;
637
1213
  });
@@ -672,7 +1248,7 @@ export class DeliveryJournal {
672
1248
  for (const d of deliveries)
673
1249
  this.db
674
1250
  .query(
675
- "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1251
+ "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id, failure_cause) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
676
1252
  )
677
1253
  .run(
678
1254
  d.id,
@@ -687,7 +1263,9 @@ export class DeliveryJournal {
687
1263
  d.reason ?? null,
688
1264
  d.attempt ?? null,
689
1265
  d.previousId ?? null,
1266
+ checkedFailureCause(d.failureCause) ?? null,
690
1267
  );
1268
+ this.refreshReviewInputCoverage(true);
691
1269
  this.harden();
692
1270
  return revision;
693
1271
  });
@@ -737,7 +1315,7 @@ export class DeliveryJournal {
737
1315
  this.writeQueueMetadata(bus, revision, trackQueuePressure);
738
1316
  this.db
739
1317
  .query(
740
- "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1318
+ "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id, failure_cause) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
741
1319
  )
742
1320
  .run(
743
1321
  delivery.id,
@@ -752,6 +1330,7 @@ export class DeliveryJournal {
752
1330
  delivery.reason ?? null,
753
1331
  delivery.attempt ?? null,
754
1332
  delivery.previousId ?? null,
1333
+ checkedFailureCause(delivery.failureCause) ?? null,
755
1334
  );
756
1335
  this.harden();
757
1336
  return { ...delivery, revision, updatedAt: now };
@@ -774,7 +1353,7 @@ export class DeliveryJournal {
774
1353
  const updatedAt = input.updatedAt ?? Date.now();
775
1354
  this.db
776
1355
  .query(
777
- "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1356
+ "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id, failure_cause) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
778
1357
  )
779
1358
  .run(
780
1359
  input.id,
@@ -789,6 +1368,7 @@ export class DeliveryJournal {
789
1368
  input.reason ?? null,
790
1369
  input.attempt ?? null,
791
1370
  input.previousId ?? null,
1371
+ checkedFailureCause(input.failureCause) ?? null,
792
1372
  );
793
1373
  this.pruneTerminal(input.state, input.id);
794
1374
  return { ...input, revision, updatedAt };
@@ -804,7 +1384,7 @@ export class DeliveryJournal {
804
1384
  this.ensureOpen(); text(deliveryId, "delivery id", 256); text(peer, "delivery peer", 256);
805
1385
  if (peer === HUB || peer === USER || !["adapter", "reply", "pull"].includes(source)) throw new Error("invalid delivered completion source");
806
1386
  if (reason !== undefined) text(reason, "delivery completion reason", 1_000_000);
807
- return this.db.transaction((): JournalDelivery => {
1387
+ return this.transaction((): JournalDelivery => {
808
1388
  const row = this.get(deliveryId);
809
1389
  if (!row || row.peer !== peer || !["dispatching", "accepted"].includes(row.state)) throw new Error("delivery is not active for this peer");
810
1390
  const completed = this.withPruningDeferred(() => {
@@ -816,7 +1396,37 @@ export class DeliveryJournal {
816
1396
  // Proof is now durable in this transaction, and the return receipt is captured.
817
1397
  this.pruneTerminal("completed", "");
818
1398
  return completed;
819
- })();
1399
+ });
1400
+ }
1401
+ /** A signed room receipt settles its matching remote row without native read evidence. */
1402
+ completeRemoteReceipt(deliveryId: string, peer: string, expectedRevision: number, reason: string): JournalDelivery {
1403
+ this.ensureOpen(); text(deliveryId, "delivery id", 256); text(peer, "delivery peer", 256);
1404
+ integer(expectedRevision, "observed revision"); text(reason, "delivery completion reason", 1_000_000);
1405
+ if (!parseRemotePeerId(peer)) throw new Error("invalid remote receipt peer");
1406
+ return this.transaction(() => {
1407
+ const row = this.get(deliveryId);
1408
+ if (!row || row.peer !== peer || !["needs_review", "dispatching", "accepted"].includes(row.state) || row.originals.length !== 1 || row.originals[0]?.to?.includes(peer) !== true) throw new Error("remote delivery is not unresolved for this peer");
1409
+ if (row.revision !== expectedRevision) throw new Error(`stale delivery revision for ${deliveryId}`);
1410
+ if (this.db.query("SELECT 1 FROM resolution_history WHERE project_id=? AND delivery_id=? LIMIT 1").get(this.projectId, deliveryId)) throw new Error("remote delivery already has a person disposition");
1411
+ const completed = this.transition(deliveryId, "completed", reason, expectedRevision);
1412
+ this.pruneTerminal("completed", "");
1413
+ return completed;
1414
+ });
1415
+ }
1416
+ /** A signed room refusal is terminal transport evidence, not a person or native retry. */
1417
+ failRemoteReceipt(deliveryId: string, peer: string, expectedRevision: number, reason: string): JournalDelivery {
1418
+ this.ensureOpen(); text(deliveryId, "delivery id", 256); text(peer, "delivery peer", 256);
1419
+ integer(expectedRevision, "observed revision"); text(reason, "delivery refusal reason", 1_000_000);
1420
+ if (!parseRemotePeerId(peer)) throw new Error("invalid remote receipt peer");
1421
+ return this.transaction(() => {
1422
+ const row = this.get(deliveryId);
1423
+ if (!row || row.peer !== peer || !["needs_review", "dispatching", "accepted"].includes(row.state) || row.originals.length !== 1 || row.originals[0]?.to?.includes(peer) !== true) throw new Error("remote delivery is not unresolved for this peer");
1424
+ if (row.revision !== expectedRevision) throw new Error(`stale delivery revision for ${deliveryId}`);
1425
+ if (this.db.query("SELECT 1 FROM resolution_history WHERE project_id=? AND delivery_id=? LIMIT 1").get(this.projectId, deliveryId)) throw new Error("remote delivery already has a person disposition");
1426
+ const failed = this.transition(deliveryId, "failed", reason, expectedRevision, "adapter refused");
1427
+ this.pruneTerminal("failed", "");
1428
+ return failed;
1429
+ });
820
1430
  }
821
1431
  /** Exact immutable offer id, independent of queue snapshots or generic completed rows. */
822
1432
  offerRead(peer: string, envelopeId: string): boolean {
@@ -830,23 +1440,28 @@ export class DeliveryJournal {
830
1440
  state: JournalDeliveryState,
831
1441
  reason?: string,
832
1442
  expectedRevision?: number,
1443
+ failureCause?: DeliveryFailureCause,
833
1444
  ): JournalDelivery {
834
1445
  this.ensureOpen();
835
1446
  if (!STATES.has(state)) throw new Error("invalid delivery state");
836
- return this.db.transaction(() => {
1447
+ checkedFailureCause(failureCause);
1448
+ return this.transaction(() => {
837
1449
  const old = this.get(id);
838
1450
  if (!old) throw new Error(`unknown delivery ${id}`);
839
1451
  if (expectedRevision !== undefined && old.revision !== expectedRevision)
840
1452
  throw new Error(`stale delivery revision for ${id}`);
1453
+ // A repeated accepted receipt is not fresh evidence and must not renew the settle deadline.
1454
+ if (old.state === state && state === "accepted") return old;
841
1455
  const revision = this.bump();
842
1456
  this.db
843
1457
  .query(
844
- "UPDATE deliveries SET state = ?, revision = ?, updated_at = ?, reason = ? WHERE id = ? AND project_id = ?",
1458
+ "UPDATE deliveries SET state = ?, revision = ?, updated_at = ?, reason = ?, failure_cause = ? WHERE id = ? AND project_id = ?",
845
1459
  )
846
- .run(state, revision, Date.now(), reason ?? null, id, this.projectId);
1460
+ .run(state, revision, Date.now(), reason ?? null, failureCause ?? old.failureCause ?? null, id, this.projectId);
1461
+ this.refreshReviewInputCoverage(true);
847
1462
  this.pruneTerminal(state, id);
848
1463
  return this.get(id)!;
849
- })();
1464
+ });
850
1465
  }
851
1466
  get(id: string): JournalDelivery | undefined {
852
1467
  this.ensureOpen();
@@ -855,12 +1470,53 @@ export class DeliveryJournal {
855
1470
  .get(id, this.projectId) as Record<string, unknown> | null;
856
1471
  return row ? parseDelivery(row) : undefined;
857
1472
  }
1473
+ /** Explicit package recovery reads durable publication evidence, not the bounded in-memory reply cache. */
1474
+ reviewEnvelopeState(id: string, expected?: Envelope): JournalDeliveryState | undefined {
1475
+ this.ensureOpen();
1476
+ const published = this.db.query(`SELECT d.state AS state, e.value AS envelope FROM deliveries d, json_each(d.originals) e
1477
+ WHERE d.project_id=? AND json_extract(e.value,'$.id')=? AND json_extract(e.value,'$.from')='hub'
1478
+ AND json_extract(e.value,'$.kind')='review' AND json_extract(e.value,'$.refs.reviewPackage') GLOB 'review-*'
1479
+ ORDER BY d.updated_at DESC, d.revision DESC LIMIT 1`).get(this.projectId, id) as { state: JournalDeliveryState; envelope: string } | null;
1480
+ // A queued/deferred publication can precede its first physical delivery row.
1481
+ const saved = published ?? this.db.query(`SELECT e.value AS envelope FROM delivery_meta m, json_tree(m.bus_snapshot) e
1482
+ WHERE m.project_id=? AND e.type='object' AND json_extract(e.value,'$.id')=?
1483
+ AND json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review'
1484
+ AND json_extract(e.value,'$.refs.reviewPackage') GLOB 'review-*' LIMIT 1`).get(this.projectId, id) as { envelope: string } | null;
1485
+ if (saved && expected && !isDeepStrictEqual(validateEnvelope(JSON.parse(saved.envelope), "saved review package"), validateEnvelope(expected, "review package")))
1486
+ throw new Error("saved review envelope differs from its publication evidence; inspect the package and queue before retrying");
1487
+ return published?.state ?? (saved ? "queued" : undefined);
1488
+ }
1489
+ reviewEnvelopePublished(id: string, expected?: Envelope): boolean {
1490
+ return this.reviewEnvelopeState(id, expected) !== undefined;
1491
+ }
1492
+ /** Pending review recipients remain quote-bearing even when the task's current roles have moved. */
1493
+ hasUnsettledReviewScope(peer: string, task?: number): boolean {
1494
+ this.ensureOpen();
1495
+ const taskId = task === undefined ? null : String(task);
1496
+ return !!this.db.query(`SELECT 1 FROM deliveries d, json_each(d.originals) e
1497
+ WHERE d.project_id=?1 AND d.peer=?2 AND d.state IN ('queued','dispatching','accepted','needs_review')
1498
+ AND json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review'
1499
+ AND json_type(e.value,'$.refs.reviewPackage')='text' AND (?3 IS NULL OR json_extract(e.value,'$.refs.task')=?3)
1500
+ UNION ALL SELECT 1 FROM delivery_meta m, json_each(m.bus_snapshot,'$.queues') q, json_each(q.value) e
1501
+ WHERE m.project_id=?1 AND q.key=?2 AND json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review'
1502
+ AND json_type(e.value,'$.refs.reviewPackage')='text' AND (?3 IS NULL OR json_extract(e.value,'$.refs.task')=?3)
1503
+ UNION ALL SELECT 1 FROM delivery_meta m, json_each(m.bus_snapshot,'$.deferredHub') e, json_each(e.value,'$.to') recipient
1504
+ WHERE m.project_id=?1 AND recipient.value=?2 AND json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review'
1505
+ AND json_type(e.value,'$.refs.reviewPackage')='text' AND (?3 IS NULL OR json_extract(e.value,'$.refs.task')=?3)
1506
+ LIMIT 1`).get(this.projectId, peer, taskId);
1507
+ }
858
1508
  /** Hot roster reads never materialize completed envelope history. */
859
1509
  pendingPeers(): string[] {
860
1510
  this.ensureOpen();
861
1511
  const rows = this.db.query("SELECT DISTINCT peer FROM deliveries WHERE project_id = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY peer").all(this.projectId) as { peer: unknown }[];
862
1512
  return rows.map(row => text(row.peer, "delivery peer", 256));
863
1513
  }
1514
+ /** Status counts unresolved failures without reading envelope payloads or terminal history. */
1515
+ unresolvedFailedCounts(): Record<string, number> {
1516
+ this.ensureOpen();
1517
+ const rows = this.db.query("SELECT d.peer,COUNT(*) AS n FROM deliveries d WHERE d.project_id=? AND d.state='failed' AND NOT EXISTS (SELECT 1 FROM resolution_history r WHERE r.project_id=d.project_id AND r.delivery_id=d.id) GROUP BY d.peer").all(this.projectId) as { peer: unknown; n: unknown }[];
1518
+ return Object.fromEntries(rows.map(row => [text(row.peer, "delivery peer", 256), integer(row.n, "unresolved failed count")]));
1519
+ }
864
1520
  /** The queue needs a current revision, not the recovery snapshot's payloads. */
865
1521
  revision(): number {
866
1522
  this.ensureOpen();
@@ -892,11 +1548,29 @@ export class DeliveryJournal {
892
1548
  : this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(this.projectId, peer)) as Record<string, unknown>[];
893
1549
  return rows.map(parseDelivery);
894
1550
  }
895
- heldDeliveryHints(peer: string) {
1551
+ heldDeliveryHints(peer: string, includeAccepted = false) {
1552
+ this.ensureOpen();
1553
+ const states = includeAccepted ? "('needs_review','accepted')" : "('needs_review')";
1554
+ const count = Number((this.db.query(`SELECT COUNT(*) AS n FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ${states}`).get(this.projectId, peer) as { n: number }).n);
1555
+ const rows = this.db.query(`SELECT d.id, d.revision, d.created_at, d.state, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.from')) FROM json_each(d.originals) env) AS senders, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.kind')) FROM json_each(d.originals) env) AS kinds FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state IN ${states} ORDER BY d.created_at, d.id LIMIT 33`).all(this.projectId, peer) as { id: string; revision: number; created_at: number; state: "accepted" | "needs_review"; senders: string; kinds: string }[];
1556
+ return heldDeliveryHints(rows.map(row => ({ id: row.id, revision: row.revision, createdAt: row.created_at, ...(includeAccepted ? { state: row.state } : {}), senders: JSON.parse(row.senders) as string[], kinds: JSON.parse(row.kinds) as Kind[] })), count);
1557
+ }
1558
+
1559
+ /** Bounded indexed scan; the caller performs a revision-fenced state transition. */
1560
+ expiredAccepted(cutoff: number, peers: readonly string[], limit = 256): AcceptedDeliveryCandidate[] {
1561
+ this.ensureOpen(); integer(cutoff, "accepted cutoff"); integer(limit, "accepted expiry limit", 1, 1024);
1562
+ if (!peers.length) return [];
1563
+ if (peers.length > 4096) fail("accepted expiry peer set is too large");
1564
+ for (const peer of peers) text(peer, "accepted expiry peer", 256);
1565
+ const placeholders = peers.map(() => "?").join(",");
1566
+ const rows = this.db.query(`SELECT d.id,d.peer,d.revision,d.updated_at,EXISTS(SELECT 1 FROM json_each(d.originals) env WHERE json_extract(env.value,'$.priority')='important' OR json_extract(env.value,'$.kind') IN ('review','task')) AS notify_conductor FROM deliveries d WHERE d.project_id = ? AND d.state = 'accepted' AND d.updated_at <= ? AND d.peer IN (${placeholders}) ORDER BY d.updated_at,d.id LIMIT ?`).all(this.projectId, cutoff, ...peers, limit) as { id: unknown; peer: unknown; revision: unknown; updated_at: unknown; notify_conductor: unknown }[];
1567
+ return rows.map(row => ({ id: text(row.id, "accepted expiry id", 256), peer: text(row.peer, "accepted expiry peer", 256), revision: integer(row.revision, "accepted expiry revision"), updatedAt: integer(row.updated_at, "accepted expiry age"), notifyConductor: row.notify_conductor === 1 }));
1568
+ }
1569
+
1570
+ /** Cheap recovery guard for timeout holds; reads no delivery payloads. */
1571
+ hasAcceptedTimeoutHolds(): boolean {
896
1572
  this.ensureOpen();
897
- const count = Number((this.db.query("SELECT COUNT(*) AS n FROM deliveries WHERE project_id = ? AND peer = ? AND state = 'needs_review'").get(this.projectId, peer) as { n: number }).n);
898
- const rows = this.db.query("SELECT d.id, d.revision, d.created_at, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.from')) FROM json_each(d.originals) env) AS senders, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.kind')) FROM json_each(d.originals) env) AS kinds FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state = 'needs_review' ORDER BY d.created_at, d.id LIMIT 33").all(this.projectId, peer) as { id: string; revision: number; created_at: number; senders: string; kinds: string }[];
899
- return heldDeliveryHints(rows.map(row => ({ id: row.id, revision: row.revision, createdAt: row.created_at, senders: JSON.parse(row.senders) as string[], kinds: JSON.parse(row.kinds) as Kind[] })), count);
1573
+ return !!this.db.query("SELECT 1 FROM deliveries WHERE project_id = ? AND state = 'needs_review' AND reason = ? LIMIT 1").get(this.projectId, ACCEPTED_SETTLE_TIMEOUT_REASON);
900
1574
  }
901
1575
  /** Explicit resolutions, not automatic safe failures, consume the immutable originals' retry lineage. */
902
1576
  retryEvidence(peer: string, originals: readonly Envelope[]): "unused" | "used" | "unknown" {
@@ -971,12 +1645,13 @@ export class DeliveryJournal {
971
1645
  reason: string,
972
1646
  requestRevision = revision,
973
1647
  actor: string = "user",
1648
+ allowAcceptedPeerDisposition = false,
974
1649
  ): JournalDelivery {
975
1650
  this.ensureOpen();
976
1651
  text(reason, "resolution reason", 1_000_000);
977
1652
  integer(revision, "observed revision");
978
1653
  if (!ACTIONS.has(action)) throw new Error("invalid resolution action");
979
- return this.db.transaction(() => {
1654
+ return this.transaction(() => {
980
1655
  const resolved = this.resolution(id);
981
1656
  if (resolved) {
982
1657
  const current = this.get(id);
@@ -996,7 +1671,7 @@ export class DeliveryJournal {
996
1671
  if (actor.startsWith("peer:")) {
997
1672
  const peer = actor.slice(5);
998
1673
  const recipientRetry = action === "retry" && current.peer === peer && current.state === "needs_review" && this.retryEvidence(current.peer, current.originals) === "unused";
999
- const ownerDisposition = (action === "completed" || action === "discard") && current.peer === peer && current.state === "needs_review";
1674
+ const ownerDisposition = (action === "completed" || action === "discard") && current.peer === peer && (current.state === "needs_review" || current.state === "accepted" && allowAcceptedPeerDisposition === true);
1000
1675
  if (!recipientRetry && !ownerDisposition) throw new Error("recipient delivery resolution unavailable; inspect the held delivery or ask the person");
1001
1676
  }
1002
1677
  if (current.revision !== revision)
@@ -1006,9 +1681,18 @@ export class DeliveryJournal {
1006
1681
  actionState(action) !== current.state
1007
1682
  )
1008
1683
  throw new Error(`delivery ${id} is already terminal`);
1684
+ const retryChildId = action === "retry" ? `${id}:retry:${(current.attempt ?? 0) + 1}` : undefined;
1685
+ if (retryChildId && (this.legacyReviewCandidateIds.has(id) || this.legacyReviewCandidateFenceIds?.includes(id))
1686
+ && !this.legacyReviewCandidateIds.has(retryChildId) && !this.legacyReviewCandidateFenceIds?.includes(retryChildId)) {
1687
+ if (this.legacyReviewCandidatesOverflow || this.legacyReviewCandidateIds.size >= MAX_REVIEW_INPUTS) {
1688
+ const fence = this.legacyReviewCandidateFenceIds ?? [...this.legacyReviewCandidateIds];
1689
+ if (fence.length < MAX_ROWS) this.legacyReviewCandidateFenceIds = [...fence, retryChildId];
1690
+ this.legacyReviewCandidatesOverflow = true;
1691
+ } else this.legacyReviewCandidateIds = new Set([...this.legacyReviewCandidateIds, retryChildId]);
1692
+ }
1009
1693
  const result = this.transition(id, actionState(action), reason, revision);
1010
1694
  if (action === "retry") {
1011
- const childId = `${id}:retry:${(current.attempt ?? 0) + 1}`;
1695
+ const childId = retryChildId!;
1012
1696
  if (!this.get(childId))
1013
1697
  this.createDelivery({
1014
1698
  id: childId,
@@ -1040,7 +1724,7 @@ export class DeliveryJournal {
1040
1724
  if (action === "retry") this.recordRetryEvidence(current.peer, current.originals, id, actor === `peer:${current.peer}`);
1041
1725
  this.pruneTerminal(result.state, result.id);
1042
1726
  return result;
1043
- })();
1727
+ });
1044
1728
  }
1045
1729
  /** #1096: the ids of every delivery an operator action (completed, retry, discard) already settled. One query, so a list of any length costs one read. */
1046
1730
  resolvedIds(): Set<string> {
@@ -1060,6 +1744,10 @@ export class DeliveryJournal {
1060
1744
  if (this.pruningDeferred) return;
1061
1745
  const terminal = state === "completed" || state === "discarded" || state === "failed";
1062
1746
  const terminalCap = terminal ? this.terminalHistoryLimit?.() ?? TERMINAL_CAP : TERMINAL_CAP;
1747
+ let protectedReviewCandidates: string[] = [];
1748
+ try {
1749
+ if (!this.readReviewInputProvenance().coverageComplete) protectedReviewCandidates = [...this.legacyReviewCandidateIds];
1750
+ } catch { protectedReviewCandidates = [...this.legacyReviewCandidateIds]; }
1063
1751
  // Never delete the receipt this mutation/resolve is still using, at any target.
1064
1752
  const count = Number(
1065
1753
  (
@@ -1071,16 +1759,20 @@ export class DeliveryJournal {
1071
1759
  ).n,
1072
1760
  );
1073
1761
  if (count <= terminalCap) return;
1762
+ // ponytail: person-package receipts retain their terminal payload alongside the saved board package.
1763
+ // A future package archival transaction can replace both with an IDs-only publication tombstone.
1074
1764
  this.db
1075
1765
  .query(
1076
- "DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND d.id != ? AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
1766
+ "DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND d.id != ? AND NOT EXISTS (SELECT 1 FROM json_each(?) protected WHERE protected.value=d.id) AND NOT EXISTS (SELECT 1 FROM json_each(d.originals) e WHERE json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review' AND json_extract(e.value,'$.refs.reviewPackage') GLOB 'review-*') AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
1077
1767
  )
1078
- .run(this.projectId, currentId, count - terminalCap);
1768
+ .run(this.projectId, currentId, JSON.stringify(protectedReviewCandidates), count - terminalCap);
1079
1769
  }
1080
1770
  importSnapshot(snapshot: JournalSnapshot, operationId: string, trackQueuePressure = false): boolean {
1081
1771
  this.ensureOpen();
1082
1772
  validateJournalSnapshot(snapshot);
1083
1773
  const bus = this.queueMetadataSnapshot(snapshot.bus, trackQueuePressure);
1774
+ const importedReviewInput = snapshot.reviewInputProvenance ?? { version: REVIEW_INPUT_VERSION, instanceId: snapshot.instanceId, complete: true, coverageComplete: false, count: 0, digest: reviewInputDigest(snapshot.instanceId, REVIEW_INPUT_VERSION, true, false, 0, [], [], []), holds: [], incompletePeers: [], incompleteSessions: [] };
1775
+ const reviewInputProvenance: ReviewInputProvenance = { ...importedReviewInput, instanceId: this.instanceId, digest: reviewInputDigest(this.instanceId, REVIEW_INPUT_VERSION, importedReviewInput.complete, importedReviewInput.coverageComplete, importedReviewInput.count, importedReviewInput.holds, importedReviewInput.incompletePeers, importedReviewInput.incompleteSessions) };
1084
1776
  text(operationId, "operationId", 256);
1085
1777
  if (this.operationId !== undefined && this.operationId !== operationId)
1086
1778
  throw new Error("recovery operation does not own delivery journal");
@@ -1097,23 +1789,29 @@ export class DeliveryJournal {
1097
1789
  Object.keys(storedBus).length !== 0
1098
1790
  )
1099
1791
  return false;
1100
- return this.queueMetadataTransaction(trackQueuePressure, () => {
1792
+ const imported = this.queueMetadataTransaction(trackQueuePressure, () => {
1101
1793
  this.db
1102
1794
  .query(
1103
- "UPDATE delivery_meta SET revision = ?, bus_snapshot = ?, manual_paused = ?, operation_id = ? WHERE project_id = ?",
1795
+ "UPDATE delivery_meta SET revision = ?, bus_snapshot = ?, manual_paused = ?, operation_id = ?, review_input_version=?,review_input_complete=?,review_input_coverage_complete=?,review_input_count=?,review_input_digest=?,review_input_instance_id=? WHERE project_id = ?",
1104
1796
  )
1105
1797
  .run(
1106
1798
  snapshot.revision,
1107
1799
  JSON.stringify(bus),
1108
1800
  JSON.stringify(snapshot.manualPaused),
1109
1801
  operationId,
1802
+ REVIEW_INPUT_VERSION,
1803
+ reviewInputProvenance.complete ? 1 : 0,
1804
+ reviewInputProvenance.coverageComplete ? 1 : 0,
1805
+ reviewInputProvenance.count,
1806
+ reviewInputProvenance.digest,
1807
+ this.instanceId,
1110
1808
  this.projectId,
1111
1809
  );
1112
1810
  this.writeQueueMetadata(bus, snapshot.revision, trackQueuePressure);
1113
1811
  for (const row of snapshot.deliveries)
1114
1812
  this.db
1115
1813
  .query(
1116
- "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1814
+ "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id, failure_cause) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1117
1815
  )
1118
1816
  .run(
1119
1817
  row.id,
@@ -1128,11 +1826,21 @@ export class DeliveryJournal {
1128
1826
  row.reason ?? null,
1129
1827
  row.attempt ?? null,
1130
1828
  row.previousId ?? null,
1829
+ checkedFailureCause(row.failureCause) ?? null,
1131
1830
  );
1831
+ for (const raw of reviewInputProvenance.holds) {
1832
+ const hold = reviewInputHold(raw, "snapshot review input");
1833
+ this.db.query("INSERT INTO review_input_sessions(project_id,peer,session_hash,input_hash) VALUES(?,?,?,?)")
1834
+ .run(this.projectId, hold.peer, hold.sessionHash, hold.inputHash);
1835
+ }
1836
+ for (const peer of reviewInputProvenance.incompletePeers) this.db.query("INSERT INTO review_input_incomplete_peers(project_id,peer) VALUES(?,?)").run(this.projectId, peer);
1837
+ for (const row of reviewInputProvenance.incompleteSessions) this.db.query("INSERT INTO review_input_incomplete_sessions(project_id,peer,session_hash) VALUES(?,?,?)").run(this.projectId, row.peer, row.sessionHash);
1132
1838
  // Snapshots deliberately omit resolution history; only these imported originals have unknown retry rights.
1133
1839
  this.markRetryUnknown(snapshot.deliveries, bus);
1134
1840
  return true;
1135
1841
  });
1842
+ if (imported) this.captureLegacyReviewCandidates(false, snapshot.deliveries.map(row => row.id));
1843
+ return imported;
1136
1844
  }
1137
1845
  close(): void {
1138
1846
  if (!this.closed) {
@@ -1160,6 +1868,7 @@ function parseDelivery(row: Record<string, unknown>): JournalDelivery {
1160
1868
  originals: JSON.parse(String(row.originals)),
1161
1869
  out: JSON.parse(String(row.out_json)),
1162
1870
  ...(row.reason == null ? {} : { reason: String(row.reason) }),
1871
+ ...(row.failure_cause == null ? {} : { failureCause: row.failure_cause }),
1163
1872
  ...(row.attempt == null ? {} : { attempt: Number(row.attempt) }),
1164
1873
  ...(row.previous_id == null ? {} : { previousId: String(row.previous_id) }),
1165
1874
  });