@staix/agent-hub 0.30.0 → 0.31.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 (125) hide show
  1. package/CHANGELOG.md +39 -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 +9 -4
  7. package/docs/agent-notes/daemon.md +15 -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 +139 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +22 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +307 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +18 -5
  21. package/docs/security.md +154 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +84 -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 +257 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  31. package/docs/verified.json +14 -14
  32. package/package.json +3 -4
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +4393 -3609
  35. package/src/adapters/acp.ts +5 -4
  36. package/src/adapters/channel-peer.ts +72 -3
  37. package/src/adapters/claude-channel-evidence.ts +52 -0
  38. package/src/adapters/claude-channel.ts +61 -18
  39. package/src/adapters/claude-implementer.ts +29 -7
  40. package/src/adapters/claude-print-child.ts +12 -2
  41. package/src/adapters/codex-appserver.ts +13 -12
  42. package/src/adapters/compat.ts +3 -3
  43. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  44. package/src/adapters/definitions/qwen.ts +1 -0
  45. package/src/adapters/local-worker.ts +61 -18
  46. package/src/adapters/pi.ts +48 -6
  47. package/src/adapters/registry-types.ts +12 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/adapters/reviewer-print.ts +50 -11
  50. package/src/broker/server.ts +44 -13
  51. package/src/broker/store.ts +38 -8
  52. package/src/cli/answer-args.ts +50 -0
  53. package/src/cli/attention.ts +17 -2
  54. package/src/cli/backlog-return-args.ts +37 -0
  55. package/src/cli/console-question.ts +110 -0
  56. package/src/cli/console-state.ts +77 -16
  57. package/src/cli/console.ts +54 -1
  58. package/src/cli/handoff-args.ts +15 -5
  59. package/src/cli/handoff-output.ts +15 -2
  60. package/src/cli/help.ts +8 -5
  61. package/src/cli/identity.ts +13 -3
  62. package/src/cli/main.ts +128 -16
  63. package/src/cli/memory.ts +107 -3
  64. package/src/cli/output.ts +29 -6
  65. package/src/cli/pet.ts +4 -4
  66. package/src/cli/results-args.ts +30 -0
  67. package/src/cli/runtime-version-warning.ts +50 -0
  68. package/src/cli/status-lines.ts +41 -1
  69. package/src/cli/tail-render.ts +1 -0
  70. package/src/cli/upgrade-interactive.ts +14 -2
  71. package/src/hub/action-class.ts +9 -1
  72. package/src/hub/board.ts +369 -18
  73. package/src/hub/bus.ts +493 -53
  74. package/src/hub/completion-detail.ts +50 -0
  75. package/src/hub/completions-store.ts +168 -0
  76. package/src/hub/completions.ts +120 -0
  77. package/src/hub/config-apply.ts +7 -5
  78. package/src/hub/config-reload.ts +10 -1
  79. package/src/hub/control-client.ts +40 -4
  80. package/src/hub/daemon.ts +1569 -134
  81. package/src/hub/delivery-failure.ts +14 -0
  82. package/src/hub/delivery-hints.ts +14 -4
  83. package/src/hub/delivery-journal.ts +751 -42
  84. package/src/hub/delivery-superseded.ts +4 -1
  85. package/src/hub/envelope.ts +3 -3
  86. package/src/hub/events.ts +6 -0
  87. package/src/hub/handoff.ts +145 -6
  88. package/src/hub/hub-tools.ts +56 -7
  89. package/src/hub/manager.ts +59 -3
  90. package/src/hub/memory-policy.ts +44 -1
  91. package/src/hub/memory-sync-policy.ts +109 -0
  92. package/src/hub/memory-sync-state.ts +145 -0
  93. package/src/hub/no-writes.ts +1 -1
  94. package/src/hub/operational-settings.ts +11 -2
  95. package/src/hub/operational-view.ts +12 -1
  96. package/src/hub/peers.ts +25 -2
  97. package/src/hub/planning.ts +6 -1
  98. package/src/hub/question-desk.ts +201 -0
  99. package/src/hub/question-detach.ts +6 -0
  100. package/src/hub/questions.ts +260 -0
  101. package/src/hub/research-evidence.ts +106 -0
  102. package/src/hub/restart.ts +14 -0
  103. package/src/hub/review-anchor.ts +181 -0
  104. package/src/hub/review-brief.ts +13 -0
  105. package/src/hub/review-comments.ts +115 -0
  106. package/src/hub/review-findings.ts +7 -2
  107. package/src/hub/review-material.ts +76 -0
  108. package/src/hub/routing.ts +3 -1
  109. package/src/hub/task-sweep.ts +2 -0
  110. package/src/hub/tasks.ts +577 -63
  111. package/src/memory/capture.ts +5 -2
  112. package/src/pet/config.ts +25 -13
  113. package/src/rooms/link.ts +209 -21
  114. package/src/rooms/remote-peer.ts +186 -19
  115. package/src/rooms/service.ts +338 -22
  116. package/src/rooms/store.ts +437 -65
  117. package/src/rooms/wire.ts +24 -6
  118. package/src/ui/index.html +138 -7
  119. package/src/ui/needs.ts +10 -4
  120. package/src/ui/plugin-questions.ts +56 -0
  121. package/src/ui/plugin-results.ts +81 -0
  122. package/src/ui/plugin.ts +207 -27
  123. package/src/pet/window.html +0 -114
  124. package/src/pet/window.jxa.js +0 -122
  125. package/src/pet/window.ts +0 -154
package/src/hub/bus.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { isDeliveryFailureCause, type DeliveryFailureCause } from "./delivery-failure.ts";
2
+ import { isReviewObserverExcluded } from "./memory-policy.ts";
1
3
  import { remoteEnvelope, appendNote, DIGEST, frame, HUB, USER, isInvocationKind, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
4
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
5
  import type { DoingKind } from "./peer-doing.ts";
4
6
  import type { QueuePressure } from "./queue-backpressure.ts";
5
- import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState, type JournalResolution } from "./delivery-journal.ts";
7
+ import { ACCEPTED_SETTLE_TIMEOUT_REASON, DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState, type JournalResolution } from "./delivery-journal.ts";
6
8
  import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
7
9
  import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
8
10
  import { parseRemotePeerId } from "../rooms/application.ts";
@@ -15,6 +17,12 @@ export interface PublishResult {
15
17
  dropped?: "hop" | "fyi";
16
18
  }
17
19
 
20
+ export interface AcceptedDeliveryExpiry { id: string; peer: PeerId; revision: number; notifyConductor: boolean }
21
+
22
+ export class ReviewInputHandoffError extends Error {
23
+ constructor() { super("review input provenance unavailable; the message remains queued. Reconnect the peer, check ahub status for journal health, then retry hub_inbox."); this.name = "ReviewInputHandoffError"; }
24
+ }
25
+
18
26
  export type BusEvent =
19
27
  | { t: "envelope"; env: Envelope; dropped?: "hop" | "fyi" }
20
28
  | { t: "overflow"; env: Envelope; peer: PeerId }
@@ -22,9 +30,16 @@ export type BusEvent =
22
30
  | { t: "stale"; env: Envelope; peer: PeerId; reason: string }
23
31
  | { t: "quiet"; env: Envelope; peers: PeerId[]; reason: string }
24
32
  | { t: "off_roster"; env: Envelope; peers: PeerId[] }
33
+ | { t: "remote_delivery_settled"; peer: PeerId; deliveryId: string; outcome: "completed" | "failed_safe" }
25
34
  | { t: "state"; peer: PeerId; state: PeerState };
26
35
 
27
36
  export interface BusOptions {
37
+ /** Durable host-only comment provenance; no peer-supplied marker or task authority. */
38
+ reviewObserverScope?: (peer: PeerId) => string | undefined;
39
+ /** Host task/input screening for observer-memory hooks; receives originals, never rendered output. */
40
+ reviewObserverInputExcluded?: (peer: PeerId, originals: Envelope[]) => boolean;
41
+ /** Required durable provenance before a review-scoped input crosses a native handoff boundary; throws to refuse handoff. */
42
+ beforeReviewInputHandoff?: (peer: PeerId, originals: Envelope[]) => void;
28
43
  /** Durable application outbox ids; the bus combines them with its own pending ids without recursion. */
29
44
  remotePending?: (room: string) => readonly string[];
30
45
  /** Host provenance at enqueue, never a body screen; unknown/throw marks the original private. */
@@ -43,6 +58,8 @@ export interface BusOptions {
43
58
  queueCap: number;
44
59
  /** Accepted live operational settings; read only at the next matching delivery/note operation. */
45
60
  maxAttempts?: () => number;
61
+ /** Maximum age of an accepted explicit-channel delivery before it needs review. */
62
+ acceptedSettleMs?: () => number;
46
63
  digestMax?: () => number;
47
64
  pullBudgetBytes?: () => number;
48
65
  notesKept?: () => number;
@@ -143,6 +160,8 @@ export const NOTES_KEPT = 10;
143
160
  * delivered as a whole (one envelope, or a digest) when the peer is idle and the queue is ready:
144
161
  * it holds an `important` envelope, or `batchMax` envelopes, or its oldest one has waited `batchMs`.
145
162
  */
163
+ export const ROOM_PROOF_UNAVAILABLE_REASON = "room delivery proof unavailable; inspect ahub queue show";
164
+
146
165
  export class Bus {
147
166
  readonly peers = new Map<PeerId, PeerAdapter>();
148
167
  private readonly opts: BusOptions;
@@ -167,7 +186,13 @@ export class Bus {
167
186
  private readonly taps = new Set<(e: BusEvent) => void>();
168
187
  private readonly withdrawn = new Set<string>();
169
188
  /** What each peer was last handed, next to what it stands for: an adapter that reports a failure later hands back the former. */
189
+ private readonly reviewParents = new Map<PeerId, Map<string, string>>();
190
+ private readonly reviewResponses = new Map<string, { peer: PeerId; source: string }>();
191
+ private reviewScopeRevision = -1;
192
+ private readonly reviewScopeCache = new Map<string, boolean>();
170
193
  private readonly lastDelivery = new Map<PeerId, { out: Envelope[]; originals: Envelope[] }>();
194
+ /** Latest inbox handout; kept separately so it cannot replace a pushed invocation's answer parent. */
195
+ private readonly lastPulled = new Map<PeerId, { out: Envelope[]; originals: Envelope[] }>();
171
196
  private readonly failureStreak = new Map<PeerId, number>();
172
197
  private readonly lastFailure = new Map<PeerId, string>();
173
198
  private readonly attempts = new Map<string, number>(); // `${peer}:${envelope id}` -> failed deliveries
@@ -203,7 +228,7 @@ export class Bus {
203
228
  }
204
229
 
205
230
  /** Hold the existing delivery rather than cancelling native work or replaying an uncertain effect. */
206
- holdInvocation(id: string, reason: string, target?: PeerId): void {
231
+ holdInvocation(id: string, reason: string, target?: PeerId, failureCause?: DeliveryFailureCause): void {
207
232
  const delivery = this.invocationDeliveries.get(id), row = delivery && this.journal?.get(delivery);
208
233
  if (!row || !this.journal) {
209
234
  this.storageError = "uncertain invocation delivery evidence unavailable; inspect the invocation ledger before recovery";
@@ -211,7 +236,7 @@ export class Bus {
211
236
  this.onQueues?.();
212
237
  return;
213
238
  }
214
- this.journal.transition(row.id, "needs_review", reason);
239
+ this.journal.transition(row.id, "needs_review", reason, undefined, failureCause ?? row.failureCause ?? "turn aborted");
215
240
  this.activeDeliveries.delete(row.id);
216
241
  this.recoveryHeldPeers.add(row.peer);
217
242
  this.onQueues?.();
@@ -219,6 +244,10 @@ export class Bus {
219
244
 
220
245
  /** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
221
246
  onDelivered?: (peer: PeerId, originals: Envelope[], deliveryId?: string) => void;
247
+ /** Metadata-only current-input observation after a successful inbox handout; never a transport ACK. */
248
+ onPulled?: (peer: PeerId, originals: Envelope[]) => void;
249
+ /** Metadata-only observation when an important message actually begins a steer; never a transport ACK. */
250
+ onSteering?: (peer: PeerId, originals: Envelope[]) => void;
222
251
  /** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
223
252
  onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
224
253
  /** Final rendered journal payload, and its first positive acknowledgement. Observation cannot alter settlement. */
@@ -243,13 +272,13 @@ export class Bus {
243
272
  this.journal = opts.journal;
244
273
  this.trackingQueuePressure();
245
274
  if (this.journal) {
246
- const state = this.journal.snapshot();
275
+ const state = this.journal.transportSnapshot();
247
276
  const bus = state.bus as BusSnapshot;
248
277
  if (state.revision > 0 && bus && bus.schemaVersion === 1) this.loadSnapshot(bus);
249
278
  for (const id of state.manualPaused) { this.manualPaused.add(id); this.paused.add(id); }
250
279
  for (const row of this.journal.list()) {
251
280
  for (const env of row.originals) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, row.id);
252
- if (row.state === "needs_review" || row.state === "dispatching" || row.state === "accepted") this.recoveryHeldPeers.add(row.peer);
281
+ if (row.state === "dispatching" || row.state === "accepted" || row.state === "needs_review" && (!remoteId(row.peer) || row.originals.some(isInvocationKind))) this.recoveryHeldPeers.add(row.peer);
253
282
  }
254
283
  }
255
284
  }
@@ -277,29 +306,38 @@ export class Bus {
277
306
  else if (member !== peer.remoteMember) throw new Error("remote queued member binding changed");
278
307
  }
279
308
  if (peer.remoteRoom !== undefined && !pendingRemote) { this.remoteQueueRooms.delete(peer.id); this.remoteQueueMembers.delete(peer.id); }
309
+ this.clearReviewProvenance(peer.id);
280
310
  this.peers.set(peer.id, peer);
281
311
  if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
282
- peer.onDelivery = (receipt) => this.deliveryReceipt(peer.id, receipt);
283
- peer.onDoing = (kind, text) => { if (!this.closed && this.peers.get(peer.id) === peer) this.opts.doing?.(peer.id, kind, text); };
312
+ peer.onDelivery = (receipt) => { if (this.peers.get(peer.id) === peer) this.deliveryReceipt(peer.id, receipt); };
313
+ peer.onDoing = (kind, text) => { if (kind === "reset") this.clearReviewProvenance(peer.id); if (!this.closed && this.peers.get(peer.id) === peer) this.opts.doing?.(peer.id, kind, text); };
284
314
  this.opts.doing?.(peer.id, "reset"); // a new adapter never shows what the one it replaces was doing
285
315
  peer.onMessage = (text, opts) => {
286
316
  if (!this.isRostered(peer.id)) return `off roster: ${peer.id}`;
287
317
  // #445 C2: an answer to an invocation is found by what the peer was handed, never by how the answer is addressed, so a headless agent whose final answer is
288
318
  // normally shared with every peer cannot leak it. It is the invocation's answer when it names the request itself (an adapter may report its delivery complete before
289
- // it answers, so the receipt cannot decide), or when it names nothing while that invocation's own delivery is still the peer's open turn. Once that turn has ended,
290
- // `lastDelivery` still holds the request until the next delivery replaces it, and a later message from the peer is an ordinary message: it must be published.
291
- const handed = this.lastDelivery.get(peer.id)?.out ?? [];
292
- const named = opts?.inReplyTo?.id !== undefined && handed.some((e) => e.id === opts.inReplyTo!.id); // an invocation is delivered alone, so the only envelope in `handed` to name is its request (or the preface that rode with it)
293
- if (this.onAnswer && handed.some((e) => e.kind === "invocation") && (named || opts?.inReplyTo?.id === undefined && this.invocationTurnOpen(peer.id))) {
319
+ // it answers, so the receipt cannot decide), or when it names nothing while that invocation's own pushed delivery is still the peer's open turn. A pull updates
320
+ // current-input provenance separately, so it never replaces this answer parent; after the invocation turn ends, a later unnamed message is ordinary.
321
+ const pushed = this.lastDelivery.get(peer.id), pulled = this.lastPulled.get(peer.id), parentId = opts?.inReplyTo?.id;
322
+ const namedPush = parentId !== undefined && pushed?.out.some(e => e.id === parentId);
323
+ const namedPull = parentId !== undefined && pulled?.out.some(e => e.id === parentId);
324
+ const pushedInvocation = pushed?.out.some(e => e.kind === "invocation") === true;
325
+ const pulledInvocation = pulled?.out.some(e => e.kind === "invocation") === true;
326
+ const answerParent = namedPush && pushedInvocation ? pushed
327
+ : namedPull && pulledInvocation ? pulled
328
+ : parentId === undefined && this.invocationTurnOpen(peer.id)
329
+ ? pushedInvocation ? pushed : pulledInvocation ? pulled : undefined
330
+ : undefined;
331
+ if (this.onAnswer && answerParent && (namedPush || namedPull || parentId === undefined && this.invocationTurnOpen(peer.id))) {
294
332
  let taken = false;
295
- try { taken = this.onAnswer(peer.id, text, handed) === true; } catch { taken = true; } // a throwing host never turns a callee's answer into a broadcast
333
+ try { taken = this.onAnswer(peer.id, text, answerParent.out) === true; } catch { taken = true; } // a throwing host never turns a callee's answer into a broadcast
296
334
  if (taken) return;
297
335
  }
298
336
  const { priority, body } = parseMarker(text);
299
337
  if (!body) return;
300
338
  // What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
301
339
  // and the priority ceiling are about what it stands for.
302
- const last = this.lastDelivery.get(peer.id);
340
+ const last = (parentId !== undefined && namedPush ? pushed : parentId !== undefined && namedPull ? pulled : pulled ?? pushed);
303
341
  const answers = opts?.inReplyTo?.id !== undefined && last?.out.some((e) => e.id === opts.inReplyTo!.id);
304
342
  const originals = answers ? last!.originals : undefined;
305
343
  // `digest` in the audience stands for the condensed delivery even when a steer, not the digest, is the parent.
@@ -307,6 +345,7 @@ export class Bus {
307
345
  const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
308
346
  const to = opts?.to?.length ? resolveTo(opts.to, audienceOriginals) : undefined;
309
347
  let env = newEnvelope(peer.id, body, { ...opts, ...(to ? { to } : {}), priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals) });
348
+ env = this.reviewResponse(env, opts?.inReplyTo?.id);
310
349
  try { this.checkRemotePublication(env); }
311
350
  catch (error) {
312
351
  if (!(error instanceof PreEffectToolRefusal)) throw error;
@@ -360,19 +399,20 @@ export class Bus {
360
399
  if (owner !== peer.id) continue;
361
400
  const row = this.journal.get(deliveryId);
362
401
  if (row && row.originals.some((e) => envs.some((incoming) => incoming.id === e.id))) {
363
- this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence" });
402
+ this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence", failureCause: failure?.failureCause ?? "turn aborted" });
364
403
  }
365
404
  }
366
- } else if (envs.some(env => env.kind === "invocation")) {
405
+ } else if (!remoteId(peer.id) && envs.some(env => env.kind === "invocation")) {
367
406
  this.recoveryHeldPeers.add(peer.id);
368
407
  for (const env of envs) if (env.kind === "invocation") this.onInvocationUncertain?.(env.id);
369
408
  } else this.failed(peer.id, same ? last.originals : envs, undefined, failure?.quota === true);
370
409
  };
371
410
  peer.onState = () => {
411
+ if (peer.state === "offline") this.clearReviewProvenance(peer.id);
372
412
  if (this.closed) return;
373
413
  if (this.peers.get(peer.id) !== peer) return;
374
414
  if (peer.state === "offline" && this.journal) {
375
- for (const [id, owner] of [...this.activeDeliveries]) if (owner === peer.id) this.uncertain(peer.id, id, "peer disconnected before delivery settlement");
415
+ for (const [id, owner] of [...this.activeDeliveries]) if (owner === peer.id) this.uncertain(peer.id, id, "peer disconnected before delivery settlement", "peer offline");
376
416
  }
377
417
  this.emit({ t: "state", peer: peer.id, state: this.stateOf(peer.id) });
378
418
  void this.drain(peer.id);
@@ -590,6 +630,13 @@ export class Bus {
590
630
  return [...new Set([...this.peers.keys(), ...[...this.queues].filter(([, queue]) => queue.length).map(([id]) => id), ...pending])].sort();
591
631
  }
592
632
 
633
+ /** Declare an already-rostered local destination whose adapter is not instantiated yet. */
634
+ ensureKnownRecipientQueue(peer: PeerId): void {
635
+ if (this.closed || this.recoveryHeld || this.storageError) throw new Error("delivery recipient queue unavailable");
636
+ if (typeof peer !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(peer) || peer === HUB || peer === USER || peer === DIGEST || remoteId(peer) || !this.isRostered(peer)) throw new Error("invalid configured local recipient");
637
+ if (!this.queues.has(peer)) this.queues.set(peer, []);
638
+ }
639
+
593
640
  queueList(peer?: string, unresolvedOnly = false): JournalDelivery[] {
594
641
  if (this.journal) {
595
642
  const rows = (unresolvedOnly ? this.journal.unresolved(peer) : this.journal.list(peer)).filter((row) => !row.reason?.startsWith("grouped into delivery "));
@@ -603,9 +650,68 @@ export class Bus {
603
650
  return ids.flatMap((p) => (this.queues.get(p) ?? []).map((e) => ({ id: e.id, peer: p, state: "queued" as const, revision: 0, createdAt: e.ts, updatedAt: e.ts, originals: [e], out: [e] })));
604
651
  }
605
652
 
653
+ /** Metadata only: status must not hydrate or sort retained delivery payloads. */
654
+ failedDeliveryCounts(): Record<string, number> | undefined {
655
+ if (this.closed || this.storageError || !this.journal) return undefined;
656
+ try { return this.journal.unresolvedFailedCounts(); }
657
+ catch { this.storageError = "delivery journal unavailable"; return undefined; }
658
+ }
659
+
606
660
  queueShow(id: string): JournalDelivery | undefined { return this.journal?.get(id) ?? this.queueList().find((d) => d.id === id); }
607
661
 
608
- heldDeliveryHints(peer: string) { return this.storageError ? undefined : this.journal?.heldDeliveryHints(peer); }
662
+ heldDeliveryHints(peer: string, includeAccepted = false) {
663
+ const adapter = this.peers.get(peer);
664
+ const canExposeAccepted = includeAccepted && adapter?.acceptedSettlement === "explicit";
665
+ return this.storageError ? undefined : this.journal?.heldDeliveryHints(peer, canExposeAccepted);
666
+ }
667
+
668
+ /** Whether timeout-originated holds still protect the recovery cause from supersession. */
669
+ hasAcceptedTimeoutHolds(): boolean {
670
+ if (this.closed || this.storageError || !this.journal) throw new Error("delivery timeout hold state unavailable");
671
+ return this.journal.hasAcceptedTimeoutHolds();
672
+ }
673
+
674
+ /** Expire only accepted channel notifications, without replaying or settling their effects. */
675
+ expireAcceptedDeliveries(now = Date.now()): AcceptedDeliveryExpiry[] {
676
+ if (this.closed || this.recoveryHeld || !this.journal || this.storageError) return [];
677
+ const settleMs = this.opts.acceptedSettleMs?.();
678
+ if (!Number.isSafeInteger(settleMs) || settleMs! < 1) return [];
679
+ if (!Number.isSafeInteger(now) || now < 0) return [];
680
+ if (now < settleMs!) return [];
681
+ const expired: AcceptedDeliveryExpiry[] = [];
682
+ const notify = () => { try { this.onQueues?.(); } catch { /* observers cannot consume a durable timeout notice */ } };
683
+ const eligiblePeers = [...this.peers.values()].filter(peer => peer.acceptedSettlement === "explicit").map(peer => peer.id);
684
+ try {
685
+ for (const row of this.journal.expiredAccepted(now - settleMs!, eligiblePeers)) {
686
+ // The synchronous read/transition pair is revision fenced; no await allows a confirmation to interleave.
687
+ const current = this.journal.get(row.id);
688
+ if (!current || current.peer !== row.peer || current.state !== "accepted" || current.revision !== row.revision) continue;
689
+ let updated: JournalDelivery;
690
+ try {
691
+ updated = this.journal.transition(row.id, "needs_review", ACCEPTED_SETTLE_TIMEOUT_REASON, row.revision, "timeout");
692
+ } catch (error) {
693
+ if (error instanceof Error && /stale delivery revision/.test(error.message)) continue;
694
+ this.storageError = "delivery journal unavailable";
695
+ notify();
696
+ return expired;
697
+ }
698
+ this.activeDeliveries.delete(row.id);
699
+ if (!remoteId(row.peer)) this.recoveryHeldPeers.add(row.peer);
700
+ expired.push({ id: row.id, peer: row.peer, revision: updated.revision, notifyConductor: row.notifyConductor });
701
+ this.deliveryFailed(current.peer);
702
+ for (const env of current.originals) if (isInvocationKind(env)) {
703
+ try { this.onInvocationReceipt?.(env.id, "needs_review"); } catch { /* an observer cannot suppress timeout metadata */ }
704
+ if (env.kind === "invocation") try { this.onInvocationUncertain?.(env.id); } catch { /* preserve uncertain invocation handling for other ids */ }
705
+ }
706
+ }
707
+ } catch {
708
+ this.storageError = "delivery journal unavailable";
709
+ notify();
710
+ return expired;
711
+ }
712
+ if (expired.length) notify();
713
+ return expired;
714
+ }
609
715
 
610
716
  /** Consent is for this peer's held row only, never authority to complete/discard or release a pause. */
611
717
  retryDelivery(peer: string, id: string): void {
@@ -628,8 +734,19 @@ export class Bus {
628
734
  const row = this.queueShow(id), actor = `peer:${peer}`, cleanReason = reason.trim();
629
735
  const notYours = "no needs_review delivery with this id is held for you; call hub_inbox to list your held deliveries and their revisions";
630
736
  if (!row || row.peer !== peer) return refuse(notYours);
737
+ if (this.activeDeliveries.has(id) && this.activeDeliveries.get(id) !== peer) return refuse(notYours);
631
738
  // An uncertain invocation may still be running: a peer claiming it completed would make the caller wait for a result that may never come. Only the person settles it.
632
739
  if (row.originals.some(env => isInvocationKind(env))) return refuse(`delivery ${id} carries an invocation whose outcome is uncertain; only the person settles it (ahub queue show ${id}, then ahub queue resolve ${id})`);
740
+ if (row.state === "accepted") {
741
+ if (this.peers.get(peer)?.acceptedSettlement !== "explicit") return refuse("only an explicit push channel session can settle its accepted delivery; ask the person to resolve it");
742
+ if (row.revision !== revision) return refuse("held delivery revision changed; read hub_inbox again for the current revision");
743
+ try { this.resolveDelivery(id, revision, action, cleanReason, actor); }
744
+ catch (error) {
745
+ if (error instanceof Error && /stale delivery revision/.test(error.message)) return refuse("held delivery revision changed; read hub_inbox again for the current revision");
746
+ return refuse("delivery storage refused this disposition; inspect ahub status and ask the person to resolve it");
747
+ }
748
+ return;
749
+ }
633
750
  const previous = this.journal.resolution(id);
634
751
  if (previous) {
635
752
  if (previous.actor !== actor || previous.action !== action || previous.reason !== cleanReason) return refuse(`delivery ${id} was already settled with a different action or reason; the person can inspect it with ahub queue show ${id}`);
@@ -684,10 +801,11 @@ export class Bus {
684
801
  if (!Number.isSafeInteger(deadline) || deadline! <= Date.now()) throw new PreEffectToolRefusal("remote original deadline unavailable or expired; discard it or send a new message explicitly");
685
802
  this.checkRemotePublication(record.originals[0]!);
686
803
  }
687
- const recipientConsent = actor === `peer:${record.peer}` && record.state === "needs_review" && (action === "retry" || action === "completed" || action === "discard");
804
+ const explicitAcceptedDisposition = actor === `peer:${record.peer}` && record.state === "accepted" && this.peers.get(record.peer)?.acceptedSettlement === "explicit" && (action === "completed" || action === "discard");
805
+ const recipientConsent = actor === `peer:${record.peer}` && (record.state === "needs_review" && (action === "retry" || action === "completed" || action === "discard") || explicitAcceptedDisposition);
688
806
  // #713: a `queued` delivery was never handed to the peer, so nothing is in flight and a busy peer (a pull-only TUI is busy most of the time) does not block
689
- // discarding it. `dispatching` and `accepted` stay refused, and every other resolution keeps the busy fence.
690
- if (["dispatching", "accepted"].includes(record.state)) throw new Error("active delivery cannot be resolved");
807
+ // discarding it. `dispatching` stays refused; `accepted` permits only its explicit channel owner's completed/discard disposition.
808
+ if (record.state === "dispatching" || record.state === "accepted" && !explicitAcceptedDisposition) throw new Error("active delivery cannot be resolved");
691
809
  const queuedDiscard = record.state === "queued" && action === "discard";
692
810
  // #1096: a `failed` row was refused before it ran and discarding it touches no queue, so a busy peer has nothing in flight to protect either.
693
811
  const failedDiscard = record.state === "failed" && action === "discard";
@@ -703,7 +821,7 @@ export class Bus {
703
821
  this.journal.transaction(() => {
704
822
  if (!this.journal!.get(id)) this.journal!.createDelivery({ ...record });
705
823
  const current = this.journal!.get(id)!;
706
- this.journal!.resolve(id, current.revision, action, reason, revision, actor);
824
+ this.journal!.resolve(id, current.revision, action, reason, revision, actor, explicitAcceptedDisposition);
707
825
  if (record.state === "queued") {
708
826
  const ids = new Set(record.originals.map((env) => env.id));
709
827
  queue.splice(0, queue.length, ...queue.filter((env) => !ids.has(env.id)));
@@ -724,6 +842,7 @@ export class Bus {
724
842
  else this.prefaces.delete(record.peer);
725
843
  throw error;
726
844
  }
845
+ if (explicitAcceptedDisposition) this.activeDeliveries.delete(id);
727
846
  this.refreshRecoveryHold(record.peer);
728
847
  this.onQueues?.();
729
848
  void this.drain(record.peer);
@@ -734,7 +853,7 @@ export class Bus {
734
853
  const queued = this.queues.get(peer) ?? [];
735
854
  const review = rows.filter((r) => r.state === "needs_review");
736
855
  const needsReview = review.length;
737
- const blocked = rows.find((r) => r.state === "needs_review" || (["dispatching", "accepted"].includes(r.state) && !this.activeDeliveries.has(r.id)));
856
+ const blocked = rows.find((r) => (r.state === "needs_review" && (!remoteId(peer) || r.originals.some(isInvocationKind))) || (["dispatching", "accepted"].includes(r.state) && !this.activeDeliveries.has(r.id)));
738
857
  const liveAccepted = rows.filter((r) => r.state === "accepted" && this.activeDeliveries.get(r.id) === peer).map((r) => r.id);
739
858
  const hold = { ...(blocked ? { heldBy: blocked.id } : {}), ...(liveAccepted.length ? { liveAccepted } : {}) };
740
859
  const oldest = queued.reduce<number | undefined>((a, r) => a === undefined ? r.ts : Math.min(a, r.ts), undefined);
@@ -744,6 +863,7 @@ export class Bus {
744
863
  manualPausedPeers(): string[] { return [...this.manualPaused].sort(); }
745
864
  setManualPaused(ids: string[]): void { this.manualPaused.clear(); for (const id of ids) this.manualPaused.add(id); this.persist(); }
746
865
  closeJournal(): void {
866
+ this.reviewParents.clear(); this.reviewResponses.clear();
747
867
  this.closed = true;
748
868
  this.recoveryHeld = true;
749
869
  for (const timer of this.timers.values()) clearTimeout(timer);
@@ -760,22 +880,33 @@ export class Bus {
760
880
  if (this.peers.get(peer)?.remoteRoom !== undefined && receipt.state === "failed_safe") {
761
881
  // A signed recipient NACK is a terminal known refusal, not a failed native
762
882
  // turn eligible for poison retries. It records no recipient read evidence.
763
- this.journal.transition(id, "failed", receipt.reason ?? "remote recipient refused safely");
764
- this.activeDeliveries.delete(id); this.remoteTransport.delete(id);
883
+ this.journal.transition(id, "failed", receipt.reason ?? "remote recipient refused safely", undefined, "adapter refused");
884
+ this.activeDeliveries.delete(id);
885
+ if (this.remoteTransport.get(id)?.peer === peer) this.remoteTransport.delete(id);
765
886
  for (const env of before?.originals ?? []) { this.attempts.delete(`${peer}:${env.id}`); this.remoteQueueExpires.get(peer)?.delete(env.id); }
766
887
  const last = this.lastDelivery.get(peer);
767
888
  if (before && last && last.out.length === before.out.length && last.out.every((env, index) => env.id === before.out[index]!.id)) this.lastDelivery.delete(peer);
768
889
  this.refreshRecoveryHold(peer);
769
- for (const env of before?.originals ?? []) if (isInvocationKind(env)) this.onInvocationReceipt?.(env.id, "failed_safe");
890
+ for (const env of before?.originals ?? []) if (isInvocationKind(env)) {
891
+ try { this.onInvocationReceipt?.(env.id, "failed_safe"); } catch { /* a ledger observer cannot undo a durable signed refusal */ }
892
+ }
770
893
  this.onQueues?.();
771
894
  void this.drain(peer);
772
895
  return;
773
896
  }
774
897
  const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
775
- if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
776
- if (state === "completed") this.journal.completeDelivered(id, peer, "adapter", receipt.reason);
777
- else this.journal.transition(id, state, receipt.reason);
778
- if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) {
898
+ if (!remoteId(peer) && (state === "failed" || state === "needs_review")) this.deliveryFailed(peer);
899
+ if (state === "completed" && !remoteId(peer)) this.journal.completeDelivered(id, peer, "adapter", receipt.reason);
900
+ else {
901
+ let cause: DeliveryFailureCause | undefined;
902
+ if (state === "failed" || state === "needs_review") {
903
+ cause = isDeliveryFailureCause(receipt.failureCause) ? receipt.failureCause : undefined;
904
+ if (!cause && state === "failed") cause = this.stateOf(peer) === "offline" ? "peer offline" : "adapter refused";
905
+ if (!cause) cause = "turn aborted";
906
+ }
907
+ this.journal.transition(id, state, receipt.reason, undefined, cause);
908
+ }
909
+ if (!remoteId(peer) && before?.state === "dispatching" && (state === "accepted" || state === "completed")) {
779
910
  this.memoryDelivery(peer, id, before.out, "delivered", before.originals);
780
911
  this.delivered(peer, before.originals, id);
781
912
  }
@@ -783,20 +914,27 @@ export class Bus {
783
914
  if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason, receipt.quota === true && before.state === "dispatching");
784
915
  }
785
916
  if (state === "completed") { this.failureStreak.delete(peer); this.lastFailure.delete(peer); }
786
- if (state === "completed" || state === "failed" || state === "needs_review") this.activeDeliveries.delete(id);
787
- if (state === "completed" || state === "failed") { this.refreshRecoveryHold(peer); void this.drain(peer); }
788
- if (receipt.state === "completed" || receipt.state === "needs_review") for (const env of before?.originals ?? []) if (isInvocationKind(env)) this.onInvocationReceipt?.(env.id, receipt.state);
789
- if (state === "needs_review") {
917
+ if (state === "completed" || state === "failed" || state === "needs_review") {
918
+ this.activeDeliveries.delete(id);
919
+ if (remoteId(peer) && this.remoteTransport.get(id)?.peer === peer) this.remoteTransport.delete(id);
920
+ }
921
+ for (const env of before?.originals ?? []) if (isInvocationKind(env) && (receipt.state === "completed" || receipt.state === "needs_review")) {
922
+ try { this.onInvocationReceipt?.(env.id, receipt.state); } catch { /* a ledger observer cannot undo a durable transport receipt */ }
923
+ }
924
+ if (state === "needs_review" && (!remoteId(peer) || before?.originals.some(isInvocationKind))) {
790
925
  this.recoveryHeldPeers.add(peer);
791
- for (const env of before?.originals ?? []) if (env.kind === "invocation") this.onInvocationUncertain?.(env.id);
926
+ for (const env of before?.originals ?? []) if (env.kind === "invocation") {
927
+ try { this.onInvocationUncertain?.(env.id); } catch { /* uncertainty hold remains durable */ }
928
+ }
792
929
  }
930
+ if (state === "completed" || state === "failed" || state === "needs_review" && remoteId(peer)) { this.refreshRecoveryHold(peer); void this.drain(peer); }
793
931
  this.onQueues?.();
794
932
  }
795
933
 
796
934
  private refreshRecoveryHold(peer: PeerId): void {
797
935
  if (!this.journal) return;
798
936
  // Live accepted notifications are awaiting explicit correlation, not crash recovery.
799
- const blocked = this.journal.list(peer).some((r) => r.state === "needs_review" || (["dispatching", "accepted"].includes(r.state) && this.activeDeliveries.get(r.id) !== peer));
937
+ const blocked = this.journal.list(peer).some((r) => (r.state === "needs_review" && (!remoteId(peer) || r.originals.some(isInvocationKind))) || (["dispatching", "accepted"].includes(r.state) && this.activeDeliveries.get(r.id) !== peer));
800
938
  if (blocked) this.recoveryHeldPeers.add(peer); else this.recoveryHeldPeers.delete(peer);
801
939
  }
802
940
 
@@ -865,14 +1003,13 @@ export class Bus {
865
1003
  }
866
1004
 
867
1005
  pause(id: PeerId): void {
1006
+ // Transient holds are not in BusSnapshot; their owners persist durable policy.
868
1007
  this.paused.add(id);
869
- this.persist();
870
1008
  this.emit({ t: "state", peer: id, state: this.stateOf(id) });
871
1009
  }
872
1010
 
873
1011
  resume(id: PeerId): void {
874
1012
  if (!this.paused.delete(id)) return;
875
- this.persist();
876
1013
  this.emit({ t: "state", peer: id, state: this.stateOf(id) });
877
1014
  void this.drain(id);
878
1015
  }
@@ -1040,9 +1177,9 @@ export class Bus {
1040
1177
  const index = queue.findIndex(item => item.id === env.id);
1041
1178
  if (index >= 0) queue.splice(index, 1);
1042
1179
  const known = this.remoteQueueExpires.get(peer)?.has(env.id);
1180
+ const failureCause: DeliveryFailureCause = known && this.remoteQueueRooms.has(peer) && this.remoteQueueMembers.has(peer) ? "timeout" : "push error";
1043
1181
  this.remoteQueueExpires.get(peer)?.delete(env.id);
1044
- this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "needs_review", createdAt: now, originals: [env], out: [env], reason: !known ? "remote queue expiry unavailable after restore" : !this.remoteQueueRooms.has(peer) ? "remote queue room binding unavailable after restore" : !this.remoteQueueMembers.has(peer) ? "remote queue member binding unavailable after restore" : "remote queue expired before recipient admission" });
1045
- this.recoveryHeldPeers.add(peer);
1182
+ this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "needs_review", createdAt: now, originals: [env], out: [env], failureCause, reason: !known ? "remote queue expiry unavailable after restore" : !this.remoteQueueRooms.has(peer) ? "remote queue room binding unavailable after restore" : !this.remoteQueueMembers.has(peer) ? "remote queue member binding unavailable after restore" : "remote queue expired before recipient admission" });
1046
1183
  }
1047
1184
  this.persist();
1048
1185
  } catch (error) {
@@ -1073,13 +1210,235 @@ export class Bus {
1073
1210
  return true;
1074
1211
  }
1075
1212
 
1213
+ /** Settle an active or held remote row only when the current room adapter owns this receipt path. */
1214
+ settleRemoteReceipt(source: PeerAdapter, deliveryId: string, outcome: "completed" | "failed_safe", reason: string, currentGuard: () => boolean): boolean {
1215
+ const peer = source.id;
1216
+ if ((outcome !== "completed" && outcome !== "failed_safe") || !parseRemotePeerId(peer) || this.peers.get(peer) !== source ||
1217
+ !source.remoteRoom || !remoteFingerprint(source.remoteMember) || !this.journal || this.storageError || this.closed) return false;
1218
+ if (this.remoteQueueRooms.get(peer) !== source.remoteRoom || this.remoteQueueMembers.get(peer) !== source.remoteMember) return false;
1219
+ const row = this.journal.get(deliveryId);
1220
+ if (!row || row.peer !== peer || !["needs_review", "dispatching", "accepted"].includes(row.state) || row.originals.length !== 1 || row.originals[0]?.to?.includes(peer) !== true || this.journal.resolution(deliveryId)) return false;
1221
+ if (row.state === "needs_review" && row.originals.some(isInvocationKind)) return false;
1222
+ const activeOwner = this.activeDeliveries.get(deliveryId);
1223
+ if (activeOwner !== undefined && activeOwner !== peer) return false;
1224
+ try { if (currentGuard() !== true) return false; } catch { return false; }
1225
+ try {
1226
+ if (outcome === "completed") this.journal.completeRemoteReceipt(row.id, peer, row.revision, reason);
1227
+ else this.journal.failRemoteReceipt(row.id, peer, row.revision, reason);
1228
+ } catch (error) {
1229
+ if (error instanceof Error && /stale delivery revision|already has a person disposition|not unresolved/.test(error.message)) return false;
1230
+ this.storageError = "delivery journal unavailable";
1231
+ this.onQueues?.();
1232
+ return false;
1233
+ }
1234
+ this.activeDeliveries.delete(row.id);
1235
+ this.remoteTransport.delete(row.id);
1236
+ for (const env of row.originals) {
1237
+ this.attempts.delete(`${peer}:${env.id}`);
1238
+ this.remoteQueueExpires.get(peer)?.delete(env.id);
1239
+ }
1240
+ if (row.state !== "needs_review") for (const env of row.originals) if (isInvocationKind(env)) {
1241
+ try { this.onInvocationReceipt?.(env.id, outcome); } catch { /* the durable room receipt remains settled */ }
1242
+ }
1243
+ this.refreshRecoveryHold(peer);
1244
+ this.emit({ t: "remote_delivery_settled", peer, deliveryId: row.id, outcome });
1245
+ this.onQueues?.();
1246
+ void this.drain(peer);
1247
+ return true;
1248
+ }
1249
+
1250
+ /** A typed room refusal before durable queue admission is safe to retry without failure accounting. */
1251
+ requeueRemoteNotSent(source: PeerAdapter, deliveryId: string): boolean {
1252
+ const peer = source.id;
1253
+ if (!parseRemotePeerId(peer) || this.peers.get(peer) !== source || !source.remoteRoom ||
1254
+ !remoteFingerprint(source.remoteMember) || !this.journal || this.storageError || this.closed ||
1255
+ this.remoteQueueRooms.get(peer) !== source.remoteRoom || this.remoteQueueMembers.get(peer) !== source.remoteMember ||
1256
+ this.activeDeliveries.get(deliveryId) !== peer) return false;
1257
+ const row = this.journal.get(deliveryId);
1258
+ if (!row || row.peer !== peer || row.state !== "dispatching" || row.originals.length !== 1) return false;
1259
+ const env = row.originals[0]!;
1260
+ if (env.to?.includes(peer) !== true) return false;
1261
+ const expiry = this.remoteQueueExpires.get(peer)?.get(env.id);
1262
+ if (expiry === undefined || expiry <= Date.now()) {
1263
+ this.deliveryReceipt(peer, { id: deliveryId, state: "needs_review", failureCause: expiry === undefined ? "push error" : "timeout", reason: expiry === undefined ? "remote queue expiry unavailable after restore" : "remote queue expired before recipient admission" });
1264
+ return true;
1265
+ }
1266
+ const queue = this.queues.get(peer) ?? [], beforeQueue = [...queue];
1267
+ if (queue.some(item => item.id === env.id)) return false;
1268
+ try {
1269
+ this.journal.transaction(() => {
1270
+ this.journal!.transition(deliveryId, "queued", "room transport confirmed not sent", row.revision);
1271
+ queue.unshift(env);
1272
+ this.queues.set(peer, queue);
1273
+ this.persist();
1274
+ });
1275
+ } catch {
1276
+ queue.splice(0, queue.length, ...beforeQueue);
1277
+ if (!beforeQueue.length && !this.queues.has(peer)) this.queues.delete(peer);
1278
+ this.storageError = "delivery journal unavailable";
1279
+ this.onQueues?.();
1280
+ return false;
1281
+ }
1282
+ this.activeDeliveries.delete(deliveryId);
1283
+ this.remoteTransport.delete(deliveryId);
1284
+ this.refreshRecoveryHold(peer);
1285
+ this.onQueues?.();
1286
+ return true;
1287
+ }
1288
+
1289
+ /** Host-only recovery port: expose one matching room original for proof revalidation. */
1290
+ remoteDeliveryEnvelope(source: PeerAdapter, deliveryId: string): Envelope | undefined {
1291
+ const peer = source.id;
1292
+ if (this.peers.get(peer) !== source || !parseRemotePeerId(peer) || !source.remoteRoom || !remoteFingerprint(source.remoteMember) ||
1293
+ this.remoteQueueRooms.get(peer) !== source.remoteRoom || this.remoteQueueMembers.get(peer) !== source.remoteMember ||
1294
+ !this.journal || this.storageError || this.closed) return undefined;
1295
+ const row = this.journal.get(deliveryId), original = row?.originals[0];
1296
+ if (!row || row.peer !== peer || !["needs_review", "dispatching", "accepted"].includes(row.state) || this.journal.resolution(deliveryId) || row.originals.length !== 1 || original?.to?.includes(peer) !== true) return undefined;
1297
+ return structuredClone(original);
1298
+ }
1299
+
1300
+ /** Current host admission for sending remote work, separate from immutable room trust proof. */
1301
+ remoteSendAllowed(source: PeerAdapter): boolean {
1302
+ const peer = source.id;
1303
+ if (!parseRemotePeerId(peer) || this.peers.get(peer) !== source || !source.remoteRoom || !remoteFingerprint(source.remoteMember) ||
1304
+ !this.journal || this.storageError || this.closed || this.recoveryHeld ||
1305
+ this.remoteQueueRooms.get(peer) !== source.remoteRoom || this.remoteQueueMembers.get(peer) !== source.remoteMember) return false;
1306
+ try {
1307
+ return this.isRostered(peer) && !this.paused.has(peer) && !this.manualPaused.has(peer) && this.opts.deliveryHeld?.(peer) !== true;
1308
+ } catch { return false; }
1309
+ }
1310
+
1311
+ private holdRemoteDeliveryMetadata(peer: PeerId, row: JournalDelivery, reason: string): boolean {
1312
+ try { this.journal!.transition(row.id, "needs_review", reason, row.revision, row.failureCause ?? "push error"); }
1313
+ catch (error) {
1314
+ if (error instanceof Error && /stale delivery revision|unknown delivery|already has a person disposition/.test(error.message)) return false;
1315
+ this.storageError = "delivery journal unavailable";
1316
+ this.onQueues?.();
1317
+ return false;
1318
+ }
1319
+ this.activeDeliveries.delete(row.id);
1320
+ if (this.remoteTransport.get(row.id)?.peer === peer) this.remoteTransport.delete(row.id);
1321
+ this.refreshRecoveryHold(peer);
1322
+ this.onQueues?.();
1323
+ void this.drain(peer);
1324
+ return true;
1325
+ }
1326
+
1327
+ /** Hold a remote row for human inspection when its durable intent or delivery proof is unavailable. */
1328
+ holdRemoteMissingIntent(source: PeerAdapter, deliveryId: string, currentGuard: () => boolean, origin: "restored" | "known-not-sent" | "proof-unavailable" = "restored"): boolean {
1329
+ if (origin !== "restored" && origin !== "known-not-sent" && origin !== "proof-unavailable") return false;
1330
+ const peer = source.id;
1331
+ if (!parseRemotePeerId(peer) || this.peers.get(peer) !== source || !source.remoteRoom || !remoteFingerprint(source.remoteMember) ||
1332
+ !this.journal || this.storageError || this.closed ||
1333
+ this.remoteQueueRooms.get(peer) !== source.remoteRoom || this.remoteQueueMembers.get(peer) !== source.remoteMember) return false;
1334
+ const row = this.journal.get(deliveryId);
1335
+ if (!row || row.peer !== peer || !["dispatching", "accepted", ...(origin === "proof-unavailable" ? ["needs_review"] : [])].includes(row.state) || row.originals.length !== 1 ||
1336
+ row.originals[0]?.to?.includes(peer) !== true || this.journal.resolution(deliveryId)) return false;
1337
+ if (origin === "proof-unavailable" && row.originals.some(isInvocationKind)) return false;
1338
+ if (origin === "known-not-sent") {
1339
+ if (this.activeDeliveries.get(deliveryId) !== peer) return false;
1340
+ } else if (origin === "restored") {
1341
+ if (this.activeDeliveries.has(deliveryId) || this.remoteTransport.has(deliveryId)) return false;
1342
+ } else {
1343
+ const activeOwner = this.activeDeliveries.get(deliveryId), transportOwner = this.remoteTransport.get(deliveryId)?.peer;
1344
+ if (activeOwner !== undefined && activeOwner !== peer || transportOwner !== undefined && transportOwner !== peer) return false;
1345
+ }
1346
+ try { if (currentGuard() !== true) return false; } catch { return false; }
1347
+ if (origin === "proof-unavailable" && row.state === "needs_review") return true;
1348
+ const reason = origin === "restored"
1349
+ ? `room durable intent unavailable after restart; inspect ahub queue show ${deliveryId}`
1350
+ : origin === "known-not-sent"
1351
+ ? `room not-sent checkpoint unavailable; inspect ahub queue show ${deliveryId}`
1352
+ : `${ROOM_PROOF_UNAVAILABLE_REASON} ${deliveryId}`;
1353
+ return this.holdRemoteDeliveryMetadata(peer, row, reason);
1354
+ }
1355
+
1356
+ /** Host-only quarantine when a signed room outcome belongs to an invalidated recipient tuple. */
1357
+ holdInvalidatedRemoteOutcome(peerId: string, deliveryId: string, expectedRoom: string, expectedMember: string, currentGuard: () => boolean): boolean {
1358
+ // Exact equality below inherits the room bound enforced at add/loadSnapshot.
1359
+ if (!parseRemotePeerId(peerId) || typeof expectedRoom !== "string" || !expectedRoom ||
1360
+ !remoteFingerprint(expectedMember) || !this.journal || this.storageError || this.closed ||
1361
+ this.remoteQueueRooms.get(peerId) !== expectedRoom || this.remoteQueueMembers.get(peerId) !== expectedMember) return false;
1362
+ const row = this.journal.get(deliveryId);
1363
+ if (!row || row.peer !== peerId || !["dispatching", "accepted"].includes(row.state) || row.originals.length !== 1 ||
1364
+ row.originals[0]?.to?.includes(peerId) !== true || this.journal.resolution(deliveryId) ||
1365
+ this.activeDeliveries.has(deliveryId) || this.remoteTransport.has(deliveryId)) return false;
1366
+ try { if (currentGuard() !== true) return false; } catch { return false; }
1367
+ return this.holdRemoteDeliveryMetadata(peerId, row, `room recipient proof no longer current after restart; inspect ahub queue show ${deliveryId}`);
1368
+ }
1369
+
1370
+ /** Resume the same delivery after the host revalidates its durable outbox proof. */
1371
+ resumeRemotePending(source: PeerAdapter, deliveryId: string, state: "dispatching" | "accepted" | "needs_review", currentGuard: () => boolean): boolean {
1372
+ const peer = source.id;
1373
+ const journal = this.journal;
1374
+ if (!journal || !this.remoteSendAllowed(source)) return false;
1375
+ const row = journal.get(deliveryId), env = row?.originals[0], expiry = env && this.remoteQueueExpires.get(peer)?.get(env.id);
1376
+ if (!row || row.peer !== peer || !["dispatching", "accepted", "needs_review"].includes(row.state) || row.originals.length !== 1 || env?.to?.includes(peer) !== true || journal.resolution(deliveryId) || !env || expiry === undefined || expiry <= Date.now()) return false;
1377
+ if (row.state === "needs_review" && row.originals.some(isInvocationKind)) return false;
1378
+ if (state === "needs_review") return false;
1379
+ if ([...(this.queues.get(peer) ?? [])].some(item => item.id === env.id)) return false;
1380
+ if ([...this.activeDeliveries].some(([id, owner]) => owner === peer && id !== deliveryId)) return false;
1381
+ if ([...this.remoteTransport].some(([id, active]) => active.peer === peer && id !== deliveryId)) return false;
1382
+ try { if (currentGuard() !== true) return false; } catch { return false; }
1383
+ try {
1384
+ // Store and journal commit independently. An already-active journal row is
1385
+ // authoritative; a different Store state must not strand or downgrade it.
1386
+ if (row.state === "needs_review") journal.transition(deliveryId, state, "room outbox proof confirmed pending", row.revision);
1387
+ }
1388
+ catch {
1389
+ this.storageError = "delivery journal unavailable";
1390
+ this.onQueues?.();
1391
+ return false;
1392
+ }
1393
+ this.activeDeliveries.set(deliveryId, peer);
1394
+ this.remoteTransport.set(deliveryId, { peer, originals: row.originals });
1395
+ this.refreshRecoveryHold(peer);
1396
+ this.onQueues?.();
1397
+ void this.drain(peer);
1398
+ return true;
1399
+ }
1400
+
1076
1401
  /** Returns the peers the envelope was accepted for, including durable deferred notices. */
1077
1402
  publish(env: Envelope): PeerId[] {
1078
1403
  return this.publishResult(env).targets;
1079
1404
  }
1080
1405
 
1406
+ private clearReviewProvenance(peer: PeerId): void {
1407
+ this.reviewParents.delete(peer);
1408
+ for (const [id, source] of this.reviewResponses) if (source.peer === peer) this.reviewResponses.delete(id);
1409
+ }
1410
+
1411
+ /** Host-derived observer provenance. Caller-supplied response/package markers are stripped.
1412
+ * ponytail: active turns and 128 delivered parent IDs per peer are attributable; arbitrary later
1413
+ * native-session paraphrases need explicit session provenance, never content matching or PII authority.
1414
+ */
1415
+ reviewResponse(env: Envelope, parentId?: string): Envelope {
1416
+ if (env.from === HUB) return env;
1417
+ const pulled = this.lastPulled.get(env.from)?.originals ?? [], pushed = this.lastDelivery.get(env.from)?.originals ?? [];
1418
+ const active = pulled.length ? pulled : pushed;
1419
+ const parent = parentId
1420
+ ? pulled.find(item => item.id === parentId && isReviewObserverExcluded(item)) ?? pushed.find(item => item.id === parentId && isReviewObserverExcluded(item))
1421
+ : active.find(isReviewObserverExcluded);
1422
+ const known = this.reviewResponses.get(env.id);
1423
+ let durableScope: string | undefined;
1424
+ try { durableScope = this.opts.reviewObserverScope?.(env.from); }
1425
+ catch { durableScope = "review-scope-unavailable"; } // Unknown provenance cannot clear an observer exclusion.
1426
+ const source = parent?.refs?.reviewPackage ?? parent?.refs?.reviewResponse
1427
+ ?? (parentId ? this.reviewParents.get(env.from)?.get(parentId) : undefined)
1428
+ ?? (known?.peer === env.from ? known.source : undefined) ?? durableScope;
1429
+ const { reviewPackage: _package, reviewResponse: _response, ...refs } = env.refs ?? {};
1430
+ if (source && (env.kind === "chat" || env.kind === "status")) {
1431
+ this.reviewResponses.set(env.id, { peer: env.from, source });
1432
+ if (this.reviewResponses.size > SEEN_CAP) this.reviewResponses.delete(this.reviewResponses.keys().next().value!);
1433
+ return { ...env, refs: { ...refs, reviewResponse: source } };
1434
+ }
1435
+ return _package !== undefined || _response !== undefined ? { ...env, refs } : env;
1436
+ }
1437
+
1081
1438
  /** Publication outcome distinguishes a policy drop from an empty audience. */
1082
1439
  publishResult(env: Envelope): PublishResult {
1440
+ if (this.closed) return { targets: [] };
1441
+ env = this.reviewResponse(env);
1083
1442
  if (this.storageError) throw new Error("delivery journal unavailable");
1084
1443
  if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return { targets: [] };
1085
1444
  this.checkRemotePublication(env);
@@ -1096,6 +1455,7 @@ export class Bus {
1096
1455
  }
1097
1456
 
1098
1457
  private publishNow(env: Envelope): PublishResult {
1458
+ if (this.closed) return { targets: [] };
1099
1459
  if (this.storageError) throw new Error("delivery journal unavailable");
1100
1460
  if (this.seen.has(env.id)) return { targets: [] };
1101
1461
  const rooms = this.checkRemotePublication(env);
@@ -1118,14 +1478,17 @@ export class Bus {
1118
1478
  if (!peer) { this.enqueue(id, env); continue; }
1119
1479
  if (!this.journal && !this.held(id) && env.priority === "important" && !remoteId(id) && peer.steer && this.stateOf(id) === "busy" && !this.invocationTurnOpen(id)) {
1120
1480
  // Not queued while the steer is in flight, or an idle transition would deliver it a second time.
1481
+ try { this.beforeReviewInputHandoff(id, [env]); }
1482
+ catch { this.enqueue(id, env); continue; }
1121
1483
  this.steering++;
1122
1484
  const deliveryId = crypto.randomUUID();
1123
1485
  this.activeDeliveries.set(deliveryId, id);
1124
1486
  if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
1487
+ this.noteSteering(id, [env]);
1125
1488
  const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
1126
1489
  steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
1127
1490
  this.deliveryFailed(id);
1128
- if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
1491
+ if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain", undefined, "push error");
1129
1492
  this.activeDeliveries.delete(deliveryId);
1130
1493
  if (!this.journal) this.enqueue(id, env, true);
1131
1494
  }).finally(() => { this.steering--; });
@@ -1179,6 +1542,45 @@ export class Bus {
1179
1542
  get(id: string): Envelope | undefined {
1180
1543
  return this.seen.get(id) ?? this.deferredHub.get(id);
1181
1544
  }
1545
+ /** Only newly saved versioned review packages may interpret absent as permission for explicit publication. */
1546
+ reviewEnvelopeStatus(id: string): "published" | "absent" | "unknown" | "failed" | "discarded" {
1547
+ if (this.closed || this.storageError || !this.journal) return "unknown";
1548
+ try {
1549
+ const state = this.journal.reviewEnvelopeState(id);
1550
+ if (state === "failed" || state === "discarded") return state;
1551
+ return state ? "published" : "absent";
1552
+ }
1553
+ catch { return "unknown"; }
1554
+ }
1555
+ hasUnsettledReviewScope(peer: PeerId, task?: number): boolean {
1556
+ if (this.closed || this.storageError) return true;
1557
+ if (!this.journal) return false; // Ephemeral test buses retain the existing delivered-parent scope.
1558
+ try {
1559
+ const revision = this.journal.revision();
1560
+ if (revision !== this.reviewScopeRevision) { this.reviewScopeCache.clear(); this.reviewScopeRevision = revision; }
1561
+ const key = JSON.stringify([peer, task ?? null]), cached = this.reviewScopeCache.get(key);
1562
+ if (cached !== undefined) return cached;
1563
+ const held = this.journal.hasUnsettledReviewScope(peer, task);
1564
+ this.reviewScopeCache.set(key, held);
1565
+ if (this.reviewScopeCache.size > 512) this.reviewScopeCache.delete(this.reviewScopeCache.keys().next().value!);
1566
+ return held;
1567
+ } catch { return true; }
1568
+ }
1569
+ /** Explicit recovery reuses the immutable package envelope; a durable publication is never replayed. */
1570
+ publishReviewEnvelope(env: Envelope): PeerId[] {
1571
+ if (env.from !== HUB || env.kind !== "review" || !env.refs?.reviewPackage || !env.to?.length) throw new Error("review package envelope is invalid; refresh the saved package before retrying");
1572
+ const status = this.reviewEnvelopeStatus(env.id);
1573
+ if (status === "published") {
1574
+ this.journal!.reviewEnvelopePublished(env.id, env);
1575
+ return [...env.to];
1576
+ }
1577
+ if (status === "failed" || status === "discarded")
1578
+ throw new Error(`saved review package delivery is ${status}; inspect ahub queue list --peer ${env.to[0]} and ahub queue show <delivery-id> before retrying`);
1579
+ if (status !== "absent") throw new Error("review package publication evidence is unavailable; inspect ahub queue list before retrying");
1580
+ // A pre-checkpoint observer may have thrown after remembering the id but before any durable enqueue or native effect.
1581
+ this.seen.delete(env.id);
1582
+ return this.publish(env);
1583
+ }
1182
1584
 
1183
1585
  /**
1184
1586
  * #445 C2: whether the peer's current turn is one that holds an invocation request. With a journal that is a delivery of the peer's own that has not reached a
@@ -1187,7 +1589,7 @@ export class Bus {
1187
1589
  * everything its turn holds, and a steered one would become what the answer names instead of the request.
1188
1590
  */
1189
1591
  private invocationTurnOpen(peer: PeerId): boolean {
1190
- if (!this.journal) return this.stateOf(peer) === "busy" && (this.lastDelivery.get(peer)?.out ?? []).some((e) => e.kind === "invocation");
1592
+ if (!this.journal) return this.stateOf(peer) === "busy" && [...(this.lastDelivery.get(peer)?.out ?? []), ...(this.lastPulled.get(peer)?.out ?? [])].some((e) => e.kind === "invocation");
1191
1593
  for (const [deliveryId, owner] of this.activeDeliveries) {
1192
1594
  if (owner === peer && this.journal.get(deliveryId)?.out.some((e) => e.kind === "invocation")) return true;
1193
1595
  }
@@ -1196,9 +1598,12 @@ export class Bus {
1196
1598
 
1197
1599
  /** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
1198
1600
  lastDeliveredTo(peer: PeerId): Envelope[] {
1199
- return this.lastDelivery.get(peer)?.out ?? [];
1601
+ return this.lastPulled.get(peer)?.out ?? this.lastDelivery.get(peer)?.out ?? [];
1200
1602
  }
1201
1603
 
1604
+ /** A confirmed new native session invalidates only this session's current-input cache; durable named-parent rows remain. */
1605
+ resetLastDelivery(peer: PeerId): void { this.lastDelivery.delete(peer); this.lastPulled.delete(peer); }
1606
+
1202
1607
  /**
1203
1608
  * Take back an envelope that has not been delivered yet (a request that has expired). True when it was removed from a
1204
1609
  * queue now; an envelope whose steer is still in flight is not in any queue, so its id is remembered and it is
@@ -1254,11 +1659,21 @@ export class Bus {
1254
1659
  }
1255
1660
 
1256
1661
  private memoryDelivery(peer: PeerId, id: string, out: Envelope[], field: "rendered" | "delivered", originals: Envelope[] = out): void {
1257
- if (remoteId(peer) || [...out, ...originals].some(env => env.private || remoteId(env.from) || env.to?.some(remoteId))) return;
1662
+ if (remoteId(peer) || this.opts.reviewObserverInputExcluded?.(peer, originals) === true || [...out, ...originals].some(env => isReviewObserverExcluded(env) || env.private || remoteId(env.from) || env.to?.some(remoteId))) return;
1258
1663
  try { this.onMemoryDelivery?.(peer, id, out, field); } catch { /* optional telemetry never alters delivery */ }
1259
1664
  }
1260
1665
 
1666
+ private rememberReviewParents(peer: PeerId, originals: Envelope[], requireAttached = true): void {
1667
+ for (const env of originals) if ((!requireAttached || !this.closed && this.peers.has(peer) && this.stateOf(peer) !== "offline") && isReviewObserverExcluded(env)) {
1668
+ const parents = this.reviewParents.get(peer) ?? new Map<string, string>();
1669
+ parents.set(env.id, (env.refs?.reviewPackage ?? env.refs?.reviewResponse)!);
1670
+ if (parents.size > 128) parents.delete(parents.keys().next().value!);
1671
+ this.reviewParents.set(peer, parents);
1672
+ }
1673
+ }
1674
+
1261
1675
  private delivered(peer: PeerId, originals: Envelope[], deliveryId?: string): void {
1676
+ this.rememberReviewParents(peer, originals);
1262
1677
  try { this.onDelivered?.(peer, originals, deliveryId); } catch { /* observation must never affect delivery settlement */ }
1263
1678
  }
1264
1679
 
@@ -1266,6 +1681,15 @@ export class Bus {
1266
1681
  try { this.onDeliveryFailed?.(peer); } catch { /* observation must never affect delivery settlement */ }
1267
1682
  }
1268
1683
 
1684
+ private noteSteering(peer: PeerId, originals: Envelope[]): void {
1685
+ try { this.onSteering?.(peer, originals); } catch { /* metadata observation cannot alter the steer attempt */ }
1686
+ }
1687
+
1688
+ private beforeReviewInputHandoff(peer: PeerId, originals: Envelope[]): void {
1689
+ try { this.opts.beforeReviewInputHandoff?.(peer, originals); }
1690
+ catch { throw new ReviewInputHandoffError(); }
1691
+ }
1692
+
1269
1693
  private markWithdrawn(id: string): void {
1270
1694
  this.withdrawn.add(id);
1271
1695
  if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
@@ -1380,7 +1804,7 @@ export class Bus {
1380
1804
  if (!remoteId(id) && queue.length > this.opts.queueCap) {
1381
1805
  const victim = queue.findIndex((e) => e.priority !== "important");
1382
1806
  const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
1383
- if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "failed", createdAt: Date.now(), originals: [lost!], out: [], reason: "queue capacity exceeded" });
1807
+ if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "failed", createdAt: Date.now(), originals: [lost!], out: [], reason: "queue capacity exceeded", failureCause: "adapter refused" });
1384
1808
  this.emit({ t: "overflow", env: lost!, peer: id });
1385
1809
  }
1386
1810
  if (remoteId(id) && !front) {
@@ -1410,9 +1834,9 @@ export class Bus {
1410
1834
  return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
1411
1835
  }
1412
1836
 
1413
- private uncertain(peer: PeerId, deliveryId: string, reason: string): void {
1837
+ private uncertain(peer: PeerId, deliveryId: string, reason: string, failureCause: DeliveryFailureCause = "push error"): void {
1414
1838
  const row = this.journal?.get(deliveryId);
1415
- if (row && ["dispatching", "accepted"].includes(row.state)) this.deliveryReceipt(peer, { id: deliveryId, state: "needs_review", reason });
1839
+ if (row && ["dispatching", "accepted"].includes(row.state)) this.deliveryReceipt(peer, { id: deliveryId, state: "needs_review", reason, failureCause });
1416
1840
  }
1417
1841
 
1418
1842
  private async steerQueued(id: PeerId, env: Envelope): Promise<void> {
@@ -1428,16 +1852,18 @@ export class Bus {
1428
1852
  let removed = false, handedOff = false;
1429
1853
  try {
1430
1854
  const out = this.retryOut(id, [env], [env]);
1855
+ this.beforeReviewInputHandoff(id, [env]);
1431
1856
  queue.splice(index, 1);
1432
1857
  removed = true;
1433
1858
  this.durableHandoff(id, deliveryId, [env], out);
1434
1859
  handedOff = true;
1435
1860
  this.activeDeliveries.set(deliveryId, id);
1861
+ this.noteSteering(id, [env]);
1436
1862
  try { await peer.steer(out, deliveryId); }
1437
1863
  catch { this.uncertain(id, deliveryId, "steering outcome is uncertain"); }
1438
- } catch {
1864
+ } catch (error) {
1439
1865
  if (removed && !handedOff) queue.splice(index, 0, env);
1440
- this.storageError = "delivery journal unavailable";
1866
+ if (!(error instanceof ReviewInputHandoffError)) this.storageError = "delivery journal unavailable";
1441
1867
  } finally { this.steering--; this.onQueues?.(); }
1442
1868
  }
1443
1869
 
@@ -1488,6 +1914,7 @@ export class Bus {
1488
1914
  const deliveryId = crypto.randomUUID();
1489
1915
  try {
1490
1916
  out = this.retryOut(id, batch, batch);
1917
+ this.beforeReviewInputHandoff(id, batch);
1491
1918
  this.prefaces.delete(id);
1492
1919
  // Before the checkpoint, as in a drain, so the persisted snapshot carries it; a failed policy read rolls back too.
1493
1920
  this.remember(out);
@@ -1498,15 +1925,18 @@ export class Bus {
1498
1925
  } catch (error) {
1499
1926
  queue.splice(0, queue.length, ...queued);
1500
1927
  if (preface) this.prefaces.set(id, preface);
1501
- this.storageError = "delivery journal unavailable";
1928
+ if (!(error instanceof ReviewInputHandoffError)) this.storageError = "delivery journal unavailable";
1502
1929
  throw error;
1503
1930
  }
1504
1931
  if (this.journal) {
1505
1932
  this.memoryDelivery(id, deliveryId, out, "rendered", batch);
1506
1933
  this.memoryDelivery(id, deliveryId, out, "delivered", batch);
1507
1934
  }
1935
+ this.lastPulled.set(id, { out, originals: batch });
1936
+ this.rememberReviewParents(id, batch, false);
1937
+ if (batch.length) { try { this.onPulled?.(id, batch); } catch { /* metadata observation cannot change a committed inbox result */ } }
1508
1938
  this.failureStreak.delete(id); this.lastFailure.delete(id); // read, as a completed push receipt clears them
1509
- this.onQueues?.();
1939
+ try { this.onQueues?.(); } catch { /* a committed inbox result must still reach its caller */ }
1510
1940
  return out;
1511
1941
  }
1512
1942
 
@@ -1558,6 +1988,7 @@ export class Bus {
1558
1988
  const deliveryId = crypto.randomUUID();
1559
1989
  if (this.journal) {
1560
1990
  if ((!remoteId(id) && this.prefaces.get(id) !== preface) || batch.some((e) => !queue.some((item) => item.id === e.id))) continue;
1991
+ this.beforeReviewInputHandoff(id, delivery);
1561
1992
  const queued = [...queue], seen = [...this.seen];
1562
1993
  for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
1563
1994
  this.prefaces.delete(id);
@@ -1571,7 +2002,16 @@ export class Bus {
1571
2002
  throw error;
1572
2003
  }
1573
2004
  this.activeDeliveries.set(deliveryId, id);
1574
- } else this.remember(out);
2005
+ } else {
2006
+ try { this.beforeReviewInputHandoff(id, delivery); }
2007
+ catch (error) {
2008
+ if (preface) this.restorePreface(id, preface);
2009
+ queue.unshift(...batch.filter(e => !this.withdrawn.has(e.id)));
2010
+ throw error;
2011
+ }
2012
+ this.remember(out);
2013
+ }
2014
+ this.lastPulled.delete(id);
1575
2015
  this.lastDelivery.set(id, { out, originals: delivery });
1576
2016
  for (const env of delivery) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, deliveryId);
1577
2017
  if (remoteId(id)) this.remoteTransport.set(deliveryId, { peer: id, originals: delivery });
@@ -1594,7 +2034,7 @@ export class Bus {
1594
2034
  break;
1595
2035
  } finally { this.remoteTransport.delete(deliveryId); }
1596
2036
  }
1597
- } catch { this.storageError = "delivery journal unavailable"; }
2037
+ } catch (error) { if (!(error instanceof ReviewInputHandoffError)) this.storageError = "delivery journal unavailable"; }
1598
2038
  finally { this.preparing.delete(id); this.draining.delete(id); this.onQueues?.(); }
1599
2039
  }
1600
2040