@cello-protocol/daemon 0.0.181 → 0.0.183

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 (197) hide show
  1. package/dist/agent-id-migration.d.ts +9 -0
  2. package/dist/agent-id-migration.d.ts.map +1 -1
  3. package/dist/agent-id-migration.js +73 -1
  4. package/dist/agent-id-migration.js.map +1 -1
  5. package/dist/agent-selection.d.ts +73 -0
  6. package/dist/agent-selection.d.ts.map +1 -0
  7. package/dist/agent-selection.js +80 -0
  8. package/dist/agent-selection.js.map +1 -0
  9. package/dist/agent-settings-keys.d.ts.map +1 -1
  10. package/dist/agent-settings-keys.js +18 -0
  11. package/dist/agent-settings-keys.js.map +1 -1
  12. package/dist/assignment-verify.d.ts +62 -0
  13. package/dist/assignment-verify.d.ts.map +1 -0
  14. package/dist/assignment-verify.js +185 -0
  15. package/dist/assignment-verify.js.map +1 -0
  16. package/dist/backup-restore.d.ts +102 -0
  17. package/dist/backup-restore.d.ts.map +1 -0
  18. package/dist/backup-restore.js +340 -0
  19. package/dist/backup-restore.js.map +1 -0
  20. package/dist/bin/cello-daemon.js +91 -49
  21. package/dist/bin/cello-daemon.js.map +1 -1
  22. package/dist/close-commitment.d.ts +67 -0
  23. package/dist/close-commitment.d.ts.map +1 -0
  24. package/dist/close-commitment.js +78 -0
  25. package/dist/close-commitment.js.map +1 -0
  26. package/dist/close-session-handler.d.ts +20 -0
  27. package/dist/close-session-handler.d.ts.map +1 -1
  28. package/dist/close-session-handler.js +351 -58
  29. package/dist/close-session-handler.js.map +1 -1
  30. package/dist/consortium-bootstrap.d.ts +2 -0
  31. package/dist/consortium-bootstrap.d.ts.map +1 -1
  32. package/dist/consortium-bootstrap.js +182 -9
  33. package/dist/consortium-bootstrap.js.map +1 -1
  34. package/dist/content-encryption-status.d.ts +97 -0
  35. package/dist/content-encryption-status.d.ts.map +1 -0
  36. package/dist/content-encryption-status.js +122 -0
  37. package/dist/content-encryption-status.js.map +1 -0
  38. package/dist/content-park-client.d.ts +10 -0
  39. package/dist/content-park-client.d.ts.map +1 -1
  40. package/dist/content-park-client.js +84 -3
  41. package/dist/content-park-client.js.map +1 -1
  42. package/dist/content-park.d.ts.map +1 -1
  43. package/dist/content-park.js +323 -21
  44. package/dist/content-park.js.map +1 -1
  45. package/dist/daemon.d.ts +13 -1
  46. package/dist/daemon.d.ts.map +1 -1
  47. package/dist/daemon.js +1168 -141
  48. package/dist/daemon.js.map +1 -1
  49. package/dist/delivery-session-suspects.d.ts.map +1 -1
  50. package/dist/delivery-session-suspects.js +5 -0
  51. package/dist/delivery-session-suspects.js.map +1 -1
  52. package/dist/directory-auth-posture.d.ts +87 -0
  53. package/dist/directory-auth-posture.d.ts.map +1 -0
  54. package/dist/directory-auth-posture.js +134 -0
  55. package/dist/directory-auth-posture.js.map +1 -0
  56. package/dist/directory-bootstrap.d.ts +66 -3
  57. package/dist/directory-bootstrap.d.ts.map +1 -1
  58. package/dist/directory-bootstrap.js +133 -7
  59. package/dist/directory-bootstrap.js.map +1 -1
  60. package/dist/document-delivery-transport.d.ts +22 -2
  61. package/dist/document-delivery-transport.d.ts.map +1 -1
  62. package/dist/document-delivery-transport.js +11 -3
  63. package/dist/document-delivery-transport.js.map +1 -1
  64. package/dist/document-handlers.d.ts.map +1 -1
  65. package/dist/document-handlers.js +119 -0
  66. package/dist/document-handlers.js.map +1 -1
  67. package/dist/document-layer.d.ts +0 -2
  68. package/dist/document-layer.d.ts.map +1 -1
  69. package/dist/document-layer.js.map +1 -1
  70. package/dist/inbound-sessions.d.ts +31 -1
  71. package/dist/inbound-sessions.d.ts.map +1 -1
  72. package/dist/inbound-sessions.js +276 -16
  73. package/dist/inbound-sessions.js.map +1 -1
  74. package/dist/index.d.ts +2 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +3 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/initiate-session-handler.d.ts.map +1 -1
  79. package/dist/initiate-session-handler.js +34 -3
  80. package/dist/initiate-session-handler.js.map +1 -1
  81. package/dist/ipc-server.d.ts +5 -1
  82. package/dist/ipc-server.d.ts.map +1 -1
  83. package/dist/ipc-server.js +19 -3
  84. package/dist/ipc-server.js.map +1 -1
  85. package/dist/manifest-deps.d.ts +0 -11
  86. package/dist/manifest-deps.d.ts.map +1 -1
  87. package/dist/manifest-deps.js +28 -2
  88. package/dist/manifest-deps.js.map +1 -1
  89. package/dist/manifest-validity.d.ts +153 -0
  90. package/dist/manifest-validity.d.ts.map +1 -0
  91. package/dist/manifest-validity.js +268 -0
  92. package/dist/manifest-validity.js.map +1 -0
  93. package/dist/notification-handlers.d.ts.map +1 -1
  94. package/dist/notification-handlers.js +15 -0
  95. package/dist/notification-handlers.js.map +1 -1
  96. package/dist/outbound-sessions.d.ts +31 -0
  97. package/dist/outbound-sessions.d.ts.map +1 -1
  98. package/dist/outbound-sessions.js +217 -17
  99. package/dist/outbound-sessions.js.map +1 -1
  100. package/dist/park-envelope.d.ts +95 -1
  101. package/dist/park-envelope.d.ts.map +1 -1
  102. package/dist/park-envelope.js +195 -4
  103. package/dist/park-envelope.js.map +1 -1
  104. package/dist/refusal-reasons.d.ts +72 -0
  105. package/dist/refusal-reasons.d.ts.map +1 -0
  106. package/dist/refusal-reasons.js +69 -0
  107. package/dist/refusal-reasons.js.map +1 -0
  108. package/dist/register-handler.d.ts.map +1 -1
  109. package/dist/register-handler.js +76 -0
  110. package/dist/register-handler.js.map +1 -1
  111. package/dist/registration-manager.d.ts.map +1 -1
  112. package/dist/registration-manager.js +20 -1
  113. package/dist/registration-manager.js.map +1 -1
  114. package/dist/relay-only.d.ts +133 -0
  115. package/dist/relay-only.d.ts.map +1 -0
  116. package/dist/relay-only.js +183 -0
  117. package/dist/relay-only.js.map +1 -0
  118. package/dist/restart-seal-resolver.d.ts.map +1 -1
  119. package/dist/restart-seal-resolver.js +6 -0
  120. package/dist/restart-seal-resolver.js.map +1 -1
  121. package/dist/retry-queue.d.ts +49 -2
  122. package/dist/retry-queue.d.ts.map +1 -1
  123. package/dist/retry-queue.js +55 -10
  124. package/dist/retry-queue.js.map +1 -1
  125. package/dist/roster-freshness.d.ts +169 -0
  126. package/dist/roster-freshness.d.ts.map +1 -0
  127. package/dist/roster-freshness.js +249 -0
  128. package/dist/roster-freshness.js.map +1 -0
  129. package/dist/seal-certificate-pull.d.ts +17 -1
  130. package/dist/seal-certificate-pull.d.ts.map +1 -1
  131. package/dist/seal-certificate-pull.js +37 -3
  132. package/dist/seal-certificate-pull.js.map +1 -1
  133. package/dist/seal-coordinator.d.ts +17 -1
  134. package/dist/seal-coordinator.d.ts.map +1 -1
  135. package/dist/seal-coordinator.js +71 -0
  136. package/dist/seal-coordinator.js.map +1 -1
  137. package/dist/seal-failure-store.d.ts +104 -0
  138. package/dist/seal-failure-store.d.ts.map +1 -0
  139. package/dist/seal-failure-store.js +126 -0
  140. package/dist/seal-failure-store.js.map +1 -0
  141. package/dist/seal-flows.d.ts.map +1 -1
  142. package/dist/seal-flows.js +35 -6
  143. package/dist/seal-flows.js.map +1 -1
  144. package/dist/seal-upgrade.d.ts +20 -5
  145. package/dist/seal-upgrade.d.ts.map +1 -1
  146. package/dist/seal-upgrade.js +28 -5
  147. package/dist/seal-upgrade.js.map +1 -1
  148. package/dist/session-assignment-parser.d.ts +41 -5
  149. package/dist/session-assignment-parser.d.ts.map +1 -1
  150. package/dist/session-assignment-parser.js +77 -14
  151. package/dist/session-assignment-parser.js.map +1 -1
  152. package/dist/session-ceremony.d.ts +35 -0
  153. package/dist/session-ceremony.d.ts.map +1 -1
  154. package/dist/session-ceremony.js +97 -0
  155. package/dist/session-ceremony.js.map +1 -1
  156. package/dist/session-connection-gater.d.ts +46 -7
  157. package/dist/session-connection-gater.d.ts.map +1 -1
  158. package/dist/session-connection-gater.js +163 -12
  159. package/dist/session-connection-gater.js.map +1 -1
  160. package/dist/session-content-handlers.d.ts +12 -0
  161. package/dist/session-content-handlers.d.ts.map +1 -1
  162. package/dist/session-content-handlers.js +149 -8
  163. package/dist/session-content-handlers.js.map +1 -1
  164. package/dist/session-node-manager.d.ts +653 -40
  165. package/dist/session-node-manager.d.ts.map +1 -1
  166. package/dist/session-node-manager.js +5938 -374
  167. package/dist/session-node-manager.js.map +1 -1
  168. package/dist/session-read-handlers.d.ts +27 -0
  169. package/dist/session-read-handlers.d.ts.map +1 -1
  170. package/dist/session-read-handlers.js +127 -6
  171. package/dist/session-read-handlers.js.map +1 -1
  172. package/dist/session-relay-client.d.ts +177 -2
  173. package/dist/session-relay-client.d.ts.map +1 -1
  174. package/dist/session-relay-client.js +564 -16
  175. package/dist/session-relay-client.js.map +1 -1
  176. package/dist/session-salt-agreement.d.ts +338 -0
  177. package/dist/session-salt-agreement.d.ts.map +1 -0
  178. package/dist/session-salt-agreement.js +478 -0
  179. package/dist/session-salt-agreement.js.map +1 -0
  180. package/dist/signal-submission.d.ts.map +1 -1
  181. package/dist/signal-submission.js +10 -1
  182. package/dist/signal-submission.js.map +1 -1
  183. package/dist/signaling-connect.d.ts +18 -1
  184. package/dist/signaling-connect.d.ts.map +1 -1
  185. package/dist/signaling-connect.js +206 -6
  186. package/dist/signaling-connect.js.map +1 -1
  187. package/dist/types.d.ts +75 -0
  188. package/dist/types.d.ts.map +1 -1
  189. package/dist/types.js.map +1 -1
  190. package/dist/vocabulary.d.ts.map +1 -1
  191. package/dist/vocabulary.js +10 -0
  192. package/dist/vocabulary.js.map +1 -1
  193. package/dist/wire-content-hash.d.ts +68 -0
  194. package/dist/wire-content-hash.d.ts.map +1 -1
  195. package/dist/wire-content-hash.js +83 -0
  196. package/dist/wire-content-hash.js.map +1 -1
  197. package/package.json +5 -5
package/dist/daemon.js CHANGED
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import { mkdir } from "node:fs/promises";
29
29
  import { randomUUID } from "node:crypto";
30
+ import { AsyncLocalStorage } from "node:async_hooks";
30
31
  import { dirname, join } from "node:path";
31
32
  import { loadAgents } from "./agent-loader.js";
32
33
  import { RestartSealResolver } from "./restart-seal-resolver.js";
@@ -35,13 +36,18 @@ import { acquireLock, removeLockIfOwned } from "./lock-file.js";
35
36
  import { acquireSingletonLock } from "./singleton-lock.js";
36
37
  import { createIpcServer } from "./ipc-server.js";
37
38
  import { renderForSurface } from "./vocabulary.js";
39
+ import { RandomizedPollScheduler } from "./manifest-poll-scheduler.js";
40
+ import { startManifestValidityWatch, classifyManifestValidity, describeManifestValidity } from "./manifest-validity.js";
41
+ import { describeDirectoryAuth, directoryAuthRequired } from "./directory-auth-posture.js";
42
+ import { SealFailureStore } from "./seal-failure-store.js";
43
+ import { startRosterSweep, classifyRosterReading, describeRosterFreshness, ROSTER_SWEEP_INTERVAL_MS, } from "./roster-freshness.js";
38
44
  import { SessionNodeManager, REVIVAL_WINDOW_MS, REVIVAL_BOUND_SWEEP_MS } from "./session-node-manager.js";
39
45
  import { registerGatewayConfigHandlers } from "./gateway-config-handlers.js";
40
46
  import { RetryQueue } from "./retry-queue.js";
41
47
  import { NonceDedupStore } from "./nonce-dedup.js";
42
48
  import { ContentParkClient } from "./content-park-client.js";
43
49
  import { NotificationDispatcher } from "./notification-dispatcher.js";
44
- import { createNode, SignalingManager } from "@cello-protocol/transport";
50
+ import { createNode, SignalingManager, } from "@cello-protocol/transport";
45
51
  import { createSignalingConnect } from "./signaling-connect.js";
46
52
  import { DbRegistrationPersistence, DbIdentityStore } from "./db-identity-store.js";
47
53
  import { DbManifestVersionStore } from "./manifest-version-store-db.js";
@@ -55,7 +61,7 @@ const MAX_SUBMISSION_BODY_CHARS = 4000;
55
61
  // CELLO-M7-MSG-001 (AC-013/AC-018): the single application content-size cap, enforced
56
62
  // at the send point here (the receive point lives in the transport content decode).
57
63
  import { sealParkEnvelope } from "./park-envelope.js";
58
- import { AgentRelayClient, extractErrorMessage } from "./session-relay-client.js";
64
+ import { LEAF_KIND_MSG, AgentRelayClient, extractErrorMessage } from "./session-relay-client.js";
59
65
  import { createReconnectDrain } from "./reconnect-drain.js";
60
66
  import { resolveCelloEnv, createTransportSelector, isProductionVariant, } from "./transport-composition.js";
61
67
  import { whoLabel } from "./who-label.js";
@@ -66,12 +72,15 @@ import { resolveDirectoryUrl } from "./directory-bootstrap.js";
66
72
  import { registerContactHandlers } from "./contact-handlers.js";
67
73
  import { createSealCoordinator } from "./seal-coordinator.js";
68
74
  import { createTelegramDoorbell } from "./telegram-doorbell.js";
69
- import { registerSessionContentHandlers } from "./session-content-handlers.js";
75
+ import { registerSessionContentHandlers, sentAuthorship } from "./session-content-handlers.js";
70
76
  import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
71
77
  import { isDocumentFrame } from "./document-frame-router.js";
72
78
  import { INBOUND_INJECTION_BLOCKED } from "@cello-protocol/gateway";
73
79
  import { registerDocumentHandlers } from "./document-handlers.js";
74
- import { wireContentHash } from "./wire-content-hash.js";
80
+ // `wireContentHash` is no longer imported here: every outbound hash in this file now comes from
81
+ // `SessionNodeManager.contentHashForSession`, which returns the hash and its ALGORITHM together
82
+ // (`DOD-M15-SEALWIRE-1` part B2b). A direct call would be a hash computed without deciding — or
83
+ // recording — how it was made, which is the state that made a version skew look like a tamper.
75
84
  import { DocumentPublish } from "./document-publish.js";
76
85
  import { createDocumentDeliveryTransport } from "./document-delivery-transport.js";
77
86
  import { ReconcileScheduler } from "./document-reconcile-scheduler.js";
@@ -81,6 +90,9 @@ import { createInboundSessions } from "./inbound-sessions.js";
81
90
  import { createOutboundSessions } from "./outbound-sessions.js";
82
91
  import { registerSessionReadHandlers } from "./session-read-handlers.js";
83
92
  import { pullSealCertificate } from "./seal-certificate-pull.js";
93
+ import { createBackup, inspectBackup } from "./backup-restore.js";
94
+ import { resolveCurrentAgentFor } from "./agent-selection.js";
95
+ import { REFUSAL_REASONS, CAPACITY_REASONS } from "./refusal-reasons.js";
84
96
  import { revocabilityOf } from "./signal-revocability.js";
85
97
  import { registerAgentHandlers } from "./agent-handlers.js";
86
98
  import { registerRegisterHandler } from "./register-handler.js";
@@ -98,6 +110,7 @@ import { countAttendance, ContentTakeLedger } from "./co-attendance.js";
98
110
  import { isOwnAwayAutoReply, AWAY_AUTO_REPLY_TEXTS, markAsAutoReply, isAutoReplyMarked } from "./away-detection.js";
99
111
  import { createDeliveryOpenRegistry } from "./delivery-open-registry.js";
100
112
  import { FrontierMismatchStore, renderFrontierMismatch } from "./frontier-mismatch.js";
113
+ import { relayOnlyState } from "./relay-only.js";
101
114
  import { decodeCbor } from "@cello-protocol/protocol-types";
102
115
  // Minimal no-op KeyProvider stub for session nodes.
103
116
  // Session nodes don't need signing keys — libp2p generates its own fresh
@@ -109,6 +122,20 @@ const SESSION_NODE_KEY_STUB = {
109
122
  };
110
123
  // Production session node factory — wraps createNode from @cello-protocol/transport
111
124
  export class ProductionSessionNodeFactory {
125
+ logger;
126
+ /**
127
+ * DOD-M15-IDLE-CONNS-1 — OPTIONAL, and optional for one reason only: this factory is constructed
128
+ * with no arguments in four existing tests, and requiring a logger would turn them into type
129
+ * errors for a unit that has nothing to do with them.
130
+ *
131
+ * The cost is stated rather than hidden: with no logger, a reaped connection is silent, which is
132
+ * exactly the "guard nobody hears" this milestone has found four times. `startDaemon` passes one
133
+ * — that is the production path — and the sweep only runs on nodes this factory builds as
134
+ * receivers, so the silent case is a test-only fixture.
135
+ */
136
+ constructor(logger) {
137
+ this.logger = logger;
138
+ }
112
139
  async createNode(config) {
113
140
  // DOD-NAT-REACHABILITY-1: the STANDING RECEIVER — the node that accepts every
114
141
  // inbound session — must bind a ROUTABLE interface by default. The old
@@ -136,15 +163,121 @@ export class ProductionSessionNodeFactory {
136
163
  listenAddresses: [listenAddr, ...(config.circuitRelayListenAddrs ?? [])],
137
164
  ...(announce ? { announceAddresses: announce } : {}),
138
165
  connectionGater: config.connectionGater,
166
+ // DOD-M15-RELAYONLY-1: an agent that asked never to be directly reachable must not hole-punch
167
+ // its way to a direct connection. dcutr's job is to UPGRADE a relayed connection, and the
168
+ // INBOUND side starts that upgrade — which is precisely the standing receiver. So filtering
169
+ // what the directory publishes is not enough on its own: the address a peer cannot be TOLD, a
170
+ // hole-punch still REVEALS, and it happens inside libp2p after every assertion has passed.
171
+ ...(config.relayOnly === true ? { holePunch: { enabled: false } } : {}),
139
172
  // Forward the role. After DOD-NAT-REACHABILITY-1, dcutr is on every node
140
- // type; nodeType's remaining transport effect is the HOP gate (client
141
- // types never advertise circuit-relay HOP).
173
+ // type unless relay-only turns it off above; nodeType's remaining transport effect is the HOP
174
+ // gate (client types never advertise circuit-relay HOP).
142
175
  nodeType: config.nodeType,
143
176
  // DOD-M12B-SESSION-SEED-1: forward the caller's transport seed when it supplied one, so a
144
177
  // rebuilt session node returns at the peer id the counterparty already holds. Omitted (rather
145
178
  // than passed as undefined) when absent, keeping createNode's "generate a fresh key" default
146
179
  // for every node that is not session-scoped.
147
180
  ...(config.transportPrivateKey ? { transportPrivateKey: config.transportPrivateKey } : {}),
181
+ /**
182
+ * DOD-M15-IDLE-CONNS-1 — the idle sweep is armed on the node that STARTS as a standing
183
+ * receiver.
184
+ *
185
+ * **It is NOT "the standing receiver only", and an earlier version of this comment said so
186
+ * and was false.** `acceptSession` does not build a new node: it moves this same `CelloNode`
187
+ * from `#standingReceivers` into `#activeNodes`, so the interval keeps running after
188
+ * promotion, against the session's own counterparty. Review measured the consequence — the
189
+ * counterparty was hung up mid-conversation and the next send failed with `no_connection`,
190
+ * with nothing anywhere naming the local sweep.
191
+ *
192
+ * That is survivable only because the spared predicate below names the counterparty. The
193
+ * arrangement is deliberate now rather than accidental: one node, one interval, and the gate
194
+ * decides who is off-limits as it narrows.
195
+ */
196
+ ...(isReceiver
197
+ ? {
198
+ idleConnectionReaper: {
199
+ /**
200
+ * C3 — THE GUARD IS HEARD. A hang-up that tells nobody is indistinguishable from the
201
+ * thing simply not happening, and review measured what that costs: the operator's
202
+ * next send returns `no_connection`, `session.transport.redial.unavailable` says
203
+ * "every send parks until they re-establish", and not one word in that chain names a
204
+ * `setInterval` on their own machine.
205
+ *
206
+ * WARN and CONTINUE, deliberately: this is resource bounding, not a security event.
207
+ * Nothing is refused and no session state changes — so it is loud, and it does not
208
+ * block (Invariant 2's own distinction).
209
+ */
210
+ onReaped: (e) => {
211
+ if (e.reason === "never_carried_a_stream") {
212
+ this.logger?.warn("session.node.connection.reaped", {
213
+ sessionId: config.sessionId,
214
+ peerId: e.peerId,
215
+ ageMs: e.ageMs,
216
+ observation: "an inbound connection was hung up after never carrying a stream since it opened",
217
+ impact: "no session state changed and nothing was refused; if this peer returns it must dial again",
218
+ });
219
+ return;
220
+ }
221
+ // A sweep that cannot do its job is a different event and must not read as one that
222
+ // did. `hangUp`'s `invalid_peer_id` is a NAMED reason written so a malformed id is
223
+ // not read as a connection problem — preserved here rather than flattened.
224
+ this.logger?.error("session.node.connection.reap_failed", {
225
+ sessionId: config.sessionId,
226
+ peerId: e.peerId,
227
+ reason: e.reason,
228
+ error: e.error,
229
+ impact: "an idle connection was NOT closed; it continues to hold a slot against the connection cap",
230
+ });
231
+ },
232
+ /**
233
+ * C4 — THE COUNT THE DoD ASKS FOR, and the reason this callback exists at all.
234
+ *
235
+ * The line says *"measure a healthy daemon's connection count first"*, and nothing in
236
+ * the tree reported one — not `cello_status`, not the CLI, not the log. Exposing the
237
+ * CAPS without the COUNT would have been a capability nothing reads, which is this
238
+ * milestone's own "no consumer, no ship". DEBUG because it is a census on a timer,
239
+ * not an event: it exists to be greppable when a cap is finally tuned.
240
+ */
241
+ onObserved: (c) => {
242
+ this.logger?.debug("transport.connections.observed", {
243
+ sessionId: config.sessionId,
244
+ total: c.total,
245
+ inbound: c.inbound,
246
+ neverSpoke: c.neverSpoke,
247
+ maxConnections: c.maxConnections,
248
+ });
249
+ },
250
+ },
251
+ }
252
+ : {}),
253
+ }).then((node) => {
254
+ /**
255
+ * SPARE WHAT REACHABILITY DEPENDS ON — two things, and the second was missing.
256
+ *
257
+ * **The reserved relay.** The same list `DOD-M15-FRAME-1`'s eviction sweep spares, for the
258
+ * reason its comment gives: reservation refreshes ride those peers, and hanging one up costs
259
+ * the agent its inbound reachability. A reservation is IDLE BY NATURE between refreshes,
260
+ * which is exactly the shape the sweep hunts.
261
+ *
262
+ * **The peer the gate currently names.** `getAllowedPeerId()` is the admitted dialer before
263
+ * promotion and the counterparty after it. Without this the sweep reaps the one peer the
264
+ * session exists for.
265
+ *
266
+ * READ LIVE on every sweep, never captured: reservations are lost and retaken constantly
267
+ * (2,675 `reservation.lost` in one daemon's log) and `#allowedPeerId` changes at offer,
268
+ * promotion and refusal. A set frozen at build time is wrong within minutes.
269
+ *
270
+ * WHAT IS LEFT TO REAP, stated because a guard with no population is theatre: a peer that was
271
+ * admitted by an offer which was then refused or expired. `closeInbound()` returns
272
+ * `#allowedPeerId` to null, and libp2p never re-runs a gater against a connection that
273
+ * already exists — so that peer stays attached, named by nobody, speaking nothing. That is
274
+ * the connection this unit removes.
275
+ */
276
+ const gater = config.connectionGater;
277
+ if (isReceiver && gater) {
278
+ node.setIdleReaperSpared((peerId) => gater.isAllowedOutboundPeer(peerId) || gater.getAllowedPeerId() === peerId);
279
+ }
280
+ return node;
148
281
  });
149
282
  }
150
283
  }
@@ -195,6 +328,43 @@ async function startDaemonHoldingLock(config, singletonLock) {
195
328
  if (manifestProvider && (!manifestRootKeys || !manifestThreshold || manifestThreshold <= 0)) {
196
329
  throw new Error("DaemonConfig: manifestProvider requires manifestRootKeys (non-empty) and manifestThreshold (positive integer >= 1)");
197
330
  }
331
+ /**
332
+ * DOD-M15-DIRAUTH-1 — an operator can DEMAND directory identity authentication.
333
+ *
334
+ * HERE, under the ADV-006/008 rule above, because this IS pure config validation: both operands
335
+ * are already in hand and it touches nothing.
336
+ *
337
+ * Review F1 caught me putting it ninety lines lower, next to ADV-002, on the reasoning that it
338
+ * "mirrors" it. It does not. ADV-002 sits down there because it MUST — it depends on
339
+ * `verifyStartupManifest`, which depends on the anti-rollback floor in the DB. This depends on
340
+ * nothing, and down there it ran AFTER: the irreversible flat-file → SQLCipher identity migration
341
+ * (which renames and unlinks files), the creation of `sessions.db` and its key, and the sweep that
342
+ * marks every `active` session `interrupted` with `interrupted_by='local'`.
343
+ *
344
+ * So a misconfigured daemon "failed to start" and changed the operator's record on the way out —
345
+ * two live sessions permanently interrupted, attributed to a local cause, by a config check that
346
+ * could have run before anything was touched.
347
+ */
348
+ if (directoryAuthRequired(process.env) && challengeVerifier === undefined) {
349
+ const url = config.directoryHttpUrl ?? resolveDirectoryUrl(process.env);
350
+ logger.error("directory.auth.required.unavailable", {
351
+ directoryUrl: url,
352
+ impact: "the daemon refused to start rather than connect without directory identity authentication.",
353
+ guidance: "CELLO_REQUIRE_DIRECTORY_AUTH is set, but no challenge verifier could be built for this " +
354
+ "directory URL. Point CELLO_DIRECTORY_URL at a bundled endpoint, or supply a manifest with " +
355
+ "CELLO_CONSORTIUM_MANIFEST plus CELLO_CONSORTIUM_ROOT_KEYS and CELLO_CONSORTIUM_THRESHOLD " +
356
+ "(all three are required together), or set CELLO_REQUIRE_DIRECTORY_AUTH to 0/false/no/off " +
357
+ "to accept the risk.",
358
+ });
359
+ throw new Error(`CELLO_REQUIRE_DIRECTORY_AUTH is set, but directory identity authentication (step 6) cannot ` +
360
+ `be enforced: no challenge verifier was supplied for this daemon. The directory URL is ` +
361
+ `compared against the bundled consortium roster after NORMALISATION (trimmed, trailing slash ` +
362
+ `dropped, lowercased) — so case and a trailing slash are forgiven, but a DNS hostname ` +
363
+ `pointing at exactly the right machine is NOT, which is the usual cause. Either use a bundled ` +
364
+ `endpoint address, or supply a manifest with CELLO_CONSORTIUM_MANIFEST plus ` +
365
+ `CELLO_CONSORTIUM_ROOT_KEYS and CELLO_CONSORTIUM_THRESHOLD (all three are required together), ` +
366
+ `or set CELLO_REQUIRE_DIRECTORY_AUTH to 0/false/no/off to start without step 6.`);
367
+ }
198
368
  // ── PERSIST-002: open the encrypted store FIRST (runs the one-time flat-file → SQLCipher migration
199
369
  // (AC-006) + creates the agents/manifest_state schema), under the single-instance lock. This must
200
370
  // precede the manifest verification below because the manifest version is now stored in the
@@ -230,7 +400,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
230
400
  // screening, so a wiring mistake shows up here instead of hiding behind a correct-looking line.
231
401
  logger.info("security.gateway.connected", { mode: securityGateway.mode });
232
402
  const sessionNodeManager = new SessionNodeManager({
233
- factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(),
403
+ factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(logger),
234
404
  logger,
235
405
  dbPath: join(celloDir, "sessions.db"),
236
406
  contentTtfMs: config.contentTtfMs,
@@ -250,6 +420,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
250
420
  manifestThreshold,
251
421
  manifestVersionStore,
252
422
  logger,
423
+ // DOD-M15-STALEROSTER-1: the same injected fetch the sweep uses, so the startup probe and the
424
+ // background probe are exercised through one seam rather than one being untestable.
425
+ ...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
253
426
  });
254
427
  // ADV-002: an operator who configures manifestProvider has opted INTO manifest enforcement, so a
255
428
  // failed verification is fatal — never a warning we start anyway on.
@@ -268,7 +441,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
268
441
  "Check the logs for the specific failure reason (manifest_signature_invalid, manifest_expired, or manifest_version_rollback).");
269
442
  }
270
443
  // The manifest poll starts only AFTER the refuse above — a refused startup must not leak a timer.
271
- const { resolveConsortiumRoster, failoverEndpointResolver, getFailoverEndpoint, getUnresolvedNodes, getUnresolvedSweptAt, stopHttpManifestPoll } = createConsortiumRouting({
444
+ const { resolveConsortiumRoster, failoverEndpointResolver, getFailoverEndpoint, getUnresolvedNodes, getUnresolvedSweptAt, getDeclaredNodeCount, stopHttpManifestPoll } = createConsortiumRouting({
272
445
  manifestProvider,
273
446
  manifestRootKeys,
274
447
  manifestThreshold,
@@ -282,7 +455,96 @@ async function startDaemonHoldingLock(config, singletonLock) {
282
455
  initialUnresolvedNodes: startupUnresolvedNodes,
283
456
  initialUnresolvedSweptAt: startupSweptAt,
284
457
  logger,
458
+ ...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
459
+ });
460
+ /**
461
+ * DOD-M15-STALEROSTER-1 — keep measuring directory reachability even when nothing is wrong.
462
+ *
463
+ * Every existing caller of the sweep is ACTIVITY-driven — ceremonies, session setup,
464
+ * `cello_refresh`, the seal broker. So an IDLE daemon never re-measures, and sitting idle is what
465
+ * a daemon does between conversations: the reading it was seeded with at boot is the reading it
466
+ * still has an hour later. Measured twice, on two machines — node failures from minutes past
467
+ * displayed while `curl` reached all three nodes in 37–184 ms.
468
+ *
469
+ * (An earlier version of this comment said the sweep had ONE caller, the failover path, and that
470
+ * recovering was what stopped the measurement. That was wrong — there are ten — and it is
471
+ * corrected here rather than deleted because believing it is why the concurrent-sweep race in
472
+ * `consortium-bootstrap.ts` went unnoticed until review.)
473
+ *
474
+ * Skipped when there is no manifest provider: there is no node roster to enumerate, so a timer
475
+ * that can only ever re-measure nothing is noise. That case is NOT silent — `cello_status`
476
+ * reports `measurement: "not_configured"` and says why.
477
+ */
478
+ /**
479
+ * WHERE the held manifest came from, because it decides what the operator can actually DO about
480
+ * an expired one — `DOD-M15-MANIFEST-EXPIRY-LIVE-1` review F5.
481
+ *
482
+ * `EmbeddedManifestProvider` is the compiled-in bundled roster: there is no file to replace and no
483
+ * poll to adopt a replacement, so "rotate the manifest" is not an available action and telling
484
+ * that operator to do it routes them toward the one workaround that silently disables directory
485
+ * identity authentication. Detected by the provider's own constructor rather than by re-reading
486
+ * the env var, so a caller that injects a provider directly is classified by what it IS.
487
+ */
488
+ const manifestOrigin = manifestProvider?.constructor?.name === "EmbeddedManifestProvider" ? "bundled" : "file";
489
+ /** REVIEW F4: the last sweep failure, surfaced in `cello_status` alongside the log line. */
490
+ let lastRosterSweepError;
491
+ /**
492
+ * DOD-M15-MANIFEST-EXPIRY-LIVE-1 — re-check the trust anchor's validity while the daemon runs.
493
+ *
494
+ * The window is enforced at STARTUP and nowhere else. The manifest poll's expiry check looks at
495
+ * the manifest being FETCHED, never the one held, so a daemon past its expiry keeps polling, keeps
496
+ * correctly refusing expired replacements, and keeps using the lapsed anchor it already has.
497
+ *
498
+ * Rides the roster sweep rather than owning a timer: that tick already fires every 90–180 s on
499
+ * exactly the path where a manifest provider exists.
500
+ */
501
+ const checkManifestValidity = startManifestValidityWatch({
502
+ getManifest: () => manifestProvider?.getCurrentManifest() ?? null,
503
+ logger,
285
504
  });
505
+ const rosterSweepScheduler = manifestProvider
506
+ ? config.rosterSweepScheduler ??
507
+ new RandomizedPollScheduler({ minMs: ROSTER_SWEEP_INTERVAL_MS, maxMs: ROSTER_SWEEP_INTERVAL_MS * 2 })
508
+ : undefined;
509
+ const stopRosterSweep = rosterSweepScheduler
510
+ ? startRosterSweep({
511
+ scheduler: rosterSweepScheduler,
512
+ // FAST_PROBE is deliberately NOT used here. It exists because the failover resolver runs
513
+ // inside the 10 s signaling wait; nothing waits on this sweep, so it can afford the
514
+ // patient probe and give the more trustworthy answer.
515
+ sweep: async () => {
516
+ /**
517
+ * DOD-M15-MANIFEST-EXPIRY-LIVE-1: the anchor's validity is re-checked on the same tick.
518
+ * BEFORE the probe, so an expired manifest is reported even on a cycle where every node is
519
+ * unreachable and the roster resolve throws.
520
+ *
521
+ * Its OWN try/catch — review F11. Sharing the sweep's error path meant a throw in here
522
+ * would surface as `directory.roster.sweep.failed` AND skip `resolveConsortiumRoster()`
523
+ * entirely: the roster reading would freeze while the operator was pointed at the
524
+ * directory. A manifest-check failure must never be reported as a directory failure, and
525
+ * must never cost the measurement it rides along with.
526
+ */
527
+ try {
528
+ checkManifestValidity();
529
+ }
530
+ catch (err) {
531
+ logger.error("directory.auth.manifest.check.failed", {
532
+ error: err instanceof Error ? err.message : String(err),
533
+ impact: "the manifest validity re-check did not run this cycle. cello_status still computes " +
534
+ "it independently on every read, so the FIELD is unaffected; what is lost is the " +
535
+ "unprompted log line on a transition.",
536
+ });
537
+ }
538
+ return resolveConsortiumRoster();
539
+ },
540
+ logger,
541
+ // REVIEW F4: the failure reaches the agent's response, not just the log. Without this a
542
+ // sweep failing every cycle is invisible for the first two or three failures, because the
543
+ // reading is still inside its 5-minute freshness bound and reports stale:false.
544
+ onSweepError: (e) => { lastRosterSweepError = e; },
545
+ onSweepSuccess: () => { lastRosterSweepError = undefined; },
546
+ })
547
+ : undefined;
286
548
  // DOD-REGISTRY-1: type registry poll — daemon-level, runs even with zero agents.
287
549
  // When registryPubkey is configured, the daemon polls GET /registry, verifies the inner
288
550
  // Ed25519 signature, and updates the in-memory TypeRegistry. A poll failure never blanks
@@ -334,6 +596,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
334
596
  for (const a of loadedAgents) {
335
597
  keyProviders.set(a.name, a.keyProvider);
336
598
  }
599
+ // DOD-M15-EPHEMERAL-AUTH-1: the session manager signs each session's throwaway key with the
600
+ // agent's identity, so it needs the same providers. Injected here rather than through the
601
+ // constructor because this map is built after the manager exists — the same reason
602
+ // `setParkedDrainHook` is a setter.
603
+ sessionNodeManager.setKeyProviderResolver((agentName) => keyProviders.get(agentName));
337
604
  // Constructed HERE, before ANY boot-time caller. autoRecoverForAgent is invoked from an agent's
338
605
  // onConnected and from the seal-upgrade content gate — both of which run long before the IPC
339
606
  // handler map exists. Its handlers register later (phase 2), which is what lets this sit up here.
@@ -544,6 +811,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
544
811
  onRelayEndpoints: (endpoints) => {
545
812
  sessionNodeManager.setDirectoryRelayEndpoints(agentName, endpoints.map((e) => ({ relayPeerId: e.peerId, relayAddrs: e.addrs })));
546
813
  },
814
+ // DOD-M15-RELAYSLOTS-1: and the credential those relays require. Same frame, same cadence as
815
+ // the endpoints above — every connect and every reconnect, which is what keeps a token that
816
+ // expires within the hour current for a receiver that lives much longer than that.
817
+ onOnlineToken: (token) => {
818
+ sessionNodeManager.setDirectoryOnlineToken(agentName, token);
819
+ },
820
+ // Review M1: and when there is none, WHY — so the operator surface can say "this directory
821
+ // does not know this agent" instead of "check your directory connection" about a connection
822
+ // that just succeeded.
823
+ onOnlineTokenAbsent: (reason) => {
824
+ sessionNodeManager.setDirectoryOnlineTokenAbsent(agentName, reason);
825
+ },
547
826
  });
548
827
  const mgr = new SignalingManager({
549
828
  connect,
@@ -580,6 +859,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
580
859
  agentName,
581
860
  persistence: getPersistence(agentName),
582
861
  agentPubkeyHex,
862
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F1): the co-sign path must check the root before this
863
+ // agent's key endorses it. Same predicate as the receiving path — one implementation, so the
864
+ // two halves cannot drift about what a mismatch is.
865
+ verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
583
866
  keyProvider: agentKeyProvider,
584
867
  getNode: entry.getNode,
585
868
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -592,6 +875,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
592
875
  agentName,
593
876
  persistence: getPersistence(agentName),
594
877
  agentPubkeyHex,
878
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F1): the co-sign path must check the root before this
879
+ // agent's key endorses it. Same predicate as the receiving path — one implementation, so the
880
+ // two halves cannot drift about what a mismatch is.
881
+ verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
595
882
  keyProvider: agentKeyProvider,
596
883
  getNode: entry.getNode,
597
884
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -606,6 +893,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
606
893
  wireSessionOfferHandler({
607
894
  agentName,
608
895
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agentName),
896
+ admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agentName, peerId, sessionIdHex),
897
+ // DOD-M15-RELAYONLY-1: lets the handler tell "no addresses because relay-only filtered them"
898
+ // from "no addresses yet", which need opposite answers — a refusal, and the pre-existing path.
899
+ isRelayOnly: () => relayOnlyState((key) => sessionNodeManager.getSetting(agentName, key), sessionNodeManager.hasDatabase()) !== "off",
609
900
  signaling: mgr,
610
901
  logger,
611
902
  });
@@ -654,6 +945,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
654
945
  agentName: agent.name,
655
946
  persistence: getPersistence(agent.name),
656
947
  agentPubkeyHex: agent.pubkey,
948
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
949
+ verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
657
950
  keyProvider: agent.keyProvider,
658
951
  getNode: noSharedDirectoryNode,
659
952
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -665,6 +958,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
665
958
  agentName: agent.name,
666
959
  persistence: getPersistence(agent.name),
667
960
  agentPubkeyHex: agent.pubkey,
961
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
962
+ verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
668
963
  keyProvider: agent.keyProvider,
669
964
  getNode: noSharedDirectoryNode,
670
965
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -675,6 +970,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
675
970
  wireSessionOfferHandler({
676
971
  agentName: agent.name,
677
972
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
973
+ admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agent.name, peerId, sessionIdHex),
974
+ // DOD-M15-RELAYONLY-1 — see the note on the sibling call site above.
975
+ isRelayOnly: () => relayOnlyState((key) => sessionNodeManager.getSetting(agent.name, key), sessionNodeManager.hasDatabase()) !== "off",
678
976
  signaling: mgr,
679
977
  logger,
680
978
  });
@@ -890,6 +1188,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
890
1188
  // whether its counterparty is quiet or its sibling was faster. Written at the destructive drain in
891
1189
  // session-content-handlers; read at that handler's timeout. Delivery itself is unchanged.
892
1190
  const contentTakes = new ContentTakeLedger();
1191
+ /**
1192
+ * In-flight background seal ceremonies — `DOD-M15-CLOSEWAIT-1` review MEDIUM-6.
1193
+ *
1194
+ * The close now answers at commitment and finishes the ceremony detached. Without this the tail
1195
+ * was the ONE background task `stop()` could cut at an arbitrary point, while it cancels or awaits
1196
+ * every other one.
1197
+ */
1198
+ const backgroundSeals = new Set();
1199
+ /**
1200
+ * DOD-M15-SEAL-FAILED-TERMINAL-1 — the last background seal failure per session.
1201
+ *
1202
+ * In memory on purpose: a restart makes "failed" the WRONG answer, because the boot sweep plus the
1203
+ * restart seal resolver retry the session. A marker whose lifetime is the process matches the
1204
+ * lifetime of the condition it describes.
1205
+ */
1206
+ const sealFailures = new SealFailureStore();
893
1207
  // DOD-AWAY-WRAP-1 AC1: request text is a leave-a-message greeting; agentName is spliced in at
894
1208
  // the call site so it names the specific away agent.
895
1209
  // DOD-AWAY-ACK-ONESHOT-TEXT-1 (live defect 2026-07-24): the ack must state the one-shot rule —
@@ -979,9 +1293,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
979
1293
  // FRONT precisely so [[WRAP]] keeps the end position the counterparty's detector anchors on.
980
1294
  const rejectText = markAsAutoReply("This inbox only accepts one message per visit. Closing. [[WRAP]]");
981
1295
  const rejectBytes = new TextEncoder().encode(rejectText);
982
- const rejectHash = wireContentHash(rejectBytes);
1296
+ // B2b: one decision point for the hash AND its algorithm — see `contentHashForSession`.
1297
+ const reject = await sessionNodeManager.contentHashForSession(agentName, sessionId, rejectBytes);
983
1298
  // Best-effort: a send failure still triggers the seal — we are closing regardless.
984
- const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(rejectHash), randomUUID());
1299
+ const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(reject.hash), randomUUID(), LEAF_KIND_MSG, reject.alg);
985
1300
  // M12-P13: commit the leaf when the rejection went out OR when it is durably queued —
986
1301
  // either way the relay already witnessed its sequence. This caller is the sharpest case
987
1302
  // of the three: the seal is initiated immediately below, so a hole here does not merely
@@ -989,14 +1304,17 @@ async function startDaemonHoldingLock(config, singletonLock) {
989
1304
  // counterparty will still receive content at. The roots then cannot agree, and the
990
1305
  // session is unsealable for good.
991
1306
  if (sendResult.ok || sendResult.durable) {
992
- const rejectHashHex = Buffer.from(rejectHash).toString("hex");
1307
+ const rejectHashHex = Buffer.from(reject.hash).toString("hex");
993
1308
  // DOD-M12B-INDEX-1: at the relay's position, not the tail. This is the riskiest append
994
1309
  // in the codebase for that — the seal is initiated a few lines below, so a leaf at the
995
1310
  // wrong index does not merely stall the far side, it seals a tree the counterparty can
996
1311
  // never agree with.
997
- const placed = sessionNodeManager.placeOwnLeaf(agentName, sessionId, rejectHashHex, rejectBytes, sendResult.sequenceNumber, randomUUID());
1312
+ // The proof travels with the leaf, not only with the transcript row below: when this
1313
+ // append is HELD behind a gap, the `recordTranscriptMessage` call is skipped entirely
1314
+ // and the held entry is the only thing that reaches the row on release.
1315
+ const placed = sessionNodeManager.placeOwnLeaf(agentName, sessionId, rejectHashHex, rejectBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
998
1316
  if (placed.placed) {
999
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID());
1317
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID(), sentAuthorship(sendResult));
1000
1318
  }
1001
1319
  logger.info("session.away.inbox.oneshot.rejected", { agentName, sessionId, sequenceNumber: placed.placed ? placed.leafIndex : placed.heldAt, committed: placed.placed, queued: !sendResult.ok });
1002
1320
  }
@@ -1015,85 +1333,124 @@ async function startDaemonHoldingLock(config, singletonLock) {
1015
1333
  // not have ingested it yet when the seal request arrives over the signaling channel.
1016
1334
  // The relay path avoids this entirely — it posts a SEAL ctrl leaf and waits for the
1017
1335
  // counterparty to independently co-seal; no bilateral leaf-count comparison needed.
1018
- void (async () => {
1019
- const correlationId = randomUUID();
1020
- const sk = sealKey(agentName, sessionId);
1021
- if (sealInterruptedInProgress.has(sk))
1022
- return;
1023
- sealInterruptedInProgress.add(sk);
1024
- try {
1025
- let resolveSeal;
1026
- const sealedP = new Promise((r) => { resolveSeal = r; });
1027
- pendingSealWaiters.set(sk, resolveSeal);
1028
- const submit = await sessionNodeManager.submitSealLeaf(agentName, sessionId, correlationId);
1029
- if (!submit.ok && submit.reason !== "responder_seal_already_submitted") {
1030
- pendingSealWaiters.delete(sk);
1031
- if (submit.reason === "relay_unavailable") {
1032
- const fallback = await handleActiveSealFlow(sessionId, record2, correlationId);
1033
- if (fallback.ok) {
1034
- logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "signaling_fallback" });
1336
+ /**
1337
+ * DOD-M15-DIVERGE-1 (review HIGH-3) — the gate has to hold on the path with no operator.
1338
+ *
1339
+ * `cello_close_session` refuses a diverged record, but this autonomous path never
1340
+ * consulted `sealReadiness` at all: it read `placed.placed` and discarded
1341
+ * `placed.diverged`, then initiated the seal directly. So "a diverged session is blocked
1342
+ * from sealing" held for the close a human drives and not for the one that runs itself —
1343
+ * which is the worse of the two, because the append two lines above is, by its own
1344
+ * comment, "the riskiest append in the codebase" for exactly this reason.
1345
+ *
1346
+ * THE LOG IS THE SURFACE HERE, and that is not a weakening of Invariant 2. There is no
1347
+ * caller to answer nothing is awaiting a response on this path — so the log carries the
1348
+ * whole warning rather than half of it.
1349
+ */
1350
+ const oneshotReadiness = sessionNodeManager.sealReadiness(agentName, sessionId);
1351
+ if (oneshotReadiness.diverged) {
1352
+ logger.warn("session.away.inbox.oneshot.seal_skipped_diverged", {
1353
+ agentName, sessionId,
1354
+ treeSize: oneshotReadiness.treeSize, highWaterSeq: oneshotReadiness.highWaterSeq,
1355
+ impact: "this side's tree parted from the relay's ordering, so the seal was NOT initiated — the session stays closeable by hand, where the operator is told what parted and can compare counts with the counterparty before deciding",
1356
+ });
1357
+ }
1358
+ else {
1359
+ void (async () => {
1360
+ const correlationId = randomUUID();
1361
+ const sk = sealKey(agentName, sessionId);
1362
+ if (sealInterruptedInProgress.has(sk))
1363
+ return;
1364
+ sealInterruptedInProgress.add(sk);
1365
+ try {
1366
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F4): SealCompletion is now a union — a refused
1367
+ // certificate resolves the waiter rather than dropping it, so this path must handle it
1368
+ // too rather than treating a refusal as a seal.
1369
+ let resolveSeal;
1370
+ const sealedP = new Promise((r) => { resolveSeal = r; });
1371
+ pendingSealWaiters.set(sk, resolveSeal);
1372
+ const submit = await sessionNodeManager.submitSealLeaf(agentName, sessionId, correlationId);
1373
+ if (!submit.ok && submit.reason !== "responder_seal_already_submitted") {
1374
+ pendingSealWaiters.delete(sk);
1375
+ if (submit.reason === "relay_unavailable") {
1376
+ const fallback = await handleActiveSealFlow(sessionId, record2, correlationId);
1377
+ if (fallback.ok) {
1378
+ logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "signaling_fallback" });
1379
+ }
1380
+ else {
1381
+ logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: fallback.reason, path: "signaling_fallback" });
1382
+ }
1035
1383
  }
1036
1384
  else {
1037
- logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: fallback.reason, path: "signaling_fallback" });
1385
+ logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: submit.reason });
1038
1386
  }
1387
+ return;
1039
1388
  }
1040
- else {
1041
- logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: submit.reason });
1389
+ logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "relay" });
1390
+ const bilateralTimeoutMs = Number(process.env["CELLO_SEAL_BILATERAL_TIMEOUT_MS"]) || 660_000;
1391
+ let timer;
1392
+ const timeoutP = new Promise((r) => { timer = setTimeout(() => r(null), bilateralTimeoutMs); });
1393
+ const sealedCompletion = await Promise.race([sealedP, timeoutP]);
1394
+ clearTimeout(timer);
1395
+ pendingSealWaiters.delete(sk);
1396
+ if (sealedCompletion !== null) {
1397
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F4): a REFUSED certificate resolves the waiter
1398
+ // now rather than being dropped, so this path must tell the two apart — logging a
1399
+ // refusal as "sealed" would be the silent acceptance the whole bullet exists to stop.
1400
+ if ("refused" in sealedCompletion) {
1401
+ logger.error("session.away.inbox.oneshot.seal_refused", {
1402
+ agentName, sessionId, reason: sealedCompletion.reason, detail: sealedCompletion.detail,
1403
+ impact: "the away auto-seal was REFUSED: the directory returned a validly signed root " +
1404
+ "that does not describe this conversation. The session is NOT sealed and nothing " +
1405
+ "was signed with this agent's key.",
1406
+ });
1407
+ return;
1408
+ }
1409
+ logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: sealedCompletion.rootHex });
1410
+ return;
1042
1411
  }
1043
- return;
1044
- }
1045
- logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "relay" });
1046
- const bilateralTimeoutMs = Number(process.env["CELLO_SEAL_BILATERAL_TIMEOUT_MS"]) || 660_000;
1047
- let timer;
1048
- const timeoutP = new Promise((r) => { timer = setTimeout(() => r(null), bilateralTimeoutMs); });
1049
- const sealedCompletion = await Promise.race([sealedP, timeoutP]);
1050
- clearTimeout(timer);
1051
- pendingSealWaiters.delete(sk);
1052
- if (sealedCompletion !== null) {
1053
- logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: sealedCompletion.rootHex });
1054
- return;
1055
- }
1056
- // Bilateral timeout — escalate to unilateral seal.
1057
- const escalation = submit.ok
1058
- ? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
1059
- : submit.reason === "responder_seal_already_submitted" &&
1060
- typeof submit.reportedRootHex === "string" &&
1061
- typeof submit.sequenceNumber === "number"
1412
+ // Bilateral timeout — escalate to unilateral seal.
1413
+ const escalation = submit.ok
1062
1414
  ? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
1063
- : null;
1064
- if (!escalation) {
1065
- logger.warn("session.away.inbox.oneshot.seal_pending", { agentName, sessionId });
1066
- return;
1067
- }
1068
- // DOD-M12B-SEAL-ESCALATE-DUP-1: THE SHARED ESCALATION, not a second copy.
1069
- //
1070
- // This used to be a line-for-line duplicate with its own hardcoded 30 s timeout, and
1071
- // it missed every refusal the other one gained: an empty carry, a gappy chain, two of
1072
- // our own ctrl leaves (permanently unsealable), and a bilateral seal already running.
1073
- // It spent the full timeout on each and then reported `seal_unilateral_timeout` — the
1074
- // label that names our own wait. Sharing the body is what stops that drifting again.
1075
- const uni = await runUnilateralEscalation({
1076
- logger, sessionNodeManager, sendOver, pendingUnilateralWaiters, sealKey,
1077
- getKeyProvider: (a) => keyProviders.get(a),
1078
- timeoutMs: UNILATERAL_SEAL_TIMEOUT_MS,
1079
- }, agentName, sessionId, escalation, correlationId, { refuseOnUnusableCarry: true });
1080
- if (uni.ok) {
1081
- logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: uni.sealed_root, sealType: "unilateral" });
1415
+ : submit.reason === "responder_seal_already_submitted" &&
1416
+ typeof submit.reportedRootHex === "string" &&
1417
+ typeof submit.sequenceNumber === "number"
1418
+ ? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
1419
+ : null;
1420
+ if (!escalation) {
1421
+ logger.warn("session.away.inbox.oneshot.seal_pending", { agentName, sessionId });
1422
+ return;
1423
+ }
1424
+ // DOD-M12B-SEAL-ESCALATE-DUP-1: THE SHARED ESCALATION, not a second copy.
1425
+ //
1426
+ // This used to be a line-for-line duplicate with its own hardcoded 30 s timeout, and
1427
+ // it missed every refusal the other one gained: an empty carry, a gappy chain, two of
1428
+ // our own ctrl leaves (permanently unsealable), and a bilateral seal already running.
1429
+ // It spent the full timeout on each and then reported `seal_unilateral_timeout` — the
1430
+ // label that names our own wait. Sharing the body is what stops that drifting again.
1431
+ const uni = await runUnilateralEscalation({
1432
+ logger, sessionNodeManager, sendOver, pendingUnilateralWaiters, sealKey,
1433
+ getKeyProvider: (a) => keyProviders.get(a),
1434
+ timeoutMs: UNILATERAL_SEAL_TIMEOUT_MS,
1435
+ }, agentName, sessionId, escalation, correlationId, { refuseOnUnusableCarry: true });
1436
+ if (uni.ok) {
1437
+ logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: uni.sealed_root, sealType: "unilateral" });
1438
+ }
1439
+ else {
1440
+ // CARRY THE GUIDANCE. The four refusals this path just gained come with the
1441
+ // sentence that tells an operator whether to retry or force-abandon — and the log
1442
+ // is this path's ONLY surface, so dropping it leaves a nameable cause with no
1443
+ // action attached. The old reasons had no guidance to lose; these do.
1444
+ logger.warn("session.away.inbox.oneshot.seal_unilateral_failed", {
1445
+ agentName, sessionId, reason: uni.reason, guidance: uni.guidance,
1446
+ });
1447
+ }
1082
1448
  }
1083
- else {
1084
- // CARRY THE GUIDANCE. The four refusals this path just gained come with the
1085
- // sentence that tells an operator whether to retry or force-abandon — and the log
1086
- // is this path's ONLY surface, so dropping it leaves a nameable cause with no
1087
- // action attached. The old reasons had no guidance to lose; these do.
1088
- logger.warn("session.away.inbox.oneshot.seal_unilateral_failed", {
1089
- agentName, sessionId, reason: uni.reason, guidance: uni.guidance,
1090
- });
1449
+ finally {
1450
+ sealInterruptedInProgress.delete(sealKey(agentName, sessionId));
1091
1451
  }
1092
- }
1093
- finally {
1094
- sealInterruptedInProgress.delete(sealKey(agentName, sessionId));
1095
- }
1096
- })();
1452
+ })();
1453
+ }
1097
1454
  }
1098
1455
  }
1099
1456
  return;
@@ -1150,8 +1507,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
1150
1507
  ? new Uint8Array(awayVerdict.content)
1151
1508
  : draftBytes;
1152
1509
  const contentBytes = new TextEncoder().encode(markAsAutoReply(new TextDecoder().decode(screenedBytes)));
1153
- const contentHash = wireContentHash(contentBytes);
1154
- const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
1510
+ // B2b: one decision point for the hash AND its algorithm — see `contentHashForSession`.
1511
+ const away = await sessionNodeManager.contentHashForSession(agentName, sessionId, contentBytes);
1512
+ const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(away.hash), randomUUID(), LEAF_KIND_MSG, away.alg);
1155
1513
  if (!sendResult.ok && !sendResult.durable) {
1156
1514
  // Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
1157
1515
  // away period — clear the guard so the next inbound arrival retries the ack.
@@ -1165,7 +1523,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1165
1523
  });
1166
1524
  return;
1167
1525
  }
1168
- const contentHashHex = Buffer.from(contentHash).toString("hex");
1526
+ const contentHashHex = Buffer.from(away.hash).toString("hex");
1169
1527
  if (!sendResult.ok) {
1170
1528
  // M12-P13 (found live 2026-08-05, M12 Entry 89): the reply is durably queued and already
1171
1529
  // owns the sequence the relay witnessed for it, so its leaf MUST be committed here. Without
@@ -1177,9 +1535,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1177
1535
  // next arrival would mint a second greeting at a second sequence.
1178
1536
  // DOD-M12B-INDEX-1: the queued reply owns the position the relay witnessed for it, and
1179
1537
  // that is where its leaf goes.
1180
- const placedQueued = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
1538
+ // Witnessed and SIGNED only the direct hand-off failed — so the proof exists and must
1539
+ // reach the leaf. This is the site `sentAuthorship`'s own comment calls dead-by-construction
1540
+ // under an `ok`-gated read; the same reasoning applies to the leaf, not just the row.
1541
+ const placedQueued = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
1181
1542
  if (placedQueued.placed) {
1182
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID());
1543
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID(), sentAuthorship(sendResult));
1183
1544
  }
1184
1545
  logger.info("session.away.response.deferred", {
1185
1546
  agentName, sessionId, kind, isKnown,
@@ -1191,9 +1552,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
1191
1552
  }
1192
1553
  // DOD-M12B-INDEX-1: the away responder fires while inbound is still arriving, so it is the
1193
1554
  // path most likely to have a gap open under it — exactly where a tail append does damage.
1194
- const placedReply = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
1555
+ const placedReply = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
1195
1556
  if (placedReply.placed) {
1196
- sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID());
1557
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID(), sentAuthorship(sendResult));
1197
1558
  }
1198
1559
  logger.info("session.away.response.sent", {
1199
1560
  agentName, sessionId, kind, isKnown,
@@ -1382,6 +1743,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
1382
1743
  challengeVerifier,
1383
1744
  getManifestVersion: () => verifiedManifestVersion,
1384
1745
  loadedAgents,
1746
+ // DOD-M15-ERRSTRING-1: so a session failure can say "and 2 of your 5 directories are
1747
+ // unreachable", which is very often the actual cause and was reported nowhere the operator
1748
+ // was looking.
1749
+ getUnresolvedNodes,
1750
+ getDeclaredNodeCount,
1385
1751
  });
1386
1752
  // Both use the same SQLite DB as the SessionNodeManager (daemon.db equivalent).
1387
1753
  // loadFromDb() must complete BEFORE IPC socket opens (AC-007).
@@ -1421,6 +1787,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
1421
1787
  // seal depends on — silently, while reporting success.
1422
1788
  receiptStore: stores.receiptStore,
1423
1789
  sealLeafStore: stores.sealLeafStore,
1790
+ // DOD-M15-RELAYSLOTS-1: the manager owns the current token and hands the accessor down, so
1791
+ // this client reads a fresh one at every auth instead of a snapshot taken here at build time.
1792
+ onlineToken: stores.onlineToken,
1424
1793
  });
1425
1794
  });
1426
1795
  // DOD-RETRYQ-STRAND-1: a direct-resend row is reachable only by drainSession, which has no
@@ -1457,15 +1826,20 @@ async function startDaemonHoldingLock(config, singletonLock) {
1457
1826
  onPersisted: (agentName, sessionId, contentHashHex) => {
1458
1827
  retryQueue.markContentAcked(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"));
1459
1828
  },
1460
- onTtf: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) => {
1461
- retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor);
1829
+ // `DOD-M15-SEALWIRE-1` B2b-1 review F1 `contentHashAlg` is the DURABLE WRITER for the column
1830
+ // this unit added. Without it every queued row carries NULL, the crash backstop re-parks a salted
1831
+ // message as sha256, and the recipient refuses it and re-pulls it forever. The commit that added
1832
+ // the column said the producer "passes it"; the producer was passing a value nothing supplied,
1833
+ // because these two hooks were never widened.
1834
+ onTtf: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) => {
1835
+ retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor, contentHashAlg);
1462
1836
  },
1463
1837
  // M12-P12: same durable destination, different cause — a park deposit the relay refused. The
1464
1838
  // TTF timer is already cancelled on this path, so this is the only thing holding the content.
1465
1839
  // M12-P13 (review HIGH-1): the enqueue's own answer is returned, never a bare `true`. A dropped
1466
1840
  // copy that reports success now buys a committed hash-chain leaf for content that is gone.
1467
- onParkFailed: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) => {
1468
- return retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor);
1841
+ onParkFailed: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) => {
1842
+ return retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor, contentHashAlg);
1469
1843
  },
1470
1844
  });
1471
1845
  // MSG-001-3b (2b): the LIVE content-park deposit. On a not-confirmed send (direct delivery
@@ -1511,7 +1885,55 @@ async function startDaemonHoldingLock(config, singletonLock) {
1511
1885
  logger.warn("session.seal.autoack.broker.unreachable", { agentName, brokerNode, correlationId });
1512
1886
  return null;
1513
1887
  });
1514
- sessionNodeManager.setContentParkHook(async ({ agentName, sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor }) => {
1888
+ /**
1889
+ * DOD-M15-RELAYABUSE-1: scheduled park-retry timers, tracked so shutdown can clear them.
1890
+ *
1891
+ * ⚠️ Every other daemon timer is cleared in `stop()`; an untracked one lets an in-process restart
1892
+ * leave a stale timer that drains into a torn-down manager. Unref'd already, so it cannot hold the
1893
+ * process open — this is about a clean teardown, not about exiting.
1894
+ */
1895
+ const parkRetryTimers = new Set();
1896
+ /**
1897
+ * DOD-M15-RELAYABUSE-1: schedule ONE drain at the delay the relay asked for.
1898
+ *
1899
+ * ⚠️ ONE implementation, used by both park paths. The live-send path and the drain path both need
1900
+ * this and a second copy is how the clamp ends up on only one of them — which is exactly the shape
1901
+ * review found here (the number had a consumer on one path and was dropped on the other).
1902
+ *
1903
+ * CLAMPED, because the value comes from another party's software and an unclamped one inverts the
1904
+ * fix: Node's `setTimeout` holds an int32, so a relay reporting `3_000_000_000` overflows it and
1905
+ * fires in about ONE MILLISECOND — re-parking instantly into the limit that just refused, and
1906
+ * printing a `TimeoutOverflowWarning` from a daemon whose convention forbids console output.
1907
+ */
1908
+ function scheduleParkRetry(retryAfterMs, filterAgentName, source) {
1909
+ const MIN_RETRY_MS = 1_000;
1910
+ const MAX_RETRY_MS = 5 * 60_000;
1911
+ const delay = Math.min(Math.max(retryAfterMs, MIN_RETRY_MS), MAX_RETRY_MS);
1912
+ if (delay !== retryAfterMs) {
1913
+ logger.warn("content.park.retry.clamped", {
1914
+ source,
1915
+ requestedMs: retryAfterMs,
1916
+ usedMs: delay,
1917
+ impact: "the relay asked for a retry delay outside the range this daemon will schedule, so it was " +
1918
+ "clamped — an unclamped value can overflow the timer and fire immediately, which would " +
1919
+ "re-park straight into the limit that just refused",
1920
+ });
1921
+ }
1922
+ logger.info("content.park.retry.scheduled", { source, delayMs: delay, ...(filterAgentName !== undefined ? { agentName: filterAgentName } : {}) });
1923
+ const timer = setTimeout(() => {
1924
+ parkRetryTimers.delete(timer);
1925
+ void flushAwaitingContent(filterAgentName).catch((err) => {
1926
+ logger.warn("content.park.retry.timer.failed", {
1927
+ source,
1928
+ reason: err instanceof Error ? err.message : String(err),
1929
+ impact: "the scheduled drain threw; the ordinary event triggers (boot, agent start, reconnect) still apply",
1930
+ });
1931
+ });
1932
+ }, delay);
1933
+ timer.unref?.();
1934
+ parkRetryTimers.add(timer);
1935
+ }
1936
+ sessionNodeManager.setContentParkHook(async ({ agentName, sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg }) => {
1515
1937
  const node = sessionNodeManager.getStandingReceiverNode();
1516
1938
  if (!node) {
1517
1939
  const reason = "standing_receiver_unavailable";
@@ -1545,12 +1967,28 @@ async function startDaemonHoldingLock(config, singletonLock) {
1545
1967
  // SEC-1: sealParkEnvelope is the SOLE producer — it signs (sender's K_local, over the
1546
1968
  // session/recipient/content binding) and seals in one place, so the two park sites cannot drift
1547
1969
  // apart on what gets signed.
1970
+ //
1971
+ // `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
1972
+ // MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
1973
+ // is salted is a fact about the message that was sent; what this side holds now says nothing
1974
+ // about it.
1975
+ //
1976
+ // ⛔ THIS COMMENT USED TO SAY "Still `sha256` everywhere, because no send path salts yet — the
1977
+ // plumbing is proven carrying the value that cannot break anything, and only then does the value
1978
+ // change." That described B2b, and B2b-2 then CHANGED THE VALUE: a session holding an agreed
1979
+ // salt hashes under `hmac-sha256-salt-v1`, so this path really does carry a salted algorithm
1980
+ // now. Rewritten rather than deleted — a stale reassurance is what `CLAIM-COMMENTS-1` is for,
1981
+ // and on 2026-08-24 a test declared `sha256` on the strength of this sentence and got a tamper
1982
+ // verdict on an honest message.
1548
1983
  const ciphertext = await sealParkEnvelope({
1549
1984
  signer: senderKp,
1550
1985
  sessionIdHex: sessionId,
1551
1986
  recipientPubkey,
1552
1987
  contentHash: contentHashBytes,
1553
1988
  content,
1989
+ // The algorithm the DIRECT frame named for this same message. The park copy must claim what
1990
+ // the message actually is, not what this side would choose for it now.
1991
+ contentHashAlg,
1554
1992
  structure1Cbor,
1555
1993
  structure2Cbor,
1556
1994
  });
@@ -1565,8 +2003,48 @@ async function startDaemonHoldingLock(config, singletonLock) {
1565
2003
  logger.info("content.park.deposited", { sessionId, contentHash: contentHashHex, recipientPubkey: recipientPubkeyHex.slice(0, 16) });
1566
2004
  return { ok: true };
1567
2005
  }
1568
- logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason: res.reason });
1569
- return { ok: false, reason: res.reason ?? "relay_deposit_failed" };
2006
+ logger.warn("content.park.deposit.failed", {
2007
+ sessionId,
2008
+ contentHash: contentHashHex,
2009
+ reason: res.reason,
2010
+ ...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
2011
+ });
2012
+ /**
2013
+ * DOD-M15-RELAYABUSE-1 — **GIVE THE RELAY'S "WHEN" A CONSUMER.**
2014
+ *
2015
+ * A deferred park is otherwise retried only on EVENTS — boot, agent start, the drain hook, a
2016
+ * signaling reconnect. None of those is coming for a throttle: the relay is healthy, the link
2017
+ * never dropped, and the condition clears on a timer nobody is watching. So the one refusal that
2018
+ * self-heals in about a minute was the one that waited longest, purely because the number the
2019
+ * relay had already computed had no reader.
2020
+ *
2021
+ * Scheduled ONCE per refusal, unref'd so it can never hold the process open, and best-effort:
2022
+ * the existing event triggers remain the guarantee, and this is a shortcut on top of them. It
2023
+ * deliberately does not retry-on-retry — a timer that reschedules itself on failure is a
2024
+ * self-inflicted flood, which is what the limiter exists to stop.
2025
+ */
2026
+ if (res.retryAfterMs !== undefined && res.retryAfterMs > 0) {
2027
+ /**
2028
+ * ⚠️ CLAMPED, because the number comes from ANOTHER PARTY'S SOFTWARE and an unclamped one is
2029
+ * not merely wrong — it inverts the fix. Node's `setTimeout` holds an int32: a relay reporting
2030
+ * `3_000_000_000` overflows it and the timer fires in about **one millisecond**, re-parking
2031
+ * instantly into the limit that just refused, and printing a `TimeoutOverflowWarning` on
2032
+ * stderr from a daemon whose convention forbids console output. A reported `1` does the same
2033
+ * thing 250 ms later.
2034
+ *
2035
+ * The floor is a second and the ceiling five minutes: long enough that a shortcut is a
2036
+ * shortcut, short enough that it stays one. A clamp is announced rather than silent — a relay
2037
+ * asking us to wait 35 days is a fact an operator wants.
2038
+ */
2039
+ scheduleParkRetry(res.retryAfterMs, agentName, "send");
2040
+ }
2041
+ return {
2042
+ ok: false,
2043
+ reason: res.reason ?? "relay_deposit_failed",
2044
+ // DOD-M15-RELAYABUSE-1 review MEDIUM-6: the guidance quotes the relay's OWN window instead of
2045
+ // guessing "about a minute", which is a hardcoded assumption about a configurable value.
2046
+ ...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
2047
+ };
1570
2048
  });
1571
2049
  // CELLO-M7-MSG-001 (AC-004/AC-005, D-d): startup flush of locally-persisted un-acked
1572
2050
  // content (the crash backstop). Runs HERE — before the IPC socket opens, consistent
@@ -1623,12 +2101,28 @@ async function startDaemonHoldingLock(config, singletonLock) {
1623
2101
  // empty after a restart, so arrival order there means a wrong leaf index and a divergent tree.
1624
2102
  // SEC-1: same sole producer as the live hook — the backstop signs from the persisted
1625
2103
  // (sessionId, recipient, contentHash).
2104
+ //
2105
+ // `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
2106
+ // MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
2107
+ // is salted is a fact about the message that was sent; what this side holds now says nothing
2108
+ // about it.
2109
+ //
2110
+ // ⛔ THIS COMMENT USED TO SAY "Still `sha256` everywhere, because no send path salts yet — the
2111
+ // plumbing is proven carrying the value that cannot break anything, and only then does the value
2112
+ // change." That described B2b, and B2b-2 then CHANGED THE VALUE: a session holding an agreed
2113
+ // salt hashes under `hmac-sha256-salt-v1`, so this path really does carry a salted algorithm
2114
+ // now. Rewritten rather than deleted — a stale reassurance is what `CLAIM-COMMENTS-1` is for,
2115
+ // and on 2026-08-24 a test declared `sha256` on the strength of this sentence and got a tamper
2116
+ // verdict on an honest message.
1626
2117
  const ciphertext = await sealParkEnvelope({
1627
2118
  signer: senderKp,
1628
2119
  sessionIdHex: entry.sessionId,
1629
2120
  recipientPubkey,
1630
2121
  contentHash: contentHashBytes,
1631
2122
  content: entry.contentBlob,
2123
+ // The queued row's own record of how it was hashed. `undefined` for a row written before the
2124
+ // column existed, which resolves to `sha256` — exactly what such a row actually used.
2125
+ contentHashAlg: entry.contentHashAlg,
1632
2126
  structure1Cbor: entry.structure1Cbor,
1633
2127
  structure2Cbor: entry.structure2Cbor,
1634
2128
  });
@@ -1643,7 +2137,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
1643
2137
  logger.info("content.park.deposited", { sessionId: entry.sessionId, contentHash: entry.contentHashHex, source: "startup_flush" });
1644
2138
  return { parked: true };
1645
2139
  }
1646
- return { parked: false, error: res.reason ?? "deposit_failed" };
2140
+ return {
2141
+ parked: false,
2142
+ error: res.reason ?? "deposit_failed",
2143
+ // DOD-M15-RELAYABUSE-1 review HIGH-2: carry the relay's own "when" out of the DRAIN path too.
2144
+ ...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
2145
+ };
1647
2146
  };
1648
2147
  // Re-park un-acked awaiting content to the relay store-and-forward queue. Runs once pre-IPC
1649
2148
  // (the crash backstop) and again per-agent when an agent comes online — because post-DOD-LOOP-1
@@ -1701,7 +2200,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
1701
2200
  let parkedTotal = 0;
1702
2201
  for (const s of sessions) {
1703
2202
  try {
1704
- parkedTotal += await retryQueue.drainAwaitingToPark(s.agentId, s.sessionId, parkFn);
2203
+ parkedTotal += await retryQueue.drainAwaitingToPark(s.agentId, s.sessionId, parkFn, (retryAfterMs) => {
2204
+ /**
2205
+ * DOD-M15-RELAYABUSE-1 review HIGH-2 — **the drain path can now schedule its own
2206
+ * follow-up, which is what makes the retry work for a BACKLOG rather than one message.**
2207
+ *
2208
+ * Before this, only the live send path heard the relay's delay. With a backlog larger than
2209
+ * one rate-limit window, the drain deposited what the window allowed, every remaining item
2210
+ * was refused, and all of them fell back to waiting for an unrelated reconnect — the exact
2211
+ * condition the retry timer was added to remove.
2212
+ *
2213
+ * ONE timer per pass (the drain reports the largest delay it saw, not one per item), and
2214
+ * it deliberately does not chain beyond that: the next pass schedules the next one only if
2215
+ * it is refused again, so a permanently-full relay costs one timer per window rather than
2216
+ * an accelerating stream of them.
2217
+ */
2218
+ scheduleParkRetry(retryAfterMs, filterAgentName, "drain");
2219
+ });
1705
2220
  }
1706
2221
  catch (err) {
1707
2222
  logger.error("content.park.flush.failed", {
@@ -1789,12 +2304,39 @@ async function startDaemonHoldingLock(config, singletonLock) {
1789
2304
  return {
1790
2305
  name: a.name,
1791
2306
  state,
2307
+ /**
2308
+ * `selected` IS THIS CONNECTION'S VIEW, NOT THE AGENT'S — `DOD-M15-IPCVISIBLE-1` clause 3.
2309
+ *
2310
+ * Every `cello` CLI invocation opens a FRESH connection, which starts with no current
2311
+ * agent. So a client asking about its own state through the CLI always reads `false`, for
2312
+ * an agent it genuinely has selected in another session. Both Andre and a Hermes agent
2313
+ * misread it that way during one investigation, in opposite directions.
2314
+ *
2315
+ * The field name cannot be changed without breaking every reader, so it is ANNOTATED: the
2316
+ * sibling below says whose view this is, and `attended_by` says how many connections hold
2317
+ * this agent at all — which is the question people were actually asking.
2318
+ */
1792
2319
  selected,
2320
+ selected_by_this_connection: selected,
2321
+ attended_by: countAttendance(perConnectionState, a.name),
1793
2322
  pubkey: a.pubkey,
1794
2323
  // M8B F14 (fix 5): per-agent standing-receiver readiness on the MCP surface
1795
2324
  // (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
1796
2325
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
1797
2326
  standing_receiver_reachability: sessionNodeManager.getStandingReceiverReachability(a.name),
2327
+ /**
2328
+ * DOD-M15-RELAYSLOTS-1: WHY it is not reachable, and what to do about it.
2329
+ *
2330
+ * `standing_receiver_reachability` says `retrying` or `unreachable` and stops there, which
2331
+ * for the person reading it is indistinguishable from the product being broken. The relay
2332
+ * now refuses for reasons someone can act on — no token from a directory yet, too many
2333
+ * sessions still open, this relay is misconfigured — each with a different next step, and
2334
+ * every one of them is wasted if it only reaches a log file. Absent when the last attempt
2335
+ * succeeded.
2336
+ */
2337
+ ...(sessionNodeManager.getStandingReceiverRefusal(a.name)
2338
+ ? { standing_receiver_refusal: sessionNodeManager.getStandingReceiverRefusal(a.name) }
2339
+ : {}),
1798
2340
  // DOD-COATTEND-VISIBLE-1 AC2: how many sessions are driving this agent, including this
1799
2341
  // one. Live, not a high-water mark — it drops when a session disconnects. `selected` says
1800
2342
  // whether YOU hold it; this says whether anyone else does too.
@@ -2005,6 +2547,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
2005
2547
  //
2006
2548
  // Omitted entirely when nothing is failing, so a healthy status stays quiet.
2007
2549
  ...(unresolvedNodesForStatus() ?? {}),
2550
+ // DOD-M15-MANIFEST-EXPIRY-LIVE-1: contributes NOTHING while the manifest is comfortably in
2551
+ // window. A field present on every status read for the years a manifest is valid is furniture,
2552
+ // not a warning, and it teaches the reader to skip the block that matters.
2553
+ ...(describeManifestValidity(classifyManifestValidity(manifestProvider?.getCurrentManifest() ?? null, Date.now()), manifestOrigin) ?? {}),
2554
+ // DOD-M15-DIRAUTH-1: the posture is STATED, in both directions. Unlike every other field in
2555
+ // this milestone the healthy case is reported too — the defect is precisely that "enforced"
2556
+ // and "skipped" differ only by the absence of a log line, so an operator must be able to
2557
+ // confirm it is on, not merely fail to find evidence that it is off.
2558
+ ...describeDirectoryAuth({
2559
+ verifierPresent: challengeVerifier !== undefined,
2560
+ directoryUrl: directoryHttpUrl ?? resolveDirectoryUrl(process.env),
2561
+ // Review F5: with neither set, `resolveDirectoryUrl` re-picks a RANDOM bundled endpoint on
2562
+ // every call, so there is no configured URL to quote or to blame.
2563
+ urlExplicitlyConfigured: directoryHttpUrl !== undefined || process.env["CELLO_DIRECTORY_URL"] !== undefined,
2564
+ }),
2008
2565
  // M8B F14 (fix 5): per-agent standing-receiver readiness, so a deaf agent (online but
2009
2566
  // no armed receiver) is visible in cello_status instead of hiding behind the ANY-agent
2010
2567
  // aggregate below (kept for backward compatibility).
@@ -2020,6 +2577,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
2020
2577
  state: agentStateFor(a),
2021
2578
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
2022
2579
  standing_receiver_reachability: sessionNodeManager.getStandingReceiverReachability(a.name),
2580
+ // DOD-M15-RELAYSLOTS-1: the same cause-and-advice on the daemon-wide surface — see the note
2581
+ // on the MCP one above. Two surfaces, one reason to exist.
2582
+ ...(sessionNodeManager.getStandingReceiverRefusal(a.name)
2583
+ ? { standing_receiver_refusal: sessionNodeManager.getStandingReceiverRefusal(a.name) }
2584
+ : {}),
2023
2585
  })),
2024
2586
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
2025
2587
  retryQueueDepth: retryQueue.getTotalDepth(),
@@ -2051,14 +2613,37 @@ async function startDaemonHoldingLock(config, singletonLock) {
2051
2613
  // Permissive by design (D12): an agent that exists goes online regardless of directory
2052
2614
  // registration state (online-without-registration is an established contract). Returns a
2053
2615
  // structured failure so callers can surface agent_start_failed with a real reason + guidance.
2616
+ /**
2617
+ * `standing_receiver` is part of the SUCCESS shape, not an optional extra — `DOD-M15-START-AGENT-
2618
+ * UNAWAITED-1`. A bare `{ ok: true }` claimed the agent was started and reachable when only the
2619
+ * first half was known, and the union makes the two states impossible to conflate at a call site.
2620
+ */
2054
2621
  function startAgentInternal(name) {
2055
2622
  const agent = agents.find((a) => a.name === name);
2056
2623
  if (!agent || agent.state === "load_failed") {
2057
2624
  return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Run 'cello login' to register agents, or check agent names with cello_agents.` };
2058
2625
  }
2059
2626
  if (onlineAgents.has(name)) {
2060
- // Idempotent — already online, no event
2061
- return { ok: true };
2627
+ // Idempotent — already online, no event.
2628
+ //
2629
+ // It still reports REAL readiness rather than a bare ok. "Already online" says this daemon
2630
+ // marked the agent online at some earlier moment; it says nothing about whether the receiver
2631
+ // that ensure was firing ever came up. An operator who calls start twice — which is exactly
2632
+ // what someone does when the first one seemed not to work — would otherwise get the most
2633
+ // reassuring answer in the run on the attempt where something is actually wrong.
2634
+ const readyNow = sessionNodeManager.getStandingReceiverInfo(name) !== null;
2635
+ if (readyNow)
2636
+ return { ok: true, standing_receiver: "ready" };
2637
+ const cause = sessionNodeManager.standingReceiverAbsenceReason(name);
2638
+ return {
2639
+ ok: true,
2640
+ standing_receiver: "starting",
2641
+ standing_receiver_cause: cause,
2642
+ guidance: `'${name}' was already online, and its standing receiver is not up (${cause}). Outbound ` +
2643
+ `sends and cello_initiate_session ensure it on demand. An inbound session arriving before ` +
2644
+ `it is ready is refused with 'standing_receiver_unavailable' — this daemon, not the ` +
2645
+ `counterparty. If it stays this way, stop the agent and start it again.`,
2646
+ };
2062
2647
  }
2063
2648
  onlineAgents.add(name);
2064
2649
  // Pressing start clears the deliberate-offline mark — that is what makes the switch reversible.
@@ -2084,7 +2669,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
2084
2669
  void sessionNodeManager.ensureStandingReceiverForAgent(name)
2085
2670
  .then(() => flushAwaitingContent(name))
2086
2671
  .catch((err) => {
2087
- logger.warn("session.standing_receiver.ensure.failed", { agentName: name, reason: extractErrorMessage(err) });
2672
+ logger.warn("session.standing_receiver.ensure.failed", {
2673
+ agentName: name,
2674
+ reason: extractErrorMessage(err),
2675
+ // `DOD-M15-START-AGENT-UNAWAITED-1`. The operator has ALREADY been told `ok: true` — this
2676
+ // handler answered before this promise settled — so nothing corrects that answer if this
2677
+ // is permanent. Say what it costs them here, because this line is the only account.
2678
+ impact: "cello_start_agent already answered ok for this agent, and its standing receiver did not " +
2679
+ "come up. The agent is online to the directory and CANNOT accept an inbound session: a " +
2680
+ "counterparty dialling it is refused standing_receiver_unavailable. Initiate and accept " +
2681
+ "each re-ensure on demand, so this may still recover on the next attempt; if it does not, " +
2682
+ "stop and restart the agent.",
2683
+ });
2088
2684
  })
2089
2685
  // DOD-MSG-4 (auto-recover-on-reconnect): RECEIVER drains its parked mailbox from every relay it
2090
2686
  // has sessions on (symmetric to the sender re-park). Its own stage so a failure is labelled
@@ -2094,10 +2690,51 @@ async function startDaemonHoldingLock(config, singletonLock) {
2094
2690
  .catch((err) => {
2095
2691
  logger.warn("content.recover.auto.failed", { agentName: name, stage: "agent_start", error: extractErrorMessage(err) });
2096
2692
  });
2097
- logger.info("agent.online", { agentName: name, agentPubkey: agent.pubkey ?? "" });
2693
+ /**
2694
+ * `DOD-M15-START-AGENT-UNAWAITED-1` — SAY WHETHER THE AGENT CAN ACTUALLY HEAR YET.
2695
+ *
2696
+ * The ensure above is fire-and-forget and that is deliberate: initiate and accept both ensure on
2697
+ * demand, and awaiting it here would turn a transient network failure into a failed start. **The
2698
+ * defect was never the timing — it was the CLAIM.** `{ ok: true }` with nothing else reads as
2699
+ * "your agent is running and reachable", and a session landing in the window before the receiver
2700
+ * exists is refused `standing_receiver_unavailable` — a precondition on OUR side, surfacing to
2701
+ * the operator as though the counterparty or the directory were at fault.
2702
+ *
2703
+ * ⚠️ **This field is only worth having because it can genuinely say `ready`.** Computed one line
2704
+ * after firing an async ensure, a naive readiness flag would be `starting` on every call — a
2705
+ * field that can never take its other value, which is the same defect as a log line reporting a
2706
+ * verdict its producer cannot have. It escapes that because `ensureStandingReceiverForAgent` is
2707
+ * IDEMPOTENT: an agent that already holds a receiver (a repeat start, or one whose receiver
2708
+ * survived) has one at this instant and reports `ready` truthfully.
2709
+ *
2710
+ * `cause` is read from the same four-way answer the refusal path uses, so the response and the
2711
+ * eventual error agree instead of describing the same state in two vocabularies.
2712
+ */
2713
+ const receiverReady = sessionNodeManager.getStandingReceiverInfo(name) !== null;
2714
+ const startingCause = receiverReady ? undefined : sessionNodeManager.standingReceiverAbsenceReason(name);
2715
+ logger.info("agent.online", {
2716
+ agentName: name,
2717
+ agentPubkey: agent.pubkey ?? "",
2718
+ standingReceiver: receiverReady ? "ready" : "starting",
2719
+ ...(startingCause !== undefined ? { standingReceiverCause: startingCause } : {}),
2720
+ });
2098
2721
  // MCP-002: Broadcast agent_state_changed to ALL connections
2099
2722
  notificationDispatcher.dispatchAgentStateChanged(name, "online", "started");
2100
- return { ok: true };
2723
+ if (receiverReady)
2724
+ return { ok: true, standing_receiver: "ready" };
2725
+ return {
2726
+ ok: true,
2727
+ standing_receiver: "starting",
2728
+ standing_receiver_cause: startingCause,
2729
+ // Invariant: an agent-facing response carries an affordance. Naming the refusal text is the
2730
+ // load-bearing half — an operator who hits it in the next second can otherwise only conclude
2731
+ // the other side is broken.
2732
+ guidance: `'${name}' is online and its standing receiver is still being built. Outbound sends and ` +
2733
+ `cello_initiate_session ensure it on demand, so ordinary use is fine. A session arriving in ` +
2734
+ `the next moment can be refused with 'standing_receiver_unavailable' — that is this daemon ` +
2735
+ `not being ready yet, NOT the counterparty being unreachable. It clears on its own; ` +
2736
+ `cello_status reports the receiver once it is up.`,
2737
+ };
2101
2738
  }
2102
2739
  // M8C-AUTOSTART-1 (F18): resolve which agent an agent-defaulting tool should act on for this
2103
2740
  // connection: an explicit { agent } wins; else the connection's current agent; else — when EXACTLY one
@@ -2117,16 +2754,95 @@ async function startDaemonHoldingLock(config, singletonLock) {
2117
2754
  * alice, alice was stopped, and the work lands on bob reporting success. A lost intent is not the
2118
2755
  * same as no intent, and it must fail loud (no_current_agent) rather than be guessed at.
2119
2756
  */
2757
+ /**
2758
+ * Which agent this call acts as — `DOD-M15-SELECTION-1`, logic in `agent-selection.ts`.
2759
+ *
2760
+ * It used to end with `if (onlineAgents.size === 1) return [...onlineAgents][0]`, so a connection
2761
+ * that had selected nothing acted as whichever agent happened to be the only one online. On a
2762
+ * shared daemon that can be a DIFFERENT operator's agent, and a live MCP session was being bound
2763
+ * to an identity it never asked for.
2764
+ *
2765
+ * The resolution is now attributable: every path reports how it was reached, and `fallback` — the
2766
+ * one that was invisible — is logged at INFO. `DOD-M15-IPCVISIBLE-1`.
2767
+ */
2768
+ /**
2769
+ * The fallback notice owed to THIS REQUEST's response — `DOD-M15-SELECTION-1` clause 2.
2770
+ *
2771
+ * ─── Why an AsyncLocalStorage and not a per-connection map ─────────────────────────────────────
2772
+ *
2773
+ * The first cut keyed a `WeakMap` on the per-connection state object, so the notice belonged to
2774
+ * the CONNECTION and was read back at the response boundary. Review found that hands the notice to
2775
+ * whichever response finishes first, and Claude Code issues tool calls in parallel:
2776
+ *
2777
+ * 1. the agent calls `cello_receive` — nothing selected, so the fallback resolves and records
2778
+ * the notice, then the handler BLOCKS for up to 30 s waiting for content;
2779
+ * 2. in the same turn it calls `cello_sessions {agent: "bob"}`, which names an agent explicitly
2780
+ * and never falls back;
2781
+ * 3. `cello_sessions` returns first and takes the notice on its way out.
2782
+ *
2783
+ * Bob's response now says *"no agent was selected, so 'solo' was used"* — false, on the one call
2784
+ * that did name an agent — and `cello_receive`, the call that actually fell back, says nothing.
2785
+ * Exactly inverted.
2786
+ *
2787
+ * A notice is a fact about ONE CALL, so it is stored in that call's async context. The store is
2788
+ * created per request in `renderedHandlers` and dies with it, which also means a handler that
2789
+ * THROWS cannot leave a notice behind to attach itself to some later, unrelated response — the
2790
+ * other half of the same review finding.
2791
+ */
2792
+ const fallbackNoticeStore = new AsyncLocalStorage();
2120
2793
  function resolveCurrentAgent(connState, explicitAgent) {
2121
- if (explicitAgent)
2122
- return explicitAgent;
2123
- if (connState?.currentAgent)
2124
- return connState.currentAgent;
2125
- if (connState?.clearedAgent)
2126
- return null;
2127
- if (onlineAgents.size === 1)
2128
- return [...onlineAgents][0];
2129
- return null;
2794
+ return resolveCurrentAgentFor({
2795
+ connState,
2796
+ onlineAgents,
2797
+ ...(explicitAgent !== undefined ? { explicitAgent } : {}),
2798
+ onResolved: (agent, trigger) => {
2799
+ // Only the FALLBACK is announced. `explicit` and `selected` are the ordinary cases and
2800
+ // logging them would bury the one that matters — a signal that fires on the normal case is
2801
+ // not a signal.
2802
+ if (trigger !== "fallback")
2803
+ return;
2804
+ /**
2805
+ * RECORDED FOR THE RESPONSE, not just the log — `DOD-M15-SELECTION-1` clause 2.
2806
+ *
2807
+ * The log tells whoever reads the daemon log. The RESPONSE tells the agent that just acted
2808
+ * as an identity it never selected, which is the one that stops the half-attended state
2809
+ * being read as the protocol dropping messages.
2810
+ */
2811
+ /**
2812
+ * No `connState` guard. The first cut wrote the notice only `if (agent && connState)`, and
2813
+ * `perConnectionState` is populated at `ipc.connect` — which `withIpc` in the CLI
2814
+ * (`core/cli/src/commands.ts`) does not send. So every plain `cello` invocation fell back
2815
+ * and got a response that said nothing, while the log line below fired regardless: the
2816
+ * clause asks for explicit IN THE RESPONSE, and those callers had it explicit in the log
2817
+ * only. The store is per-REQUEST, so it exists whether or not the connection ever
2818
+ * handshook.
2819
+ */
2820
+ const store = fallbackNoticeStore.getStore();
2821
+ if (agent && store) {
2822
+ store.notice = {
2823
+ acting_as: agent,
2824
+ agent_selection: "fallback",
2825
+ agent_selection_guidance: `No agent was selected on this connection, so '${agent}' was used because it is the ` +
2826
+ `only one online. This is a per-call subject, NOT an attendance: doorbells route by ` +
2827
+ `the connection's registered agent and this does not set it. Two things follow — ` +
2828
+ `this session will not WAKE on an incoming message even though sending and reading ` +
2829
+ `work, and anyone who opens a session with you is sent an AWAY auto-reply while you ` +
2830
+ `sit here able to answer. Run cello_use_agent to fix both. Naming the agent on each ` +
2831
+ `call is NOT a remedy for either: it settles which agent a call is about and leaves ` +
2832
+ `the connection just as unattended.`,
2833
+ };
2834
+ }
2835
+ logger.info("agent.current.fallback", {
2836
+ agentName: agent,
2837
+ clientType: connState?.clientType ?? "cli",
2838
+ impact: "this call had no selected agent and exactly one was online, so that one was used. It " +
2839
+ "is a per-call subject, NOT an attendance: doorbells route by the connection's " +
2840
+ "registered agent, which this does not set.",
2841
+ guidance: "If this was not the intended agent, name it explicitly, or run cello_use_agent to " +
2842
+ "select one for the connection.",
2843
+ });
2844
+ },
2845
+ });
2130
2846
  }
2131
2847
  // Agent lifecycle (agent-handlers.ts): create, remove, start, stop, select, list.
2132
2848
  registerAgentHandlers({
@@ -2216,6 +2932,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
2216
2932
  agentName,
2217
2933
  persistence: getPersistence(agentName),
2218
2934
  agentPubkeyHex: loaded.pubkey,
2935
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
2936
+ verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
2219
2937
  keyProvider: loaded.keyProvider,
2220
2938
  getNode: entry.getNode,
2221
2939
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -2260,6 +2978,28 @@ async function startDaemonHoldingLock(config, singletonLock) {
2260
2978
  subject_kind: r.subjectKind,
2261
2979
  subject: r.subject,
2262
2980
  issuer_kind: r.issuerKind,
2981
+ /**
2982
+ * DOD-M15-SAMEOP-FALSEPOS-1: WHO ISSUED IT. The listing carried `subject` — who the signal is
2983
+ * ABOUT — and never who SAID it, so an operator could see "someone endorsed me" and not see
2984
+ * who. For the decision this list exists to support (do I rely on this endorsement?) the
2985
+ * author is the primary fact, and an endorsement nobody can attribute is worth roughly
2986
+ * nothing.
2987
+ *
2988
+ * `issuer_kind` reads like it answers this and does not: it says `"agent"` or `"portal"`, a
2989
+ * category. Elsewhere a field literally named `issuer` holds `"peer-claimed"` /
2990
+ * `"platform-verified"` — also a category. Two fields whose names promise identity, neither
2991
+ * carrying it.
2992
+ *
2993
+ * It also unblocks an investigation this omission stalled: four endorsements in a wallet all
2994
+ * read `same_operator: true`, and **nothing in the response could say whether the stranger's
2995
+ * was among them** — so "a stranger is flagged as self-dealing" and "the stranger's
2996
+ * endorsement never arrived" were indistinguishable from the listing. Different bugs, in
2997
+ * different places, and the field that separates them was already in the row.
2998
+ *
2999
+ * Discloses nothing new: the issuer pubkey is inside the notarized envelope the recipient
3000
+ * already holds and can already present.
3001
+ */
3002
+ issuer_pubkey: r.issuerPubkey,
2263
3003
  status: r.status,
2264
3004
  issued_at: r.issuedAt,
2265
3005
  expires_at: r.expiresAt,
@@ -3049,32 +3789,78 @@ async function startDaemonHoldingLock(config, singletonLock) {
3049
3789
  });
3050
3790
  // ─── MCP-001: cello_status (per-connection perspective) ───
3051
3791
  /**
3052
- * The directory-reachability block for `cello_status`, or undefined when every node resolves.
3792
+ * The directory-reachability block for `cello_status`.
3793
+ *
3794
+ * `undefined` — i.e. silence — is reserved for ONE state: a reading taken recently enough to
3795
+ * speak for the present, which found nothing wrong. Every other combination emits, including an
3796
+ * EMPTY node list, because an empty list is not proof of health: it also means nothing has
3797
+ * looked, or looked too long ago to say. `DOD-M15-STALEROSTER-1`.
3053
3798
  *
3054
- * Reports the LAST resolve sweep. An empty list is not proof of health it also means no sweep
3055
- * has run yet so the shape says which nodes failed and why rather than asserting "all good".
3799
+ * So presence of this block does NOT mean nodes are failing. Read `measurement` first
3800
+ * `current` | `stale` | `never` | `not_configured` and only then the node list.
3056
3801
  */
3057
3802
  function unresolvedNodesForStatus() {
3058
3803
  const failures = getUnresolvedNodes();
3059
- if (failures.length === 0)
3804
+ /**
3805
+ * DOD-M15-STALEROSTER-1 — the block is now gated on the AGE of the reading, not just on whether
3806
+ * it found anything.
3807
+ *
3808
+ * `if (failures.length === 0) return undefined` made two different states render identically:
3809
+ * "all three nodes answered a moment ago" and "nothing has ever looked". The second is
3810
+ * reachable — `verifyStartupManifest` returns without sweeping when the consortium manifest is
3811
+ * missing, not yet valid, EXPIRED, or rolled back — so a daemon with an expired manifest
3812
+ * reported no directory trouble at all.
3813
+ *
3814
+ * Silence is therefore reserved for the one case that has earned it: a RECENT reading that
3815
+ * found nothing wrong. Every other case says why it cannot make that claim.
3816
+ */
3817
+ const freshness = describeRosterFreshness(classifyRosterReading(getUnresolvedSweptAt(), Date.now()), {
3818
+ // REVIEW F2: "no manifest configured" is DESIGNED (local dev, the e2e harness, or a
3819
+ // CELLO_DIRECTORY_URL that is not byte-equal to a bundled endpoint) and must not be dressed
3820
+ // as an alarm — it would fire on every local run. It still EMITS, because the line forbids
3821
+ // hiding the field; what differs is what the operator is told.
3822
+ manifestConfigured: manifestProvider !== undefined,
3823
+ ...(lastRosterSweepError ? { lastSweepError: lastRosterSweepError } : {}),
3824
+ });
3825
+ if (failures.length === 0 && freshness.measurement === "current")
3060
3826
  return undefined;
3061
3827
  return {
3062
3828
  directory_endpoints_unresolved: {
3063
- // WHEN this was measured. Without it the block asserts the PRESENT, and a transient blip
3064
- // reads as an ongoing outage: on 2026-08-09 all three endpoints failed with ENETUNREACH for
3065
- // under a minute a network transition on the operator's machine and the block went on
3066
- // reporting them unreachable long after they answered again. True when taken, false when read.
3067
- checked_at: getUnresolvedSweptAt(),
3068
- nodes: failures.map((f) => ({ node: f.nodeId, endpoint: f.endpoint, reason: f.reason, detail: f.detail })),
3069
- guidance: "AS OF checked_at (this is a point-in-time reading, not necessarily now), this daemon could not "
3070
- + "resolve these directory endpoints, so the consortium roster was short and " +
3071
- "threshold ceremonies will failsessions surface that as counterparty_offline, " +
3072
- "directory_below_threshold, or ceremony_exhausted, none of which name the real cause. " +
3073
- "Agents can still show 'online': signaling dials multiaddrs and does not need DNS. " +
3074
- "If reason is dns_error after a directory restart or wake, the resolver is holding a cached " +
3075
- "negative answer flush it (macOS: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder). " +
3076
- "Verify with node -e 'require(\"dns\").lookup(host,console.log)', NOT dig: dig bypasses the " +
3077
- "cache this daemon is stuck behind, so it reports success while the daemon still fails.",
3829
+ // WHEN this was measured, and whether that is recent enough to mean anything. Without it the
3830
+ // block asserts the PRESENT, and a transient blip reads as an ongoing outage: on 2026-08-09
3831
+ // all three endpoints failed with ENETUNREACH for under a minutea network transition on
3832
+ // the operator's machine and the block went on reporting them unreachable long after they
3833
+ // answered again. True when taken, false when read.
3834
+ ...freshness,
3835
+ // DOD-M15-BOOTSTRAP-1: `attempts` distinguishes a node that answered definitively (one
3836
+ // probe a 404, a bad payload, its configuration) from one that never answered at all
3837
+ // (every probe spentthe path to it). Those call for opposite responses, and without the
3838
+ // count they rendered identically here.
3839
+ nodes: failures.map((f) => ({ node: f.nodeId, endpoint: f.endpoint, reason: f.reason, detail: f.detail, attempts: f.attempts })),
3840
+ guidance: failures.length === 0
3841
+ // The block is present with an EMPTY node list, which before DOD-M15-STALEROSTER-1 could
3842
+ // not happen. Saying "could not resolve these endpoints" here would be a flat lie — there
3843
+ // are no endpoints listed and the point is that nothing was measured. The empty list is
3844
+ // the ABSENCE of a reading, not a clean bill of health, and freshness_guidance above says
3845
+ // which of the two it is.
3846
+ ? "This block is present with NO nodes listed, which does not mean the nodes are healthy — " +
3847
+ "it means this reading cannot support that claim. See freshness_guidance above for " +
3848
+ "whether the daemon has never measured, or measured too long ago to speak for the " +
3849
+ "present. Directory reachability is what threshold ceremonies depend on, so an " +
3850
+ "unmeasured roster is an unknown, not an all-clear."
3851
+ : "AS OF checked_at (this is a point-in-time reading, not necessarily now), this daemon could not "
3852
+ + "resolve these directory endpoints, so the consortium roster was short and " +
3853
+ "threshold ceremonies will fail — sessions surface that as home_node_reports_no_receiver, " +
3854
+ "home_node_not_in_reachable_roster, directory_named_no_home, directory_below_threshold or " +
3855
+ "ceremony_exhausted, none of which name the real cause on their own (DOD-M15-ERRSTRING-1 " +
3856
+ "renamed the first three; they now append this shortfall to their own guidance). " +
3857
+ "Agents can still show 'online': signaling dials multiaddrs and does not need DNS. " +
3858
+ "If reason is dns_error after a directory restart or wake, the resolver is holding a cached " +
3859
+ "negative answer — flush it (macOS: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder). " +
3860
+ "Verify with node -e 'require(\"dns\").lookup(host,console.log)', NOT dig: dig bypasses the " +
3861
+ "cache this daemon is stuck behind, so it reports success while the daemon still fails. "
3862
+ + "attempts:1 means the node ANSWERED and the answer was unusable — look at that node. "
3863
+ + "attempts:2+ means it never answered — look at the path to it.",
3078
3864
  },
3079
3865
  };
3080
3866
  }
@@ -3093,6 +3879,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
3093
3879
  //
3094
3880
  // Omitted entirely when nothing is failing, so a healthy status stays quiet.
3095
3881
  ...(unresolvedNodesForStatus() ?? {}),
3882
+ // DOD-M15-MANIFEST-EXPIRY-LIVE-1: contributes NOTHING while the manifest is comfortably in
3883
+ // window. A field present on every status read for the years a manifest is valid is furniture,
3884
+ // not a warning, and it teaches the reader to skip the block that matters.
3885
+ ...(describeManifestValidity(classifyManifestValidity(manifestProvider?.getCurrentManifest() ?? null, Date.now()), manifestOrigin) ?? {}),
3886
+ // DOD-M15-DIRAUTH-1: the posture is STATED, in both directions. Unlike every other field in
3887
+ // this milestone the healthy case is reported too — the defect is precisely that "enforced"
3888
+ // and "skipped" differ only by the absence of a log line, so an operator must be able to
3889
+ // confirm it is on, not merely fail to find evidence that it is off.
3890
+ ...describeDirectoryAuth({
3891
+ verifierPresent: challengeVerifier !== undefined,
3892
+ directoryUrl: directoryHttpUrl ?? resolveDirectoryUrl(process.env),
3893
+ // Review F5: with neither set, `resolveDirectoryUrl` re-picks a RANDOM bundled endpoint on
3894
+ // every call, so there is no configured URL to quote or to blame.
3895
+ urlExplicitlyConfigured: directoryHttpUrl !== undefined || process.env["CELLO_DIRECTORY_URL"] !== undefined,
3896
+ }),
3096
3897
  agents: getAgentsForConnection(connectionId),
3097
3898
  // M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
3098
3899
  // the daemon-wide getStatus() surfaces them.
@@ -3173,6 +3974,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
3173
3974
  // cello_close_session (close-session-handler.ts). Fifteen dependencies — a long list, but a KNOWN
3174
3975
  // one, which is the whole difference from a closure over 73 shared locals.
3175
3976
  registerCloseSessionHandler({
3977
+ // DOD-M15-CLOSEWAIT-1 review MEDIUM-6: a detached seal tail is registered here so stop() can
3978
+ // drain it, like every other background worker. Self-evicting, so a long-running daemon does not
3979
+ // accumulate settled promises.
3980
+ sealFailures,
3981
+ registerBackgroundSeal: (p) => {
3982
+ backgroundSeals.add(p);
3983
+ void p.finally(() => backgroundSeals.delete(p));
3984
+ },
3176
3985
  // M12-P14: the pre-seal readiness gate drains the parked mailbox before judging, so a close
3177
3986
  // does not refuse over content the relay is still holding for us.
3178
3987
  recoverParkedContent: (agentName, trigger) => autoRecoverForAgent(agentName, trigger),
@@ -3210,9 +4019,74 @@ async function startDaemonHoldingLock(config, singletonLock) {
3210
4019
  handleActiveSealFlow,
3211
4020
  resolveConsortiumRoster,
3212
4021
  });
4022
+ /**
4023
+ * DOD-M15-BACKUP-1 — export the agent, so losing the machine does not lose the identity.
4024
+ *
4025
+ * `VACUUM INTO` takes a consistent snapshot with the database still open, so this is safe to call
4026
+ * on a running daemon — which matters, because the daemon is what serves the tool.
4027
+ */
4028
+ handlers.set("cello_backup", async (params, _connectionId) => {
4029
+ const outPath = typeof params?.["path"] === "string" ? params["path"] : "";
4030
+ if (!outPath) {
4031
+ return {
4032
+ ok: false,
4033
+ reason: "missing_path",
4034
+ guidance: "Give an absolute path to write the backup to, e.g. { path: \"/Users/you/cello-agent.cello-backup\" }. " +
4035
+ "It is not written to a default location on purpose: the file contains the key to your agent, " +
4036
+ "so where it lands is a decision you should make deliberately.",
4037
+ };
4038
+ }
4039
+ const res = await createBackup({
4040
+ dbPath: join(celloDir, "sessions.db"),
4041
+ outPath,
4042
+ logger,
4043
+ ...(params?.["overwrite"] === true ? { overwrite: true } : {}),
4044
+ });
4045
+ return res.ok
4046
+ ? { ok: true, path: res.path, bytes: res.bytes, guidance: res.guidance }
4047
+ : { ok: false, reason: res.reason, guidance: res.guidance };
4048
+ });
4049
+ /**
4050
+ * RESTORE IS REFUSED WHILE THIS DAEMON IS RUNNING, and that is not a limitation to apologise for.
4051
+ *
4052
+ * The daemon holds the database open with a write lock. Overwriting the file underneath an open
4053
+ * SQLite handle risks the handle flushing its own pages back over the restored ones — the restore
4054
+ * appears to succeed and the database is a hybrid of two identities, which is worse than either
4055
+ * failing cleanly.
4056
+ *
4057
+ * The capability itself lives in `backup-restore.ts` (a daemon module, per the DoD), and the CLI
4058
+ * calls it with the daemon stopped. So the honest answer here is the exact sequence, not an
4059
+ * attempt.
4060
+ */
4061
+ handlers.set("cello_restore", async (params, _connectionId) => {
4062
+ const archivePath = typeof params?.["path"] === "string" ? params["path"] : "";
4063
+ const inspected = archivePath ? await inspectBackup(archivePath) : null;
4064
+ return {
4065
+ ok: false,
4066
+ reason: "daemon_running",
4067
+ ...(inspected?.ok
4068
+ ? { archive_verified: true, backup_created_at: new Date(inspected.createdAt).toISOString() }
4069
+ : inspected
4070
+ ? { archive_verified: false, archive_problem: inspected.reason }
4071
+ : {}),
4072
+ guidance: (inspected?.ok
4073
+ ? `That archive is valid (taken ${new Date(inspected.createdAt).toISOString()}). `
4074
+ : inspected
4075
+ ? `WARNING: that archive did not validate (${inspected.reason}) — fix that before going further. `
4076
+ : "") +
4077
+ "Restoring REPLACES this machine's agent database, and it cannot be done while the daemon " +
4078
+ "is holding it open — the running daemon could flush its own pages back over the restored " +
4079
+ "ones and leave a database that is half one identity and half another. Stop the daemon " +
4080
+ "first:\n\n cello logout\n cello restore " +
4081
+ (archivePath || "<archive>") +
4082
+ "\n cello login\n\n" +
4083
+ "Anything that happened on this machine since the backup was taken will be gone — restore " +
4084
+ "replaces, it does not merge.",
4085
+ };
4086
+ });
3213
4087
  // ─── MCP-001: stubs for tools registered in cello-mcp.ts but not yet implemented ───
3214
4088
  // These return not_implemented (same as session tools) so LLMs get consistent guidance.
3215
- for (const tool of ["cello_backup", "cello_restore", "cello_get_inclusion_proof"]) {
4089
+ for (const tool of ["cello_get_inclusion_proof"]) {
3216
4090
  handlers.set(tool, async (_params, _connectionId) => {
3217
4091
  return { ok: false, reason: "not_implemented", guidance: `'${tool}' is not yet implemented in the daemon. This feature will be available in a future milestone.` };
3218
4092
  });
@@ -3234,6 +4108,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
3234
4108
  logger,
3235
4109
  sessionNodeManager,
3236
4110
  loadedAgents,
4111
+ // DOD-M15-CLOSEWAIT-1 review HIGH-2: the SAME predicate cello_status uses, so the two surfaces
4112
+ // cannot disagree about whether a ceremony is in flight. Both maps, because either can be the
4113
+ // one running — pendingSealWaiters is the active close, sealInterruptedInProgress the interrupted.
4114
+ isSealing: (agentName, sessionId) => pendingSealWaiters.has(sealKey(agentName, sessionId)) ||
4115
+ sealInterruptedInProgress.has(sealKey(agentName, sessionId)),
4116
+ // DOD-M15-SEAL-FAILED-TERMINAL-1: the SAME store the close handler writes, so a failure recorded
4117
+ // by the detached tail is the one the receipt surface reads.
4118
+ getSealFailure: (agentName, sessionId) => sealFailures.get(agentName, sessionId),
3237
4119
  getConnState: (connectionId) => perConnectionState.get(connectionId),
3238
4120
  resolveCurrentAgent,
3239
4121
  NO_CURRENT_AGENT_RESPONSE,
@@ -3308,7 +4190,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
3308
4190
  return { error: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass agentName." };
3309
4191
  }
3310
4192
  const agentId = sessionNodeManager.resolveAgentId(agentName);
3311
- retryQueue.enqueueAwaitingContent(agentId, sessionId, Buffer.from(contentHashHex, "hex"), Buffer.from(contentHex, "hex"));
4193
+ // The fault-injection IPC seam. It states all three trailing values explicitly rather than
4194
+ // relying on defaults (B2b-1 pass-2 F1): `undefined` for the ordering record and the algorithm
4195
+ // is what this path genuinely has — it injects a bare queued entry — and saying so keeps the
4196
+ // seam honest about what it is producing rather than inheriting whatever the signature assumed.
4197
+ retryQueue.enqueueAwaitingContent(agentId, sessionId, Buffer.from(contentHashHex, "hex"), Buffer.from(contentHex, "hex"), undefined, undefined, undefined);
3312
4198
  return { queued: true, awaitingDepth: retryQueue.getAwaitingDepth(agentId, sessionId) };
3313
4199
  });
3314
4200
  // CELLO-M7-MSG-001: a `persisted` delivery ACK (or a confirmed park) clears the durable
@@ -3395,7 +4281,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
3395
4281
  const sessionId = params?.sessionId;
3396
4282
  if (!agentName || !sessionId)
3397
4283
  return { error: "missing_params", guidance: "Provide agentName and sessionId." };
3398
- recordRefusal(agentName, sessionId, params?.counterpartyPubkey ?? "", params?.reason ?? "test");
4284
+ // The reason is VALIDATED against the closed union rather than cast through it. A test seam
4285
+ // that can inject a reason production cannot produce would let a test prove the inbox handles a
4286
+ // code no refusal path emits — which is the shape DOD-M15-GUARD-HEARD-1 exists to remove, and a
4287
+ // seam is not exempt from it.
4288
+ const asked = params?.reason ?? CAPACITY_REASONS.ABUSE_BOUND_SESSIONS_PER_SENDER;
4289
+ const known = [
4290
+ ...Object.values(REFUSAL_REASONS),
4291
+ ...Object.values(CAPACITY_REASONS),
4292
+ ];
4293
+ if (!known.includes(asked)) {
4294
+ return {
4295
+ error: "unknown_refusal_reason",
4296
+ guidance: `"${asked}" is not a reason any refusal path can emit. Use one of: ${known.join(", ")}. ` +
4297
+ `Seeding an invented reason would test the inbox against a code production never produces.`,
4298
+ };
4299
+ }
4300
+ recordRefusal(agentName, sessionId, params?.counterpartyPubkey ?? "", asked);
3399
4301
  return { ok: true };
3400
4302
  });
3401
4303
  handlers.set("__test_delivery_open_begin", async (params, _connectionId) => {
@@ -3572,12 +4474,27 @@ async function startDaemonHoldingLock(config, singletonLock) {
3572
4474
  if (!handler)
3573
4475
  return undefined;
3574
4476
  return async (params, connectionId) => {
3575
- const result = await handler(params, connectionId);
4477
+ /**
4478
+ * ONE request, ONE store — and the fallback notice is spread in BEFORE `renderForSurface`.
4479
+ *
4480
+ * `DOD-M15-SELECTION-1` clause 2 first annotated the response out in `ipc-server.ts`, which
4481
+ * is downstream of this wrapper and therefore downstream of surface rendering. The notice
4482
+ * says *"Run cello_use_agent"*; `isInstructionKey` in `vocabulary.ts` rewrites any key
4483
+ * ending in `guidance`, so that WOULD have become `cello use-agent` for a terminal — but it
4484
+ * arrived after the rewrite had already run. An operator running `cello inbox` was handed a
4485
+ * verb that does not exist in a shell, which is the exact failure the vocabulary layer was
4486
+ * built to prevent. Annotating here puts it back in front of the renderer.
4487
+ */
4488
+ const store = {};
4489
+ const result = await fallbackNoticeStore.run(store, () => handler(params, connectionId));
4490
+ const annotated = store.notice && result !== null && typeof result === "object" && !Array.isArray(result)
4491
+ ? { ...result, ...store.notice }
4492
+ : result;
3576
4493
  // Default to "cli": a connection that never sent ipc.connect has no recorded surface, and
3577
4494
  // the CLI verb is the safe answer — it is at least a real command an operator can run,
3578
4495
  // whereas an MCP tool name is useless in a terminal.
3579
4496
  const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
3580
- return renderForSurface(result, surface);
4497
+ return renderForSurface(annotated, surface);
3581
4498
  };
3582
4499
  },
3583
4500
  };
@@ -3828,7 +4745,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
3828
4745
  logger.error("document.delivery.seal_failed", { agent, sessionId, reason: "close_handler_missing", correlationId });
3829
4746
  return;
3830
4747
  }
3831
- const sealed = (await close({ session_id: sessionId, agent }, `doc-delivery-${correlationId}`));
4748
+ /**
4749
+ * `wait_for_seal: true` — DOD-M15-CLOSEWAIT-1 review MEDIUM-5.
4750
+ *
4751
+ * This is NOT an IPC caller. It is an in-process worker awaiting the close for a session
4752
+ * it opened itself, and the `ok !== true` check below is the only thing that ever reports
4753
+ * a failed document-delivery seal. The new default answers `ok: true` at COMMITMENT, so
4754
+ * without this flag that check could never fire for a ceremony failure again — §16.4's
4755
+ * "the autonomous session still carries the seal" would rest on a detached task nobody
4756
+ * awaited, retried or reported on.
4757
+ *
4758
+ * The whole point of answering early is that a human is watching a terminal. Nobody is
4759
+ * watching this one, so it takes the blocking form — which also stops each delivery
4760
+ * leaving a detached ceremony holding a visiting connection for up to eleven minutes,
4761
+ * overlapping instead of serialising.
4762
+ */
4763
+ const sealed = (await close({ session_id: sessionId, agent, wait_for_seal: true }, `doc-delivery-${correlationId}`));
3832
4764
  if (sealed?.ok !== true) {
3833
4765
  // `cello_close_session` has distinct failure codes — session_already_sealed,
3834
4766
  // seal_interrupted_*, signaling_reconnecting — and every one of them landed nowhere.
@@ -3841,7 +4773,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
3841
4773
  // transport asked for 0x04, the composition root threw it away, and every document leaf
3842
4774
  // still reached the relay as a MESSAGE. Verified on live traffic: daemon 0.0.145 shipped
3843
4775
  // the fix everywhere except here and the wire was unchanged.
3844
- sendContent: (agent, sessionId, content, contentHash, correlationId, leafKind) => sessionNodeManager.sendContent(agent, sessionId, content, contentHash, correlationId, leafKind),
4776
+ //
4777
+ // ⚠️ AND `contentHashAlg` IS THE SECOND PARAMETER THIS ADAPTER MUST NOT DROP. The note above
4778
+ // is about `leafKind`, which this wrapper silently swallowed while every other caller passed
4779
+ // it — a thin pass-through is exactly where a new argument goes missing, because nothing
4780
+ // about the call site looks wrong afterwards. B2b's failure mode if it happens again: the
4781
+ // document path sends a salted hash labelled `sha256`, and every peer refuses it.
4782
+ sendContent: (agent, sessionId, content, contentHash, correlationId, leafKind, contentHashAlg) => sessionNodeManager.sendContent(agent, sessionId, content, contentHash, correlationId, leafKind, contentHashAlg),
4783
+ contentHashForSession: (agent, sessionId, content) => sessionNodeManager.contentHashForSession(agent, sessionId, content),
3845
4784
  // The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
3846
4785
  // successful send. See the comment at the call site for why this is delivery-critical and
3847
4786
  // not audit bookkeeping.
@@ -3850,7 +4789,31 @@ async function startDaemonHoldingLock(config, singletonLock) {
3850
4789
  // (deliberate — f75ea09), so it obeys the same discipline a message does — including
3851
4790
  // being HELD when the position is ahead of the tail, which is why the real frame bytes
3852
4791
  // have to travel with it.
3853
- const placed = sessionNodeManager.placeOwnLeaf(agent, sessionId, Buffer.from(contentHash).toString("hex"), frameBytes, assignedSeq, correlationId, "doc");
4792
+ const placed = sessionNodeManager.placeOwnLeaf(agent, sessionId, Buffer.from(contentHash).toString("hex"), frameBytes, assignedSeq, correlationId, "doc",
4793
+ /**
4794
+ * No proof — and the reason I first wrote here was FALSE, which review traced rather
4795
+ * than read.
4796
+ *
4797
+ * ⚠️ IT SAID *"the document transport does not go through `sendContent`, so no Structure-1
4798
+ * was signed."* Both halves are wrong. `document-delivery-transport.ts` calls
4799
+ * `deps.sendContent(...)`, wired straight to `sessionNodeManager.sendContent` a few lines
4800
+ * above this; and `session-relay-client.ts` signs the Structure-1 with no `leafKind` gate
4801
+ * at all, so a `0x04` doc leaf is signed exactly like a message. **A proof exists and is
4802
+ * discarded here.**
4803
+ *
4804
+ * That matters more than a wrong comment usually would, because this unit's whole thesis
4805
+ * is *"`undefined` is a claim the author made rather than one the signature made for
4806
+ * them"* — and the first claim made under the new signature was untrue.
4807
+ *
4808
+ * THE TRUE REASON, which is a better one: **no consumer.** A doc leaf released from a
4809
+ * hold writes no transcript row — `#releaseHeld` skips `recordTranscriptMessage` for
4810
+ * `kind === "doc"` — so there is nothing for the proof to reach. Discarding it is
4811
+ * no-consumer-no-ship, deliberately.
4812
+ *
4813
+ * **If doc rows ever reach the transcript, `appendLeaf` needs an authorship parameter**
4814
+ * and this `undefined` becomes a defect rather than a decision.
4815
+ */
4816
+ undefined);
3854
4817
  return { placed: placed.placed, leafIndex: placed.placed ? placed.leafIndex : null };
3855
4818
  },
3856
4819
  });
@@ -4012,6 +4975,33 @@ async function startDaemonHoldingLock(config, singletonLock) {
4012
4975
  // MCP-001: Clean up per-connection state when a connection disconnects
4013
4976
  // MCP-002: Also unregister from notification dispatcher
4014
4977
  ipcServer.onDisconnect((connectionId) => {
4978
+ /**
4979
+ * DOD-M15-IPCVISIBLE-1: SAY THAT IT CLOSED, and say what it was attending.
4980
+ *
4981
+ * `daemon.ipc.connected` fired on every open and nothing on close, so a live client and a dead
4982
+ * one that was never cleaned up looked identical in the log. The attended agent is the field
4983
+ * that matters: attendance dropping was silent, and an agent losing its last attendee changes
4984
+ * whether away-messages fire and who receives doorbells — so a session that stopped waking is
4985
+ * diagnosable from the log rather than by guesswork.
4986
+ */
4987
+ const closing = perConnectionState.get(connectionId);
4988
+ const stillAttending = closing?.currentAgent
4989
+ ? countAttendance(perConnectionState, closing.currentAgent) - 1
4990
+ : null;
4991
+ // RETURNED, not logged here — `ipcServer` merges this into its single
4992
+ // `daemon.ipc.disconnected` line. A second line under the same name left neither carrying the
4993
+ // whole picture and doubled every count (review F8).
4994
+ const disconnectContext = {
4995
+ clientType: closing?.clientType ?? "unknown",
4996
+ attendedAgent: closing?.currentAgent ?? null,
4997
+ ...(stillAttending !== null ? { remainingAttendance: stillAttending } : {}),
4998
+ ...(stillAttending === 0
4999
+ ? {
5000
+ impact: "that agent has no attending session left — inbound sessions are now answered with " +
5001
+ "its away message rather than a live reply, and its doorbells reach nobody",
5002
+ }
5003
+ : {}),
5004
+ };
4015
5005
  perConnectionState.delete(connectionId);
4016
5006
  connectionCursors.delete(connectionId); // M8C-CURSOR-1: cursor is connection-scoped, dies with it
4017
5007
  // ...and so is the delivery bookmark (review F1). It is a SEPARATE map from the gate's cursor
@@ -4044,6 +5034,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
4044
5034
  else
4045
5035
  inboundSessionWaiters.delete(agentName);
4046
5036
  }
5037
+ return disconnectContext;
4047
5038
  });
4048
5039
  // Log daemon.login.validation.complete (stub — all unverified until SIGNAL-001)
4049
5040
  logger.info("daemon.login.validation.complete", {
@@ -4068,6 +5059,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
4068
5059
  async function stop(reason) {
4069
5060
  clearInterval(reconcileSweepTimer);
4070
5061
  clearInterval(revivalBoundSweepTimer);
5062
+ // DOD-M15-RELAYABUSE-1: scheduled park retries. Unref'd, so they never held the process open —
5063
+ // cleared so an in-process restart cannot leave one draining into a torn-down manager.
5064
+ for (const t of parkRetryTimers)
5065
+ clearTimeout(t);
5066
+ parkRetryTimers.clear();
4071
5067
  // DOD-M12B-SHUTDOWN-1: clearing the timer only stops the NEXT tick. The pass already running
4072
5068
  // walks every agent, and each step dials a peer and opens a session — which is why a daemon
4073
5069
  // reported down, with its socket already removed, was still logging `document.reconcile.sweep`
@@ -4094,6 +5090,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
4094
5090
  if (manifestPollScheduler) {
4095
5091
  manifestPollScheduler.cancel();
4096
5092
  }
5093
+ // DOD-M15-STALEROSTER-1: same discipline as the manifest poll — the stop function sets the
5094
+ // flag so an in-flight sweep cannot re-arm, and the belt-and-suspenders cancel covers the
5095
+ // scheduler-without-sweep case.
5096
+ stopRosterSweep?.();
5097
+ rosterSweepScheduler?.cancel();
4097
5098
  stopRegistryPoll?.();
4098
5099
  if (config.registryPollScheduler) {
4099
5100
  config.registryPollScheduler.cancel();
@@ -4103,6 +5104,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
4103
5104
  // outbound work is not draining. Above the `daemon.stopped` log with the other cancels, because
4104
5105
  // this is "stop making new work", not "tear down transports".
4105
5106
  await restartSealResolver?.stop();
5107
+ /**
5108
+ * DRAIN THE DETACHED SEAL TAILS — review MEDIUM-6, and it sits HERE for the same reason
5109
+ * `restartSealResolver.stop()` does: both are directory ceremonies that must not be cut with
5110
+ * the counterparty holding a commitment this side never acknowledged. Before
5111
+ * `stopAllSignaling()`, because that is what severs the transport underneath them.
5112
+ *
5113
+ * BOUNDED. A ceremony can legitimately wait eleven minutes for a counterparty, and a shutdown
5114
+ * must not. Past the bound they are abandoned deliberately and said out loud — the next boot
5115
+ * resolves them, which is exactly what the restart seal resolver is for.
5116
+ */
5117
+ if (backgroundSeals.size > 0) {
5118
+ const SHUTDOWN_SEAL_DRAIN_MS = 5_000;
5119
+ logger.info("session.seal.background.draining", { count: backgroundSeals.size, budgetMs: SHUTDOWN_SEAL_DRAIN_MS });
5120
+ const drained = await Promise.race([
5121
+ Promise.allSettled([...backgroundSeals]).then(() => true),
5122
+ new Promise((r) => { const t = setTimeout(() => r(false), SHUTDOWN_SEAL_DRAIN_MS); t.unref?.(); }),
5123
+ ]);
5124
+ if (!drained) {
5125
+ logger.warn("session.seal.background.abandoned", {
5126
+ count: backgroundSeals.size,
5127
+ impact: "shutdown did not wait for these seal ceremonies. Each session holds a durable commitment " +
5128
+ "but no receipt yet, and the counterparty may hold a commitment this side never acknowledged.",
5129
+ guidance: "The next daemon start resolves them via the restart seal resolver; no operator action is needed.",
5130
+ });
5131
+ }
5132
+ }
4106
5133
  logger.info("daemon.stopped", { pid: process.pid, reason });
4107
5134
  // DOD-LOGOUT-EXIT-1: what the teardown actually DID, carried to onStopped so the binary can
4108
5135
  // exit non-zero on a dirty stop. Without it a shutdown that threw halfway — sessions never