@cello-protocol/daemon 0.0.180 → 0.0.182

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 (185) 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/assignment-verify.d.ts +62 -0
  10. package/dist/assignment-verify.d.ts.map +1 -0
  11. package/dist/assignment-verify.js +185 -0
  12. package/dist/assignment-verify.js.map +1 -0
  13. package/dist/backup-restore.d.ts +102 -0
  14. package/dist/backup-restore.d.ts.map +1 -0
  15. package/dist/backup-restore.js +340 -0
  16. package/dist/backup-restore.js.map +1 -0
  17. package/dist/bin/cello-daemon.js +91 -49
  18. package/dist/bin/cello-daemon.js.map +1 -1
  19. package/dist/close-commitment.d.ts +67 -0
  20. package/dist/close-commitment.d.ts.map +1 -0
  21. package/dist/close-commitment.js +78 -0
  22. package/dist/close-commitment.js.map +1 -0
  23. package/dist/close-session-handler.d.ts +20 -0
  24. package/dist/close-session-handler.d.ts.map +1 -1
  25. package/dist/close-session-handler.js +351 -58
  26. package/dist/close-session-handler.js.map +1 -1
  27. package/dist/consortium-bootstrap.d.ts +2 -0
  28. package/dist/consortium-bootstrap.d.ts.map +1 -1
  29. package/dist/consortium-bootstrap.js +65 -7
  30. package/dist/consortium-bootstrap.js.map +1 -1
  31. package/dist/content-park.d.ts.map +1 -1
  32. package/dist/content-park.js +141 -10
  33. package/dist/content-park.js.map +1 -1
  34. package/dist/daemon.d.ts +13 -1
  35. package/dist/daemon.d.ts.map +1 -1
  36. package/dist/daemon.js +928 -123
  37. package/dist/daemon.js.map +1 -1
  38. package/dist/delivery-session-suspects.d.ts.map +1 -1
  39. package/dist/delivery-session-suspects.js +5 -0
  40. package/dist/delivery-session-suspects.js.map +1 -1
  41. package/dist/directory-auth-posture.d.ts +87 -0
  42. package/dist/directory-auth-posture.d.ts.map +1 -0
  43. package/dist/directory-auth-posture.js +134 -0
  44. package/dist/directory-auth-posture.js.map +1 -0
  45. package/dist/directory-bootstrap.d.ts +66 -3
  46. package/dist/directory-bootstrap.d.ts.map +1 -1
  47. package/dist/directory-bootstrap.js +133 -7
  48. package/dist/directory-bootstrap.js.map +1 -1
  49. package/dist/document-delivery-transport.d.ts +22 -2
  50. package/dist/document-delivery-transport.d.ts.map +1 -1
  51. package/dist/document-delivery-transport.js +11 -3
  52. package/dist/document-delivery-transport.js.map +1 -1
  53. package/dist/document-frame-router.d.ts +14 -0
  54. package/dist/document-frame-router.d.ts.map +1 -1
  55. package/dist/document-frame-router.js +17 -0
  56. package/dist/document-frame-router.js.map +1 -1
  57. package/dist/document-gate.js +51 -8
  58. package/dist/document-gate.js.map +1 -1
  59. package/dist/document-handlers.d.ts.map +1 -1
  60. package/dist/document-handlers.js +50 -6
  61. package/dist/document-handlers.js.map +1 -1
  62. package/dist/document-inbound.d.ts +22 -0
  63. package/dist/document-inbound.d.ts.map +1 -1
  64. package/dist/document-inbound.js +54 -0
  65. package/dist/document-inbound.js.map +1 -1
  66. package/dist/document-layer.d.ts +13 -0
  67. package/dist/document-layer.d.ts.map +1 -1
  68. package/dist/document-layer.js +1 -0
  69. package/dist/document-layer.js.map +1 -1
  70. package/dist/document-screen.d.ts +39 -0
  71. package/dist/document-screen.d.ts.map +1 -1
  72. package/dist/document-screen.js +64 -9
  73. package/dist/document-screen.js.map +1 -1
  74. package/dist/inbound-sessions.d.ts +31 -1
  75. package/dist/inbound-sessions.d.ts.map +1 -1
  76. package/dist/inbound-sessions.js +276 -16
  77. package/dist/inbound-sessions.js.map +1 -1
  78. package/dist/index.d.ts +2 -0
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +3 -0
  81. package/dist/index.js.map +1 -1
  82. package/dist/ipc-server.d.ts +5 -1
  83. package/dist/ipc-server.d.ts.map +1 -1
  84. package/dist/ipc-server.js +19 -3
  85. package/dist/ipc-server.js.map +1 -1
  86. package/dist/manifest-validity.d.ts +151 -0
  87. package/dist/manifest-validity.d.ts.map +1 -0
  88. package/dist/manifest-validity.js +268 -0
  89. package/dist/manifest-validity.js.map +1 -0
  90. package/dist/notification-handlers.d.ts.map +1 -1
  91. package/dist/notification-handlers.js +15 -0
  92. package/dist/notification-handlers.js.map +1 -1
  93. package/dist/outbound-sessions.d.ts +31 -0
  94. package/dist/outbound-sessions.d.ts.map +1 -1
  95. package/dist/outbound-sessions.js +198 -17
  96. package/dist/outbound-sessions.js.map +1 -1
  97. package/dist/park-envelope.d.ts +72 -1
  98. package/dist/park-envelope.d.ts.map +1 -1
  99. package/dist/park-envelope.js +119 -4
  100. package/dist/park-envelope.js.map +1 -1
  101. package/dist/refusal-reasons.d.ts +72 -0
  102. package/dist/refusal-reasons.d.ts.map +1 -0
  103. package/dist/refusal-reasons.js +69 -0
  104. package/dist/refusal-reasons.js.map +1 -0
  105. package/dist/registration-manager.d.ts.map +1 -1
  106. package/dist/registration-manager.js +20 -1
  107. package/dist/registration-manager.js.map +1 -1
  108. package/dist/restart-seal-resolver.d.ts.map +1 -1
  109. package/dist/restart-seal-resolver.js +6 -0
  110. package/dist/restart-seal-resolver.js.map +1 -1
  111. package/dist/retry-queue.d.ts +23 -1
  112. package/dist/retry-queue.d.ts.map +1 -1
  113. package/dist/retry-queue.js +37 -9
  114. package/dist/retry-queue.js.map +1 -1
  115. package/dist/roster-freshness.d.ts +169 -0
  116. package/dist/roster-freshness.d.ts.map +1 -0
  117. package/dist/roster-freshness.js +249 -0
  118. package/dist/roster-freshness.js.map +1 -0
  119. package/dist/seal-certificate-pull.d.ts +17 -1
  120. package/dist/seal-certificate-pull.d.ts.map +1 -1
  121. package/dist/seal-certificate-pull.js +37 -3
  122. package/dist/seal-certificate-pull.js.map +1 -1
  123. package/dist/seal-coordinator.d.ts +17 -1
  124. package/dist/seal-coordinator.d.ts.map +1 -1
  125. package/dist/seal-coordinator.js +71 -0
  126. package/dist/seal-coordinator.js.map +1 -1
  127. package/dist/seal-failure-store.d.ts +104 -0
  128. package/dist/seal-failure-store.d.ts.map +1 -0
  129. package/dist/seal-failure-store.js +126 -0
  130. package/dist/seal-failure-store.js.map +1 -0
  131. package/dist/seal-flows.d.ts.map +1 -1
  132. package/dist/seal-flows.js +35 -6
  133. package/dist/seal-flows.js.map +1 -1
  134. package/dist/seal-upgrade.d.ts +20 -5
  135. package/dist/seal-upgrade.d.ts.map +1 -1
  136. package/dist/seal-upgrade.js +28 -5
  137. package/dist/seal-upgrade.js.map +1 -1
  138. package/dist/session-assignment-parser.d.ts +41 -5
  139. package/dist/session-assignment-parser.d.ts.map +1 -1
  140. package/dist/session-assignment-parser.js +77 -14
  141. package/dist/session-assignment-parser.js.map +1 -1
  142. package/dist/session-ceremony.d.ts +29 -0
  143. package/dist/session-ceremony.d.ts.map +1 -1
  144. package/dist/session-ceremony.js +71 -0
  145. package/dist/session-ceremony.js.map +1 -1
  146. package/dist/session-connection-gater.d.ts +46 -7
  147. package/dist/session-connection-gater.d.ts.map +1 -1
  148. package/dist/session-connection-gater.js +163 -12
  149. package/dist/session-connection-gater.js.map +1 -1
  150. package/dist/session-content-handlers.d.ts.map +1 -1
  151. package/dist/session-content-handlers.js +110 -4
  152. package/dist/session-content-handlers.js.map +1 -1
  153. package/dist/session-node-manager.d.ts +407 -37
  154. package/dist/session-node-manager.d.ts.map +1 -1
  155. package/dist/session-node-manager.js +3051 -239
  156. package/dist/session-node-manager.js.map +1 -1
  157. package/dist/session-read-handlers.d.ts +27 -0
  158. package/dist/session-read-handlers.d.ts.map +1 -1
  159. package/dist/session-read-handlers.js +112 -6
  160. package/dist/session-read-handlers.js.map +1 -1
  161. package/dist/session-relay-client.d.ts +61 -2
  162. package/dist/session-relay-client.d.ts.map +1 -1
  163. package/dist/session-relay-client.js +170 -11
  164. package/dist/session-relay-client.js.map +1 -1
  165. package/dist/session-salt-agreement.d.ts +284 -0
  166. package/dist/session-salt-agreement.d.ts.map +1 -0
  167. package/dist/session-salt-agreement.js +371 -0
  168. package/dist/session-salt-agreement.js.map +1 -0
  169. package/dist/signal-submission.d.ts.map +1 -1
  170. package/dist/signal-submission.js +10 -1
  171. package/dist/signal-submission.js.map +1 -1
  172. package/dist/signaling-connect.d.ts.map +1 -1
  173. package/dist/signaling-connect.js +42 -5
  174. package/dist/signaling-connect.js.map +1 -1
  175. package/dist/types.d.ts +54 -0
  176. package/dist/types.d.ts.map +1 -1
  177. package/dist/types.js.map +1 -1
  178. package/dist/vocabulary.d.ts.map +1 -1
  179. package/dist/vocabulary.js +10 -0
  180. package/dist/vocabulary.js.map +1 -1
  181. package/dist/wire-content-hash.d.ts +56 -0
  182. package/dist/wire-content-hash.d.ts.map +1 -1
  183. package/dist/wire-content-hash.js +71 -0
  184. package/dist/wire-content-hash.js.map +1 -1
  185. 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";
@@ -68,8 +74,13 @@ import { createSealCoordinator } from "./seal-coordinator.js";
68
74
  import { createTelegramDoorbell } from "./telegram-doorbell.js";
69
75
  import { registerSessionContentHandlers } from "./session-content-handlers.js";
70
76
  import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
77
+ import { isDocumentFrame } from "./document-frame-router.js";
78
+ import { INBOUND_INJECTION_BLOCKED } from "@cello-protocol/gateway";
71
79
  import { registerDocumentHandlers } from "./document-handlers.js";
72
- 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.
73
84
  import { DocumentPublish } from "./document-publish.js";
74
85
  import { createDocumentDeliveryTransport } from "./document-delivery-transport.js";
75
86
  import { ReconcileScheduler } from "./document-reconcile-scheduler.js";
@@ -79,6 +90,9 @@ import { createInboundSessions } from "./inbound-sessions.js";
79
90
  import { createOutboundSessions } from "./outbound-sessions.js";
80
91
  import { registerSessionReadHandlers } from "./session-read-handlers.js";
81
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";
82
96
  import { revocabilityOf } from "./signal-revocability.js";
83
97
  import { registerAgentHandlers } from "./agent-handlers.js";
84
98
  import { registerRegisterHandler } from "./register-handler.js";
@@ -107,6 +121,20 @@ const SESSION_NODE_KEY_STUB = {
107
121
  };
108
122
  // Production session node factory — wraps createNode from @cello-protocol/transport
109
123
  export class ProductionSessionNodeFactory {
124
+ logger;
125
+ /**
126
+ * DOD-M15-IDLE-CONNS-1 — OPTIONAL, and optional for one reason only: this factory is constructed
127
+ * with no arguments in four existing tests, and requiring a logger would turn them into type
128
+ * errors for a unit that has nothing to do with them.
129
+ *
130
+ * The cost is stated rather than hidden: with no logger, a reaped connection is silent, which is
131
+ * exactly the "guard nobody hears" this milestone has found four times. `startDaemon` passes one
132
+ * — that is the production path — and the sweep only runs on nodes this factory builds as
133
+ * receivers, so the silent case is a test-only fixture.
134
+ */
135
+ constructor(logger) {
136
+ this.logger = logger;
137
+ }
110
138
  async createNode(config) {
111
139
  // DOD-NAT-REACHABILITY-1: the STANDING RECEIVER — the node that accepts every
112
140
  // inbound session — must bind a ROUTABLE interface by default. The old
@@ -143,6 +171,106 @@ export class ProductionSessionNodeFactory {
143
171
  // than passed as undefined) when absent, keeping createNode's "generate a fresh key" default
144
172
  // for every node that is not session-scoped.
145
173
  ...(config.transportPrivateKey ? { transportPrivateKey: config.transportPrivateKey } : {}),
174
+ /**
175
+ * DOD-M15-IDLE-CONNS-1 — the idle sweep is armed on the node that STARTS as a standing
176
+ * receiver.
177
+ *
178
+ * **It is NOT "the standing receiver only", and an earlier version of this comment said so
179
+ * and was false.** `acceptSession` does not build a new node: it moves this same `CelloNode`
180
+ * from `#standingReceivers` into `#activeNodes`, so the interval keeps running after
181
+ * promotion, against the session's own counterparty. Review measured the consequence — the
182
+ * counterparty was hung up mid-conversation and the next send failed with `no_connection`,
183
+ * with nothing anywhere naming the local sweep.
184
+ *
185
+ * That is survivable only because the spared predicate below names the counterparty. The
186
+ * arrangement is deliberate now rather than accidental: one node, one interval, and the gate
187
+ * decides who is off-limits as it narrows.
188
+ */
189
+ ...(isReceiver
190
+ ? {
191
+ idleConnectionReaper: {
192
+ /**
193
+ * C3 — THE GUARD IS HEARD. A hang-up that tells nobody is indistinguishable from the
194
+ * thing simply not happening, and review measured what that costs: the operator's
195
+ * next send returns `no_connection`, `session.transport.redial.unavailable` says
196
+ * "every send parks until they re-establish", and not one word in that chain names a
197
+ * `setInterval` on their own machine.
198
+ *
199
+ * WARN and CONTINUE, deliberately: this is resource bounding, not a security event.
200
+ * Nothing is refused and no session state changes — so it is loud, and it does not
201
+ * block (Invariant 2's own distinction).
202
+ */
203
+ onReaped: (e) => {
204
+ if (e.reason === "never_carried_a_stream") {
205
+ this.logger?.warn("session.node.connection.reaped", {
206
+ sessionId: config.sessionId,
207
+ peerId: e.peerId,
208
+ ageMs: e.ageMs,
209
+ observation: "an inbound connection was hung up after never carrying a stream since it opened",
210
+ impact: "no session state changed and nothing was refused; if this peer returns it must dial again",
211
+ });
212
+ return;
213
+ }
214
+ // A sweep that cannot do its job is a different event and must not read as one that
215
+ // did. `hangUp`'s `invalid_peer_id` is a NAMED reason written so a malformed id is
216
+ // not read as a connection problem — preserved here rather than flattened.
217
+ this.logger?.error("session.node.connection.reap_failed", {
218
+ sessionId: config.sessionId,
219
+ peerId: e.peerId,
220
+ reason: e.reason,
221
+ error: e.error,
222
+ impact: "an idle connection was NOT closed; it continues to hold a slot against the connection cap",
223
+ });
224
+ },
225
+ /**
226
+ * C4 — THE COUNT THE DoD ASKS FOR, and the reason this callback exists at all.
227
+ *
228
+ * The line says *"measure a healthy daemon's connection count first"*, and nothing in
229
+ * the tree reported one — not `cello_status`, not the CLI, not the log. Exposing the
230
+ * CAPS without the COUNT would have been a capability nothing reads, which is this
231
+ * milestone's own "no consumer, no ship". DEBUG because it is a census on a timer,
232
+ * not an event: it exists to be greppable when a cap is finally tuned.
233
+ */
234
+ onObserved: (c) => {
235
+ this.logger?.debug("transport.connections.observed", {
236
+ sessionId: config.sessionId,
237
+ total: c.total,
238
+ inbound: c.inbound,
239
+ neverSpoke: c.neverSpoke,
240
+ maxConnections: c.maxConnections,
241
+ });
242
+ },
243
+ },
244
+ }
245
+ : {}),
246
+ }).then((node) => {
247
+ /**
248
+ * SPARE WHAT REACHABILITY DEPENDS ON — two things, and the second was missing.
249
+ *
250
+ * **The reserved relay.** The same list `DOD-M15-FRAME-1`'s eviction sweep spares, for the
251
+ * reason its comment gives: reservation refreshes ride those peers, and hanging one up costs
252
+ * the agent its inbound reachability. A reservation is IDLE BY NATURE between refreshes,
253
+ * which is exactly the shape the sweep hunts.
254
+ *
255
+ * **The peer the gate currently names.** `getAllowedPeerId()` is the admitted dialer before
256
+ * promotion and the counterparty after it. Without this the sweep reaps the one peer the
257
+ * session exists for.
258
+ *
259
+ * READ LIVE on every sweep, never captured: reservations are lost and retaken constantly
260
+ * (2,675 `reservation.lost` in one daemon's log) and `#allowedPeerId` changes at offer,
261
+ * promotion and refusal. A set frozen at build time is wrong within minutes.
262
+ *
263
+ * WHAT IS LEFT TO REAP, stated because a guard with no population is theatre: a peer that was
264
+ * admitted by an offer which was then refused or expired. `closeInbound()` returns
265
+ * `#allowedPeerId` to null, and libp2p never re-runs a gater against a connection that
266
+ * already exists — so that peer stays attached, named by nobody, speaking nothing. That is
267
+ * the connection this unit removes.
268
+ */
269
+ const gater = config.connectionGater;
270
+ if (isReceiver && gater) {
271
+ node.setIdleReaperSpared((peerId) => gater.isAllowedOutboundPeer(peerId) || gater.getAllowedPeerId() === peerId);
272
+ }
273
+ return node;
146
274
  });
147
275
  }
148
276
  }
@@ -193,6 +321,43 @@ async function startDaemonHoldingLock(config, singletonLock) {
193
321
  if (manifestProvider && (!manifestRootKeys || !manifestThreshold || manifestThreshold <= 0)) {
194
322
  throw new Error("DaemonConfig: manifestProvider requires manifestRootKeys (non-empty) and manifestThreshold (positive integer >= 1)");
195
323
  }
324
+ /**
325
+ * DOD-M15-DIRAUTH-1 — an operator can DEMAND directory identity authentication.
326
+ *
327
+ * HERE, under the ADV-006/008 rule above, because this IS pure config validation: both operands
328
+ * are already in hand and it touches nothing.
329
+ *
330
+ * Review F1 caught me putting it ninety lines lower, next to ADV-002, on the reasoning that it
331
+ * "mirrors" it. It does not. ADV-002 sits down there because it MUST — it depends on
332
+ * `verifyStartupManifest`, which depends on the anti-rollback floor in the DB. This depends on
333
+ * nothing, and down there it ran AFTER: the irreversible flat-file → SQLCipher identity migration
334
+ * (which renames and unlinks files), the creation of `sessions.db` and its key, and the sweep that
335
+ * marks every `active` session `interrupted` with `interrupted_by='local'`.
336
+ *
337
+ * So a misconfigured daemon "failed to start" and changed the operator's record on the way out —
338
+ * two live sessions permanently interrupted, attributed to a local cause, by a config check that
339
+ * could have run before anything was touched.
340
+ */
341
+ if (directoryAuthRequired(process.env) && challengeVerifier === undefined) {
342
+ const url = config.directoryHttpUrl ?? resolveDirectoryUrl(process.env);
343
+ logger.error("directory.auth.required.unavailable", {
344
+ directoryUrl: url,
345
+ impact: "the daemon refused to start rather than connect without directory identity authentication.",
346
+ guidance: "CELLO_REQUIRE_DIRECTORY_AUTH is set, but no challenge verifier could be built for this " +
347
+ "directory URL. Point CELLO_DIRECTORY_URL at a bundled endpoint, or supply a manifest with " +
348
+ "CELLO_CONSORTIUM_MANIFEST plus CELLO_CONSORTIUM_ROOT_KEYS and CELLO_CONSORTIUM_THRESHOLD " +
349
+ "(all three are required together), or set CELLO_REQUIRE_DIRECTORY_AUTH to 0/false/no/off " +
350
+ "to accept the risk.",
351
+ });
352
+ throw new Error(`CELLO_REQUIRE_DIRECTORY_AUTH is set, but directory identity authentication (step 6) cannot ` +
353
+ `be enforced: no challenge verifier was supplied for this daemon. The directory URL is ` +
354
+ `compared against the bundled consortium roster after NORMALISATION (trimmed, trailing slash ` +
355
+ `dropped, lowercased) — so case and a trailing slash are forgiven, but a DNS hostname ` +
356
+ `pointing at exactly the right machine is NOT, which is the usual cause. Either use a bundled ` +
357
+ `endpoint address, or supply a manifest with CELLO_CONSORTIUM_MANIFEST plus ` +
358
+ `CELLO_CONSORTIUM_ROOT_KEYS and CELLO_CONSORTIUM_THRESHOLD (all three are required together), ` +
359
+ `or set CELLO_REQUIRE_DIRECTORY_AUTH to 0/false/no/off to start without step 6.`);
360
+ }
196
361
  // ── PERSIST-002: open the encrypted store FIRST (runs the one-time flat-file → SQLCipher migration
197
362
  // (AC-006) + creates the agents/manifest_state schema), under the single-instance lock. This must
198
363
  // precede the manifest verification below because the manifest version is now stored in the
@@ -228,7 +393,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
228
393
  // screening, so a wiring mistake shows up here instead of hiding behind a correct-looking line.
229
394
  logger.info("security.gateway.connected", { mode: securityGateway.mode });
230
395
  const sessionNodeManager = new SessionNodeManager({
231
- factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(),
396
+ factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(logger),
232
397
  logger,
233
398
  dbPath: join(celloDir, "sessions.db"),
234
399
  contentTtfMs: config.contentTtfMs,
@@ -248,6 +413,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
248
413
  manifestThreshold,
249
414
  manifestVersionStore,
250
415
  logger,
416
+ // DOD-M15-STALEROSTER-1: the same injected fetch the sweep uses, so the startup probe and the
417
+ // background probe are exercised through one seam rather than one being untestable.
418
+ ...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
251
419
  });
252
420
  // ADV-002: an operator who configures manifestProvider has opted INTO manifest enforcement, so a
253
421
  // failed verification is fatal — never a warning we start anyway on.
@@ -266,7 +434,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
266
434
  "Check the logs for the specific failure reason (manifest_signature_invalid, manifest_expired, or manifest_version_rollback).");
267
435
  }
268
436
  // The manifest poll starts only AFTER the refuse above — a refused startup must not leak a timer.
269
- const { resolveConsortiumRoster, failoverEndpointResolver, getFailoverEndpoint, getUnresolvedNodes, getUnresolvedSweptAt, stopHttpManifestPoll } = createConsortiumRouting({
437
+ const { resolveConsortiumRoster, failoverEndpointResolver, getFailoverEndpoint, getUnresolvedNodes, getUnresolvedSweptAt, getDeclaredNodeCount, stopHttpManifestPoll } = createConsortiumRouting({
270
438
  manifestProvider,
271
439
  manifestRootKeys,
272
440
  manifestThreshold,
@@ -280,7 +448,96 @@ async function startDaemonHoldingLock(config, singletonLock) {
280
448
  initialUnresolvedNodes: startupUnresolvedNodes,
281
449
  initialUnresolvedSweptAt: startupSweptAt,
282
450
  logger,
451
+ ...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
283
452
  });
453
+ /**
454
+ * DOD-M15-STALEROSTER-1 — keep measuring directory reachability even when nothing is wrong.
455
+ *
456
+ * Every existing caller of the sweep is ACTIVITY-driven — ceremonies, session setup,
457
+ * `cello_refresh`, the seal broker. So an IDLE daemon never re-measures, and sitting idle is what
458
+ * a daemon does between conversations: the reading it was seeded with at boot is the reading it
459
+ * still has an hour later. Measured twice, on two machines — node failures from minutes past
460
+ * displayed while `curl` reached all three nodes in 37–184 ms.
461
+ *
462
+ * (An earlier version of this comment said the sweep had ONE caller, the failover path, and that
463
+ * recovering was what stopped the measurement. That was wrong — there are ten — and it is
464
+ * corrected here rather than deleted because believing it is why the concurrent-sweep race in
465
+ * `consortium-bootstrap.ts` went unnoticed until review.)
466
+ *
467
+ * Skipped when there is no manifest provider: there is no node roster to enumerate, so a timer
468
+ * that can only ever re-measure nothing is noise. That case is NOT silent — `cello_status`
469
+ * reports `measurement: "not_configured"` and says why.
470
+ */
471
+ /**
472
+ * WHERE the held manifest came from, because it decides what the operator can actually DO about
473
+ * an expired one — `DOD-M15-MANIFEST-EXPIRY-LIVE-1` review F5.
474
+ *
475
+ * `EmbeddedManifestProvider` is the compiled-in bundled roster: there is no file to replace and no
476
+ * poll to adopt a replacement, so "rotate the manifest" is not an available action and telling
477
+ * that operator to do it routes them toward the one workaround that silently disables directory
478
+ * identity authentication. Detected by the provider's own constructor rather than by re-reading
479
+ * the env var, so a caller that injects a provider directly is classified by what it IS.
480
+ */
481
+ const manifestOrigin = manifestProvider?.constructor?.name === "EmbeddedManifestProvider" ? "bundled" : "file";
482
+ /** REVIEW F4: the last sweep failure, surfaced in `cello_status` alongside the log line. */
483
+ let lastRosterSweepError;
484
+ /**
485
+ * DOD-M15-MANIFEST-EXPIRY-LIVE-1 — re-check the trust anchor's validity while the daemon runs.
486
+ *
487
+ * The window is enforced at STARTUP and nowhere else. The manifest poll's expiry check looks at
488
+ * the manifest being FETCHED, never the one held, so a daemon past its expiry keeps polling, keeps
489
+ * correctly refusing expired replacements, and keeps using the lapsed anchor it already has.
490
+ *
491
+ * Rides the roster sweep rather than owning a timer: that tick already fires every 90–180 s on
492
+ * exactly the path where a manifest provider exists.
493
+ */
494
+ const checkManifestValidity = startManifestValidityWatch({
495
+ getManifest: () => manifestProvider?.getCurrentManifest() ?? null,
496
+ logger,
497
+ });
498
+ const rosterSweepScheduler = manifestProvider
499
+ ? config.rosterSweepScheduler ??
500
+ new RandomizedPollScheduler({ minMs: ROSTER_SWEEP_INTERVAL_MS, maxMs: ROSTER_SWEEP_INTERVAL_MS * 2 })
501
+ : undefined;
502
+ const stopRosterSweep = rosterSweepScheduler
503
+ ? startRosterSweep({
504
+ scheduler: rosterSweepScheduler,
505
+ // FAST_PROBE is deliberately NOT used here. It exists because the failover resolver runs
506
+ // inside the 10 s signaling wait; nothing waits on this sweep, so it can afford the
507
+ // patient probe and give the more trustworthy answer.
508
+ sweep: async () => {
509
+ /**
510
+ * DOD-M15-MANIFEST-EXPIRY-LIVE-1: the anchor's validity is re-checked on the same tick.
511
+ * BEFORE the probe, so an expired manifest is reported even on a cycle where every node is
512
+ * unreachable and the roster resolve throws.
513
+ *
514
+ * Its OWN try/catch — review F11. Sharing the sweep's error path meant a throw in here
515
+ * would surface as `directory.roster.sweep.failed` AND skip `resolveConsortiumRoster()`
516
+ * entirely: the roster reading would freeze while the operator was pointed at the
517
+ * directory. A manifest-check failure must never be reported as a directory failure, and
518
+ * must never cost the measurement it rides along with.
519
+ */
520
+ try {
521
+ checkManifestValidity();
522
+ }
523
+ catch (err) {
524
+ logger.error("directory.auth.manifest.check.failed", {
525
+ error: err instanceof Error ? err.message : String(err),
526
+ impact: "the manifest validity re-check did not run this cycle. cello_status still computes " +
527
+ "it independently on every read, so the FIELD is unaffected; what is lost is the " +
528
+ "unprompted log line on a transition.",
529
+ });
530
+ }
531
+ return resolveConsortiumRoster();
532
+ },
533
+ logger,
534
+ // REVIEW F4: the failure reaches the agent's response, not just the log. Without this a
535
+ // sweep failing every cycle is invisible for the first two or three failures, because the
536
+ // reading is still inside its 5-minute freshness bound and reports stale:false.
537
+ onSweepError: (e) => { lastRosterSweepError = e; },
538
+ onSweepSuccess: () => { lastRosterSweepError = undefined; },
539
+ })
540
+ : undefined;
284
541
  // DOD-REGISTRY-1: type registry poll — daemon-level, runs even with zero agents.
285
542
  // When registryPubkey is configured, the daemon polls GET /registry, verifies the inner
286
543
  // Ed25519 signature, and updates the in-memory TypeRegistry. A poll failure never blanks
@@ -578,6 +835,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
578
835
  agentName,
579
836
  persistence: getPersistence(agentName),
580
837
  agentPubkeyHex,
838
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F1): the co-sign path must check the root before this
839
+ // agent's key endorses it. Same predicate as the receiving path — one implementation, so the
840
+ // two halves cannot drift about what a mismatch is.
841
+ verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
581
842
  keyProvider: agentKeyProvider,
582
843
  getNode: entry.getNode,
583
844
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -590,6 +851,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
590
851
  agentName,
591
852
  persistence: getPersistence(agentName),
592
853
  agentPubkeyHex,
854
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F1): the co-sign path must check the root before this
855
+ // agent's key endorses it. Same predicate as the receiving path — one implementation, so the
856
+ // two halves cannot drift about what a mismatch is.
857
+ verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
593
858
  keyProvider: agentKeyProvider,
594
859
  getNode: entry.getNode,
595
860
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -604,6 +869,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
604
869
  wireSessionOfferHandler({
605
870
  agentName,
606
871
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agentName),
872
+ admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agentName, peerId, sessionIdHex),
607
873
  signaling: mgr,
608
874
  logger,
609
875
  });
@@ -652,6 +918,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
652
918
  agentName: agent.name,
653
919
  persistence: getPersistence(agent.name),
654
920
  agentPubkeyHex: agent.pubkey,
921
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
922
+ verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
655
923
  keyProvider: agent.keyProvider,
656
924
  getNode: noSharedDirectoryNode,
657
925
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -663,6 +931,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
663
931
  agentName: agent.name,
664
932
  persistence: getPersistence(agent.name),
665
933
  agentPubkeyHex: agent.pubkey,
934
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
935
+ verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
666
936
  keyProvider: agent.keyProvider,
667
937
  getNode: noSharedDirectoryNode,
668
938
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -673,6 +943,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
673
943
  wireSessionOfferHandler({
674
944
  agentName: agent.name,
675
945
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
946
+ admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agent.name, peerId, sessionIdHex),
676
947
  signaling: mgr,
677
948
  logger,
678
949
  });
@@ -888,6 +1159,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
888
1159
  // whether its counterparty is quiet or its sibling was faster. Written at the destructive drain in
889
1160
  // session-content-handlers; read at that handler's timeout. Delivery itself is unchanged.
890
1161
  const contentTakes = new ContentTakeLedger();
1162
+ /**
1163
+ * In-flight background seal ceremonies — `DOD-M15-CLOSEWAIT-1` review MEDIUM-6.
1164
+ *
1165
+ * The close now answers at commitment and finishes the ceremony detached. Without this the tail
1166
+ * was the ONE background task `stop()` could cut at an arbitrary point, while it cancels or awaits
1167
+ * every other one.
1168
+ */
1169
+ const backgroundSeals = new Set();
1170
+ /**
1171
+ * DOD-M15-SEAL-FAILED-TERMINAL-1 — the last background seal failure per session.
1172
+ *
1173
+ * In memory on purpose: a restart makes "failed" the WRONG answer, because the boot sweep plus the
1174
+ * restart seal resolver retry the session. A marker whose lifetime is the process matches the
1175
+ * lifetime of the condition it describes.
1176
+ */
1177
+ const sealFailures = new SealFailureStore();
891
1178
  // DOD-AWAY-WRAP-1 AC1: request text is a leave-a-message greeting; agentName is spliced in at
892
1179
  // the call site so it names the specific away agent.
893
1180
  // DOD-AWAY-ACK-ONESHOT-TEXT-1 (live defect 2026-07-24): the ack must state the one-shot rule —
@@ -977,9 +1264,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
977
1264
  // FRONT precisely so [[WRAP]] keeps the end position the counterparty's detector anchors on.
978
1265
  const rejectText = markAsAutoReply("This inbox only accepts one message per visit. Closing. [[WRAP]]");
979
1266
  const rejectBytes = new TextEncoder().encode(rejectText);
980
- const rejectHash = wireContentHash(rejectBytes);
1267
+ // B2b: one decision point for the hash AND its algorithm — see `contentHashForSession`.
1268
+ const reject = await sessionNodeManager.contentHashForSession(agentName, sessionId, rejectBytes);
981
1269
  // Best-effort: a send failure still triggers the seal — we are closing regardless.
982
- const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(rejectHash), randomUUID());
1270
+ const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(reject.hash), randomUUID(), LEAF_KIND_MSG, reject.alg);
983
1271
  // M12-P13: commit the leaf when the rejection went out OR when it is durably queued —
984
1272
  // either way the relay already witnessed its sequence. This caller is the sharpest case
985
1273
  // of the three: the seal is initiated immediately below, so a hole here does not merely
@@ -987,7 +1275,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
987
1275
  // counterparty will still receive content at. The roots then cannot agree, and the
988
1276
  // session is unsealable for good.
989
1277
  if (sendResult.ok || sendResult.durable) {
990
- const rejectHashHex = Buffer.from(rejectHash).toString("hex");
1278
+ const rejectHashHex = Buffer.from(reject.hash).toString("hex");
991
1279
  // DOD-M12B-INDEX-1: at the relay's position, not the tail. This is the riskiest append
992
1280
  // in the codebase for that — the seal is initiated a few lines below, so a leaf at the
993
1281
  // wrong index does not merely stall the far side, it seals a tree the counterparty can
@@ -1013,85 +1301,124 @@ async function startDaemonHoldingLock(config, singletonLock) {
1013
1301
  // not have ingested it yet when the seal request arrives over the signaling channel.
1014
1302
  // The relay path avoids this entirely — it posts a SEAL ctrl leaf and waits for the
1015
1303
  // counterparty to independently co-seal; no bilateral leaf-count comparison needed.
1016
- void (async () => {
1017
- const correlationId = randomUUID();
1018
- const sk = sealKey(agentName, sessionId);
1019
- if (sealInterruptedInProgress.has(sk))
1020
- return;
1021
- sealInterruptedInProgress.add(sk);
1022
- try {
1023
- let resolveSeal;
1024
- const sealedP = new Promise((r) => { resolveSeal = r; });
1025
- pendingSealWaiters.set(sk, resolveSeal);
1026
- const submit = await sessionNodeManager.submitSealLeaf(agentName, sessionId, correlationId);
1027
- if (!submit.ok && submit.reason !== "responder_seal_already_submitted") {
1028
- pendingSealWaiters.delete(sk);
1029
- if (submit.reason === "relay_unavailable") {
1030
- const fallback = await handleActiveSealFlow(sessionId, record2, correlationId);
1031
- if (fallback.ok) {
1032
- logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "signaling_fallback" });
1304
+ /**
1305
+ * DOD-M15-DIVERGE-1 (review HIGH-3) — the gate has to hold on the path with no operator.
1306
+ *
1307
+ * `cello_close_session` refuses a diverged record, but this autonomous path never
1308
+ * consulted `sealReadiness` at all: it read `placed.placed` and discarded
1309
+ * `placed.diverged`, then initiated the seal directly. So "a diverged session is blocked
1310
+ * from sealing" held for the close a human drives and not for the one that runs itself —
1311
+ * which is the worse of the two, because the append two lines above is, by its own
1312
+ * comment, "the riskiest append in the codebase" for exactly this reason.
1313
+ *
1314
+ * THE LOG IS THE SURFACE HERE, and that is not a weakening of Invariant 2. There is no
1315
+ * caller to answer nothing is awaiting a response on this path — so the log carries the
1316
+ * whole warning rather than half of it.
1317
+ */
1318
+ const oneshotReadiness = sessionNodeManager.sealReadiness(agentName, sessionId);
1319
+ if (oneshotReadiness.diverged) {
1320
+ logger.warn("session.away.inbox.oneshot.seal_skipped_diverged", {
1321
+ agentName, sessionId,
1322
+ treeSize: oneshotReadiness.treeSize, highWaterSeq: oneshotReadiness.highWaterSeq,
1323
+ 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",
1324
+ });
1325
+ }
1326
+ else {
1327
+ void (async () => {
1328
+ const correlationId = randomUUID();
1329
+ const sk = sealKey(agentName, sessionId);
1330
+ if (sealInterruptedInProgress.has(sk))
1331
+ return;
1332
+ sealInterruptedInProgress.add(sk);
1333
+ try {
1334
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F4): SealCompletion is now a union — a refused
1335
+ // certificate resolves the waiter rather than dropping it, so this path must handle it
1336
+ // too rather than treating a refusal as a seal.
1337
+ let resolveSeal;
1338
+ const sealedP = new Promise((r) => { resolveSeal = r; });
1339
+ pendingSealWaiters.set(sk, resolveSeal);
1340
+ const submit = await sessionNodeManager.submitSealLeaf(agentName, sessionId, correlationId);
1341
+ if (!submit.ok && submit.reason !== "responder_seal_already_submitted") {
1342
+ pendingSealWaiters.delete(sk);
1343
+ if (submit.reason === "relay_unavailable") {
1344
+ const fallback = await handleActiveSealFlow(sessionId, record2, correlationId);
1345
+ if (fallback.ok) {
1346
+ logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "signaling_fallback" });
1347
+ }
1348
+ else {
1349
+ logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: fallback.reason, path: "signaling_fallback" });
1350
+ }
1033
1351
  }
1034
1352
  else {
1035
- logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: fallback.reason, path: "signaling_fallback" });
1353
+ logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: submit.reason });
1036
1354
  }
1355
+ return;
1037
1356
  }
1038
- else {
1039
- logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: submit.reason });
1357
+ logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "relay" });
1358
+ const bilateralTimeoutMs = Number(process.env["CELLO_SEAL_BILATERAL_TIMEOUT_MS"]) || 660_000;
1359
+ let timer;
1360
+ const timeoutP = new Promise((r) => { timer = setTimeout(() => r(null), bilateralTimeoutMs); });
1361
+ const sealedCompletion = await Promise.race([sealedP, timeoutP]);
1362
+ clearTimeout(timer);
1363
+ pendingSealWaiters.delete(sk);
1364
+ if (sealedCompletion !== null) {
1365
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F4): a REFUSED certificate resolves the waiter
1366
+ // now rather than being dropped, so this path must tell the two apart — logging a
1367
+ // refusal as "sealed" would be the silent acceptance the whole bullet exists to stop.
1368
+ if ("refused" in sealedCompletion) {
1369
+ logger.error("session.away.inbox.oneshot.seal_refused", {
1370
+ agentName, sessionId, reason: sealedCompletion.reason, detail: sealedCompletion.detail,
1371
+ impact: "the away auto-seal was REFUSED: the directory returned a validly signed root " +
1372
+ "that does not describe this conversation. The session is NOT sealed and nothing " +
1373
+ "was signed with this agent's key.",
1374
+ });
1375
+ return;
1376
+ }
1377
+ logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: sealedCompletion.rootHex });
1378
+ return;
1040
1379
  }
1041
- return;
1042
- }
1043
- logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "relay" });
1044
- const bilateralTimeoutMs = Number(process.env["CELLO_SEAL_BILATERAL_TIMEOUT_MS"]) || 660_000;
1045
- let timer;
1046
- const timeoutP = new Promise((r) => { timer = setTimeout(() => r(null), bilateralTimeoutMs); });
1047
- const sealedCompletion = await Promise.race([sealedP, timeoutP]);
1048
- clearTimeout(timer);
1049
- pendingSealWaiters.delete(sk);
1050
- if (sealedCompletion !== null) {
1051
- logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: sealedCompletion.rootHex });
1052
- return;
1053
- }
1054
- // Bilateral timeout — escalate to unilateral seal.
1055
- const escalation = submit.ok
1056
- ? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
1057
- : submit.reason === "responder_seal_already_submitted" &&
1058
- typeof submit.reportedRootHex === "string" &&
1059
- typeof submit.sequenceNumber === "number"
1380
+ // Bilateral timeout — escalate to unilateral seal.
1381
+ const escalation = submit.ok
1060
1382
  ? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
1061
- : null;
1062
- if (!escalation) {
1063
- logger.warn("session.away.inbox.oneshot.seal_pending", { agentName, sessionId });
1064
- return;
1065
- }
1066
- // DOD-M12B-SEAL-ESCALATE-DUP-1: THE SHARED ESCALATION, not a second copy.
1067
- //
1068
- // This used to be a line-for-line duplicate with its own hardcoded 30 s timeout, and
1069
- // it missed every refusal the other one gained: an empty carry, a gappy chain, two of
1070
- // our own ctrl leaves (permanently unsealable), and a bilateral seal already running.
1071
- // It spent the full timeout on each and then reported `seal_unilateral_timeout` — the
1072
- // label that names our own wait. Sharing the body is what stops that drifting again.
1073
- const uni = await runUnilateralEscalation({
1074
- logger, sessionNodeManager, sendOver, pendingUnilateralWaiters, sealKey,
1075
- getKeyProvider: (a) => keyProviders.get(a),
1076
- timeoutMs: UNILATERAL_SEAL_TIMEOUT_MS,
1077
- }, agentName, sessionId, escalation, correlationId, { refuseOnUnusableCarry: true });
1078
- if (uni.ok) {
1079
- logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: uni.sealed_root, sealType: "unilateral" });
1383
+ : submit.reason === "responder_seal_already_submitted" &&
1384
+ typeof submit.reportedRootHex === "string" &&
1385
+ typeof submit.sequenceNumber === "number"
1386
+ ? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
1387
+ : null;
1388
+ if (!escalation) {
1389
+ logger.warn("session.away.inbox.oneshot.seal_pending", { agentName, sessionId });
1390
+ return;
1391
+ }
1392
+ // DOD-M12B-SEAL-ESCALATE-DUP-1: THE SHARED ESCALATION, not a second copy.
1393
+ //
1394
+ // This used to be a line-for-line duplicate with its own hardcoded 30 s timeout, and
1395
+ // it missed every refusal the other one gained: an empty carry, a gappy chain, two of
1396
+ // our own ctrl leaves (permanently unsealable), and a bilateral seal already running.
1397
+ // It spent the full timeout on each and then reported `seal_unilateral_timeout` — the
1398
+ // label that names our own wait. Sharing the body is what stops that drifting again.
1399
+ const uni = await runUnilateralEscalation({
1400
+ logger, sessionNodeManager, sendOver, pendingUnilateralWaiters, sealKey,
1401
+ getKeyProvider: (a) => keyProviders.get(a),
1402
+ timeoutMs: UNILATERAL_SEAL_TIMEOUT_MS,
1403
+ }, agentName, sessionId, escalation, correlationId, { refuseOnUnusableCarry: true });
1404
+ if (uni.ok) {
1405
+ logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: uni.sealed_root, sealType: "unilateral" });
1406
+ }
1407
+ else {
1408
+ // CARRY THE GUIDANCE. The four refusals this path just gained come with the
1409
+ // sentence that tells an operator whether to retry or force-abandon — and the log
1410
+ // is this path's ONLY surface, so dropping it leaves a nameable cause with no
1411
+ // action attached. The old reasons had no guidance to lose; these do.
1412
+ logger.warn("session.away.inbox.oneshot.seal_unilateral_failed", {
1413
+ agentName, sessionId, reason: uni.reason, guidance: uni.guidance,
1414
+ });
1415
+ }
1080
1416
  }
1081
- else {
1082
- // CARRY THE GUIDANCE. The four refusals this path just gained come with the
1083
- // sentence that tells an operator whether to retry or force-abandon — and the log
1084
- // is this path's ONLY surface, so dropping it leaves a nameable cause with no
1085
- // action attached. The old reasons had no guidance to lose; these do.
1086
- logger.warn("session.away.inbox.oneshot.seal_unilateral_failed", {
1087
- agentName, sessionId, reason: uni.reason, guidance: uni.guidance,
1088
- });
1417
+ finally {
1418
+ sealInterruptedInProgress.delete(sealKey(agentName, sessionId));
1089
1419
  }
1090
- }
1091
- finally {
1092
- sealInterruptedInProgress.delete(sealKey(agentName, sessionId));
1093
- }
1094
- })();
1420
+ })();
1421
+ }
1095
1422
  }
1096
1423
  }
1097
1424
  return;
@@ -1148,8 +1475,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
1148
1475
  ? new Uint8Array(awayVerdict.content)
1149
1476
  : draftBytes;
1150
1477
  const contentBytes = new TextEncoder().encode(markAsAutoReply(new TextDecoder().decode(screenedBytes)));
1151
- const contentHash = wireContentHash(contentBytes);
1152
- const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
1478
+ // B2b: one decision point for the hash AND its algorithm — see `contentHashForSession`.
1479
+ const away = await sessionNodeManager.contentHashForSession(agentName, sessionId, contentBytes);
1480
+ const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(away.hash), randomUUID(), LEAF_KIND_MSG, away.alg);
1153
1481
  if (!sendResult.ok && !sendResult.durable) {
1154
1482
  // Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
1155
1483
  // away period — clear the guard so the next inbound arrival retries the ack.
@@ -1163,7 +1491,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1163
1491
  });
1164
1492
  return;
1165
1493
  }
1166
- const contentHashHex = Buffer.from(contentHash).toString("hex");
1494
+ const contentHashHex = Buffer.from(away.hash).toString("hex");
1167
1495
  if (!sendResult.ok) {
1168
1496
  // M12-P13 (found live 2026-08-05, M12 Entry 89): the reply is durably queued and already
1169
1497
  // owns the sequence the relay witnessed for it, so its leaf MUST be committed here. Without
@@ -1380,6 +1708,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
1380
1708
  challengeVerifier,
1381
1709
  getManifestVersion: () => verifiedManifestVersion,
1382
1710
  loadedAgents,
1711
+ // DOD-M15-ERRSTRING-1: so a session failure can say "and 2 of your 5 directories are
1712
+ // unreachable", which is very often the actual cause and was reported nowhere the operator
1713
+ // was looking.
1714
+ getUnresolvedNodes,
1715
+ getDeclaredNodeCount,
1383
1716
  });
1384
1717
  // Both use the same SQLite DB as the SessionNodeManager (daemon.db equivalent).
1385
1718
  // loadFromDb() must complete BEFORE IPC socket opens (AC-007).
@@ -1455,15 +1788,20 @@ async function startDaemonHoldingLock(config, singletonLock) {
1455
1788
  onPersisted: (agentName, sessionId, contentHashHex) => {
1456
1789
  retryQueue.markContentAcked(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"));
1457
1790
  },
1458
- onTtf: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) => {
1459
- retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor);
1791
+ // `DOD-M15-SEALWIRE-1` B2b-1 review F1 `contentHashAlg` is the DURABLE WRITER for the column
1792
+ // this unit added. Without it every queued row carries NULL, the crash backstop re-parks a salted
1793
+ // message as sha256, and the recipient refuses it and re-pulls it forever. The commit that added
1794
+ // the column said the producer "passes it"; the producer was passing a value nothing supplied,
1795
+ // because these two hooks were never widened.
1796
+ onTtf: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) => {
1797
+ retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor, contentHashAlg);
1460
1798
  },
1461
1799
  // M12-P12: same durable destination, different cause — a park deposit the relay refused. The
1462
1800
  // TTF timer is already cancelled on this path, so this is the only thing holding the content.
1463
1801
  // M12-P13 (review HIGH-1): the enqueue's own answer is returned, never a bare `true`. A dropped
1464
1802
  // copy that reports success now buys a committed hash-chain leaf for content that is gone.
1465
- onParkFailed: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) => {
1466
- return retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor);
1803
+ onParkFailed: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) => {
1804
+ return retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor, contentHashAlg);
1467
1805
  },
1468
1806
  });
1469
1807
  // MSG-001-3b (2b): the LIVE content-park deposit. On a not-confirmed send (direct delivery
@@ -1509,7 +1847,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1509
1847
  logger.warn("session.seal.autoack.broker.unreachable", { agentName, brokerNode, correlationId });
1510
1848
  return null;
1511
1849
  });
1512
- sessionNodeManager.setContentParkHook(async ({ agentName, sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor }) => {
1850
+ sessionNodeManager.setContentParkHook(async ({ agentName, sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg }) => {
1513
1851
  const node = sessionNodeManager.getStandingReceiverNode();
1514
1852
  if (!node) {
1515
1853
  const reason = "standing_receiver_unavailable";
@@ -1543,12 +1881,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
1543
1881
  // SEC-1: sealParkEnvelope is the SOLE producer — it signs (sender's K_local, over the
1544
1882
  // session/recipient/content binding) and seals in one place, so the two park sites cannot drift
1545
1883
  // apart on what gets signed.
1884
+ //
1885
+ // `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
1886
+ // MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
1887
+ // is salted is a fact about the message that was sent; what this side holds now says nothing
1888
+ // about it. Still `sha256` everywhere, because no send path salts yet — the plumbing is proven
1889
+ // carrying the value that cannot break anything, and only then does the value change.
1546
1890
  const ciphertext = await sealParkEnvelope({
1547
1891
  signer: senderKp,
1548
1892
  sessionIdHex: sessionId,
1549
1893
  recipientPubkey,
1550
1894
  contentHash: contentHashBytes,
1551
1895
  content,
1896
+ // The algorithm the DIRECT frame named for this same message. The park copy must claim what
1897
+ // the message actually is, not what this side would choose for it now.
1898
+ contentHashAlg,
1552
1899
  structure1Cbor,
1553
1900
  structure2Cbor,
1554
1901
  });
@@ -1621,12 +1968,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
1621
1968
  // empty after a restart, so arrival order there means a wrong leaf index and a divergent tree.
1622
1969
  // SEC-1: same sole producer as the live hook — the backstop signs from the persisted
1623
1970
  // (sessionId, recipient, contentHash).
1971
+ //
1972
+ // `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
1973
+ // MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
1974
+ // is salted is a fact about the message that was sent; what this side holds now says nothing
1975
+ // about it. Still `sha256` everywhere, because no send path salts yet — the plumbing is proven
1976
+ // carrying the value that cannot break anything, and only then does the value change.
1624
1977
  const ciphertext = await sealParkEnvelope({
1625
1978
  signer: senderKp,
1626
1979
  sessionIdHex: entry.sessionId,
1627
1980
  recipientPubkey,
1628
1981
  contentHash: contentHashBytes,
1629
1982
  content: entry.contentBlob,
1983
+ // The queued row's own record of how it was hashed. `undefined` for a row written before the
1984
+ // column existed, which resolves to `sha256` — exactly what such a row actually used.
1985
+ contentHashAlg: entry.contentHashAlg,
1630
1986
  structure1Cbor: entry.structure1Cbor,
1631
1987
  structure2Cbor: entry.structure2Cbor,
1632
1988
  });
@@ -1787,7 +2143,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
1787
2143
  return {
1788
2144
  name: a.name,
1789
2145
  state,
2146
+ /**
2147
+ * `selected` IS THIS CONNECTION'S VIEW, NOT THE AGENT'S — `DOD-M15-IPCVISIBLE-1` clause 3.
2148
+ *
2149
+ * Every `cello` CLI invocation opens a FRESH connection, which starts with no current
2150
+ * agent. So a client asking about its own state through the CLI always reads `false`, for
2151
+ * an agent it genuinely has selected in another session. Both Andre and a Hermes agent
2152
+ * misread it that way during one investigation, in opposite directions.
2153
+ *
2154
+ * The field name cannot be changed without breaking every reader, so it is ANNOTATED: the
2155
+ * sibling below says whose view this is, and `attended_by` says how many connections hold
2156
+ * this agent at all — which is the question people were actually asking.
2157
+ */
1790
2158
  selected,
2159
+ selected_by_this_connection: selected,
2160
+ attended_by: countAttendance(perConnectionState, a.name),
1791
2161
  pubkey: a.pubkey,
1792
2162
  // M8B F14 (fix 5): per-agent standing-receiver readiness on the MCP surface
1793
2163
  // (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
@@ -2003,6 +2373,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
2003
2373
  //
2004
2374
  // Omitted entirely when nothing is failing, so a healthy status stays quiet.
2005
2375
  ...(unresolvedNodesForStatus() ?? {}),
2376
+ // DOD-M15-MANIFEST-EXPIRY-LIVE-1: contributes NOTHING while the manifest is comfortably in
2377
+ // window. A field present on every status read for the years a manifest is valid is furniture,
2378
+ // not a warning, and it teaches the reader to skip the block that matters.
2379
+ ...(describeManifestValidity(classifyManifestValidity(manifestProvider?.getCurrentManifest() ?? null, Date.now()), manifestOrigin) ?? {}),
2380
+ // DOD-M15-DIRAUTH-1: the posture is STATED, in both directions. Unlike every other field in
2381
+ // this milestone the healthy case is reported too — the defect is precisely that "enforced"
2382
+ // and "skipped" differ only by the absence of a log line, so an operator must be able to
2383
+ // confirm it is on, not merely fail to find evidence that it is off.
2384
+ ...describeDirectoryAuth({
2385
+ verifierPresent: challengeVerifier !== undefined,
2386
+ directoryUrl: directoryHttpUrl ?? resolveDirectoryUrl(process.env),
2387
+ // Review F5: with neither set, `resolveDirectoryUrl` re-picks a RANDOM bundled endpoint on
2388
+ // every call, so there is no configured URL to quote or to blame.
2389
+ urlExplicitlyConfigured: directoryHttpUrl !== undefined || process.env["CELLO_DIRECTORY_URL"] !== undefined,
2390
+ }),
2006
2391
  // M8B F14 (fix 5): per-agent standing-receiver readiness, so a deaf agent (online but
2007
2392
  // no armed receiver) is visible in cello_status instead of hiding behind the ANY-agent
2008
2393
  // aggregate below (kept for backward compatibility).
@@ -2115,16 +2500,95 @@ async function startDaemonHoldingLock(config, singletonLock) {
2115
2500
  * alice, alice was stopped, and the work lands on bob reporting success. A lost intent is not the
2116
2501
  * same as no intent, and it must fail loud (no_current_agent) rather than be guessed at.
2117
2502
  */
2503
+ /**
2504
+ * Which agent this call acts as — `DOD-M15-SELECTION-1`, logic in `agent-selection.ts`.
2505
+ *
2506
+ * It used to end with `if (onlineAgents.size === 1) return [...onlineAgents][0]`, so a connection
2507
+ * that had selected nothing acted as whichever agent happened to be the only one online. On a
2508
+ * shared daemon that can be a DIFFERENT operator's agent, and a live MCP session was being bound
2509
+ * to an identity it never asked for.
2510
+ *
2511
+ * The resolution is now attributable: every path reports how it was reached, and `fallback` — the
2512
+ * one that was invisible — is logged at INFO. `DOD-M15-IPCVISIBLE-1`.
2513
+ */
2514
+ /**
2515
+ * The fallback notice owed to THIS REQUEST's response — `DOD-M15-SELECTION-1` clause 2.
2516
+ *
2517
+ * ─── Why an AsyncLocalStorage and not a per-connection map ─────────────────────────────────────
2518
+ *
2519
+ * The first cut keyed a `WeakMap` on the per-connection state object, so the notice belonged to
2520
+ * the CONNECTION and was read back at the response boundary. Review found that hands the notice to
2521
+ * whichever response finishes first, and Claude Code issues tool calls in parallel:
2522
+ *
2523
+ * 1. the agent calls `cello_receive` — nothing selected, so the fallback resolves and records
2524
+ * the notice, then the handler BLOCKS for up to 30 s waiting for content;
2525
+ * 2. in the same turn it calls `cello_sessions {agent: "bob"}`, which names an agent explicitly
2526
+ * and never falls back;
2527
+ * 3. `cello_sessions` returns first and takes the notice on its way out.
2528
+ *
2529
+ * Bob's response now says *"no agent was selected, so 'solo' was used"* — false, on the one call
2530
+ * that did name an agent — and `cello_receive`, the call that actually fell back, says nothing.
2531
+ * Exactly inverted.
2532
+ *
2533
+ * A notice is a fact about ONE CALL, so it is stored in that call's async context. The store is
2534
+ * created per request in `renderedHandlers` and dies with it, which also means a handler that
2535
+ * THROWS cannot leave a notice behind to attach itself to some later, unrelated response — the
2536
+ * other half of the same review finding.
2537
+ */
2538
+ const fallbackNoticeStore = new AsyncLocalStorage();
2118
2539
  function resolveCurrentAgent(connState, explicitAgent) {
2119
- if (explicitAgent)
2120
- return explicitAgent;
2121
- if (connState?.currentAgent)
2122
- return connState.currentAgent;
2123
- if (connState?.clearedAgent)
2124
- return null;
2125
- if (onlineAgents.size === 1)
2126
- return [...onlineAgents][0];
2127
- return null;
2540
+ return resolveCurrentAgentFor({
2541
+ connState,
2542
+ onlineAgents,
2543
+ ...(explicitAgent !== undefined ? { explicitAgent } : {}),
2544
+ onResolved: (agent, trigger) => {
2545
+ // Only the FALLBACK is announced. `explicit` and `selected` are the ordinary cases and
2546
+ // logging them would bury the one that matters — a signal that fires on the normal case is
2547
+ // not a signal.
2548
+ if (trigger !== "fallback")
2549
+ return;
2550
+ /**
2551
+ * RECORDED FOR THE RESPONSE, not just the log — `DOD-M15-SELECTION-1` clause 2.
2552
+ *
2553
+ * The log tells whoever reads the daemon log. The RESPONSE tells the agent that just acted
2554
+ * as an identity it never selected, which is the one that stops the half-attended state
2555
+ * being read as the protocol dropping messages.
2556
+ */
2557
+ /**
2558
+ * No `connState` guard. The first cut wrote the notice only `if (agent && connState)`, and
2559
+ * `perConnectionState` is populated at `ipc.connect` — which `withIpc` in the CLI
2560
+ * (`core/cli/src/commands.ts`) does not send. So every plain `cello` invocation fell back
2561
+ * and got a response that said nothing, while the log line below fired regardless: the
2562
+ * clause asks for explicit IN THE RESPONSE, and those callers had it explicit in the log
2563
+ * only. The store is per-REQUEST, so it exists whether or not the connection ever
2564
+ * handshook.
2565
+ */
2566
+ const store = fallbackNoticeStore.getStore();
2567
+ if (agent && store) {
2568
+ store.notice = {
2569
+ acting_as: agent,
2570
+ agent_selection: "fallback",
2571
+ agent_selection_guidance: `No agent was selected on this connection, so '${agent}' was used because it is the ` +
2572
+ `only one online. This is a per-call subject, NOT an attendance: doorbells route by ` +
2573
+ `the connection's registered agent and this does not set it. Two things follow — ` +
2574
+ `this session will not WAKE on an incoming message even though sending and reading ` +
2575
+ `work, and anyone who opens a session with you is sent an AWAY auto-reply while you ` +
2576
+ `sit here able to answer. Run cello_use_agent to fix both. Naming the agent on each ` +
2577
+ `call is NOT a remedy for either: it settles which agent a call is about and leaves ` +
2578
+ `the connection just as unattended.`,
2579
+ };
2580
+ }
2581
+ logger.info("agent.current.fallback", {
2582
+ agentName: agent,
2583
+ clientType: connState?.clientType ?? "cli",
2584
+ impact: "this call had no selected agent and exactly one was online, so that one was used. It " +
2585
+ "is a per-call subject, NOT an attendance: doorbells route by the connection's " +
2586
+ "registered agent, which this does not set.",
2587
+ guidance: "If this was not the intended agent, name it explicitly, or run cello_use_agent to " +
2588
+ "select one for the connection.",
2589
+ });
2590
+ },
2591
+ });
2128
2592
  }
2129
2593
  // Agent lifecycle (agent-handlers.ts): create, remove, start, stop, select, list.
2130
2594
  registerAgentHandlers({
@@ -2214,6 +2678,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
2214
2678
  agentName,
2215
2679
  persistence: getPersistence(agentName),
2216
2680
  agentPubkeyHex: loaded.pubkey,
2681
+ // DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
2682
+ verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
2217
2683
  keyProvider: loaded.keyProvider,
2218
2684
  getNode: entry.getNode,
2219
2685
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -2258,6 +2724,28 @@ async function startDaemonHoldingLock(config, singletonLock) {
2258
2724
  subject_kind: r.subjectKind,
2259
2725
  subject: r.subject,
2260
2726
  issuer_kind: r.issuerKind,
2727
+ /**
2728
+ * DOD-M15-SAMEOP-FALSEPOS-1: WHO ISSUED IT. The listing carried `subject` — who the signal is
2729
+ * ABOUT — and never who SAID it, so an operator could see "someone endorsed me" and not see
2730
+ * who. For the decision this list exists to support (do I rely on this endorsement?) the
2731
+ * author is the primary fact, and an endorsement nobody can attribute is worth roughly
2732
+ * nothing.
2733
+ *
2734
+ * `issuer_kind` reads like it answers this and does not: it says `"agent"` or `"portal"`, a
2735
+ * category. Elsewhere a field literally named `issuer` holds `"peer-claimed"` /
2736
+ * `"platform-verified"` — also a category. Two fields whose names promise identity, neither
2737
+ * carrying it.
2738
+ *
2739
+ * It also unblocks an investigation this omission stalled: four endorsements in a wallet all
2740
+ * read `same_operator: true`, and **nothing in the response could say whether the stranger's
2741
+ * was among them** — so "a stranger is flagged as self-dealing" and "the stranger's
2742
+ * endorsement never arrived" were indistinguishable from the listing. Different bugs, in
2743
+ * different places, and the field that separates them was already in the row.
2744
+ *
2745
+ * Discloses nothing new: the issuer pubkey is inside the notarized envelope the recipient
2746
+ * already holds and can already present.
2747
+ */
2748
+ issuer_pubkey: r.issuerPubkey,
2261
2749
  status: r.status,
2262
2750
  issued_at: r.issuedAt,
2263
2751
  expires_at: r.expiresAt,
@@ -3047,32 +3535,78 @@ async function startDaemonHoldingLock(config, singletonLock) {
3047
3535
  });
3048
3536
  // ─── MCP-001: cello_status (per-connection perspective) ───
3049
3537
  /**
3050
- * The directory-reachability block for `cello_status`, or undefined when every node resolves.
3538
+ * The directory-reachability block for `cello_status`.
3539
+ *
3540
+ * `undefined` — i.e. silence — is reserved for ONE state: a reading taken recently enough to
3541
+ * speak for the present, which found nothing wrong. Every other combination emits, including an
3542
+ * EMPTY node list, because an empty list is not proof of health: it also means nothing has
3543
+ * looked, or looked too long ago to say. `DOD-M15-STALEROSTER-1`.
3051
3544
  *
3052
- * Reports the LAST resolve sweep. An empty list is not proof of health it also means no sweep
3053
- * has run yet so the shape says which nodes failed and why rather than asserting "all good".
3545
+ * So presence of this block does NOT mean nodes are failing. Read `measurement` first
3546
+ * `current` | `stale` | `never` | `not_configured` and only then the node list.
3054
3547
  */
3055
3548
  function unresolvedNodesForStatus() {
3056
3549
  const failures = getUnresolvedNodes();
3057
- if (failures.length === 0)
3550
+ /**
3551
+ * DOD-M15-STALEROSTER-1 — the block is now gated on the AGE of the reading, not just on whether
3552
+ * it found anything.
3553
+ *
3554
+ * `if (failures.length === 0) return undefined` made two different states render identically:
3555
+ * "all three nodes answered a moment ago" and "nothing has ever looked". The second is
3556
+ * reachable — `verifyStartupManifest` returns without sweeping when the consortium manifest is
3557
+ * missing, not yet valid, EXPIRED, or rolled back — so a daemon with an expired manifest
3558
+ * reported no directory trouble at all.
3559
+ *
3560
+ * Silence is therefore reserved for the one case that has earned it: a RECENT reading that
3561
+ * found nothing wrong. Every other case says why it cannot make that claim.
3562
+ */
3563
+ const freshness = describeRosterFreshness(classifyRosterReading(getUnresolvedSweptAt(), Date.now()), {
3564
+ // REVIEW F2: "no manifest configured" is DESIGNED (local dev, the e2e harness, or a
3565
+ // CELLO_DIRECTORY_URL that is not byte-equal to a bundled endpoint) and must not be dressed
3566
+ // as an alarm — it would fire on every local run. It still EMITS, because the line forbids
3567
+ // hiding the field; what differs is what the operator is told.
3568
+ manifestConfigured: manifestProvider !== undefined,
3569
+ ...(lastRosterSweepError ? { lastSweepError: lastRosterSweepError } : {}),
3570
+ });
3571
+ if (failures.length === 0 && freshness.measurement === "current")
3058
3572
  return undefined;
3059
3573
  return {
3060
3574
  directory_endpoints_unresolved: {
3061
- // WHEN this was measured. Without it the block asserts the PRESENT, and a transient blip
3062
- // reads as an ongoing outage: on 2026-08-09 all three endpoints failed with ENETUNREACH for
3063
- // under a minute a network transition on the operator's machine and the block went on
3064
- // reporting them unreachable long after they answered again. True when taken, false when read.
3065
- checked_at: getUnresolvedSweptAt(),
3066
- nodes: failures.map((f) => ({ node: f.nodeId, endpoint: f.endpoint, reason: f.reason, detail: f.detail })),
3067
- guidance: "AS OF checked_at (this is a point-in-time reading, not necessarily now), this daemon could not "
3068
- + "resolve these directory endpoints, so the consortium roster was short and " +
3069
- "threshold ceremonies will failsessions surface that as counterparty_offline, " +
3070
- "directory_below_threshold, or ceremony_exhausted, none of which name the real cause. " +
3071
- "Agents can still show 'online': signaling dials multiaddrs and does not need DNS. " +
3072
- "If reason is dns_error after a directory restart or wake, the resolver is holding a cached " +
3073
- "negative answer flush it (macOS: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder). " +
3074
- "Verify with node -e 'require(\"dns\").lookup(host,console.log)', NOT dig: dig bypasses the " +
3075
- "cache this daemon is stuck behind, so it reports success while the daemon still fails.",
3575
+ // WHEN this was measured, and whether that is recent enough to mean anything. Without it the
3576
+ // block asserts the PRESENT, and a transient blip reads as an ongoing outage: on 2026-08-09
3577
+ // all three endpoints failed with ENETUNREACH for under a minutea network transition on
3578
+ // the operator's machine and the block went on reporting them unreachable long after they
3579
+ // answered again. True when taken, false when read.
3580
+ ...freshness,
3581
+ // DOD-M15-BOOTSTRAP-1: `attempts` distinguishes a node that answered definitively (one
3582
+ // probe a 404, a bad payload, its configuration) from one that never answered at all
3583
+ // (every probe spentthe path to it). Those call for opposite responses, and without the
3584
+ // count they rendered identically here.
3585
+ nodes: failures.map((f) => ({ node: f.nodeId, endpoint: f.endpoint, reason: f.reason, detail: f.detail, attempts: f.attempts })),
3586
+ guidance: failures.length === 0
3587
+ // The block is present with an EMPTY node list, which before DOD-M15-STALEROSTER-1 could
3588
+ // not happen. Saying "could not resolve these endpoints" here would be a flat lie — there
3589
+ // are no endpoints listed and the point is that nothing was measured. The empty list is
3590
+ // the ABSENCE of a reading, not a clean bill of health, and freshness_guidance above says
3591
+ // which of the two it is.
3592
+ ? "This block is present with NO nodes listed, which does not mean the nodes are healthy — " +
3593
+ "it means this reading cannot support that claim. See freshness_guidance above for " +
3594
+ "whether the daemon has never measured, or measured too long ago to speak for the " +
3595
+ "present. Directory reachability is what threshold ceremonies depend on, so an " +
3596
+ "unmeasured roster is an unknown, not an all-clear."
3597
+ : "AS OF checked_at (this is a point-in-time reading, not necessarily now), this daemon could not "
3598
+ + "resolve these directory endpoints, so the consortium roster was short and " +
3599
+ "threshold ceremonies will fail — sessions surface that as home_node_reports_no_receiver, " +
3600
+ "home_node_not_in_reachable_roster, directory_named_no_home, directory_below_threshold or " +
3601
+ "ceremony_exhausted, none of which name the real cause on their own (DOD-M15-ERRSTRING-1 " +
3602
+ "renamed the first three; they now append this shortfall to their own guidance). " +
3603
+ "Agents can still show 'online': signaling dials multiaddrs and does not need DNS. " +
3604
+ "If reason is dns_error after a directory restart or wake, the resolver is holding a cached " +
3605
+ "negative answer — flush it (macOS: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder). " +
3606
+ "Verify with node -e 'require(\"dns\").lookup(host,console.log)', NOT dig: dig bypasses the " +
3607
+ "cache this daemon is stuck behind, so it reports success while the daemon still fails. "
3608
+ + "attempts:1 means the node ANSWERED and the answer was unusable — look at that node. "
3609
+ + "attempts:2+ means it never answered — look at the path to it.",
3076
3610
  },
3077
3611
  };
3078
3612
  }
@@ -3091,6 +3625,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
3091
3625
  //
3092
3626
  // Omitted entirely when nothing is failing, so a healthy status stays quiet.
3093
3627
  ...(unresolvedNodesForStatus() ?? {}),
3628
+ // DOD-M15-MANIFEST-EXPIRY-LIVE-1: contributes NOTHING while the manifest is comfortably in
3629
+ // window. A field present on every status read for the years a manifest is valid is furniture,
3630
+ // not a warning, and it teaches the reader to skip the block that matters.
3631
+ ...(describeManifestValidity(classifyManifestValidity(manifestProvider?.getCurrentManifest() ?? null, Date.now()), manifestOrigin) ?? {}),
3632
+ // DOD-M15-DIRAUTH-1: the posture is STATED, in both directions. Unlike every other field in
3633
+ // this milestone the healthy case is reported too — the defect is precisely that "enforced"
3634
+ // and "skipped" differ only by the absence of a log line, so an operator must be able to
3635
+ // confirm it is on, not merely fail to find evidence that it is off.
3636
+ ...describeDirectoryAuth({
3637
+ verifierPresent: challengeVerifier !== undefined,
3638
+ directoryUrl: directoryHttpUrl ?? resolveDirectoryUrl(process.env),
3639
+ // Review F5: with neither set, `resolveDirectoryUrl` re-picks a RANDOM bundled endpoint on
3640
+ // every call, so there is no configured URL to quote or to blame.
3641
+ urlExplicitlyConfigured: directoryHttpUrl !== undefined || process.env["CELLO_DIRECTORY_URL"] !== undefined,
3642
+ }),
3094
3643
  agents: getAgentsForConnection(connectionId),
3095
3644
  // M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
3096
3645
  // the daemon-wide getStatus() surfaces them.
@@ -3171,6 +3720,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
3171
3720
  // cello_close_session (close-session-handler.ts). Fifteen dependencies — a long list, but a KNOWN
3172
3721
  // one, which is the whole difference from a closure over 73 shared locals.
3173
3722
  registerCloseSessionHandler({
3723
+ // DOD-M15-CLOSEWAIT-1 review MEDIUM-6: a detached seal tail is registered here so stop() can
3724
+ // drain it, like every other background worker. Self-evicting, so a long-running daemon does not
3725
+ // accumulate settled promises.
3726
+ sealFailures,
3727
+ registerBackgroundSeal: (p) => {
3728
+ backgroundSeals.add(p);
3729
+ void p.finally(() => backgroundSeals.delete(p));
3730
+ },
3174
3731
  // M12-P14: the pre-seal readiness gate drains the parked mailbox before judging, so a close
3175
3732
  // does not refuse over content the relay is still holding for us.
3176
3733
  recoverParkedContent: (agentName, trigger) => autoRecoverForAgent(agentName, trigger),
@@ -3208,9 +3765,74 @@ async function startDaemonHoldingLock(config, singletonLock) {
3208
3765
  handleActiveSealFlow,
3209
3766
  resolveConsortiumRoster,
3210
3767
  });
3768
+ /**
3769
+ * DOD-M15-BACKUP-1 — export the agent, so losing the machine does not lose the identity.
3770
+ *
3771
+ * `VACUUM INTO` takes a consistent snapshot with the database still open, so this is safe to call
3772
+ * on a running daemon — which matters, because the daemon is what serves the tool.
3773
+ */
3774
+ handlers.set("cello_backup", async (params, _connectionId) => {
3775
+ const outPath = typeof params?.["path"] === "string" ? params["path"] : "";
3776
+ if (!outPath) {
3777
+ return {
3778
+ ok: false,
3779
+ reason: "missing_path",
3780
+ guidance: "Give an absolute path to write the backup to, e.g. { path: \"/Users/you/cello-agent.cello-backup\" }. " +
3781
+ "It is not written to a default location on purpose: the file contains the key to your agent, " +
3782
+ "so where it lands is a decision you should make deliberately.",
3783
+ };
3784
+ }
3785
+ const res = await createBackup({
3786
+ dbPath: join(celloDir, "sessions.db"),
3787
+ outPath,
3788
+ logger,
3789
+ ...(params?.["overwrite"] === true ? { overwrite: true } : {}),
3790
+ });
3791
+ return res.ok
3792
+ ? { ok: true, path: res.path, bytes: res.bytes, guidance: res.guidance }
3793
+ : { ok: false, reason: res.reason, guidance: res.guidance };
3794
+ });
3795
+ /**
3796
+ * RESTORE IS REFUSED WHILE THIS DAEMON IS RUNNING, and that is not a limitation to apologise for.
3797
+ *
3798
+ * The daemon holds the database open with a write lock. Overwriting the file underneath an open
3799
+ * SQLite handle risks the handle flushing its own pages back over the restored ones — the restore
3800
+ * appears to succeed and the database is a hybrid of two identities, which is worse than either
3801
+ * failing cleanly.
3802
+ *
3803
+ * The capability itself lives in `backup-restore.ts` (a daemon module, per the DoD), and the CLI
3804
+ * calls it with the daemon stopped. So the honest answer here is the exact sequence, not an
3805
+ * attempt.
3806
+ */
3807
+ handlers.set("cello_restore", async (params, _connectionId) => {
3808
+ const archivePath = typeof params?.["path"] === "string" ? params["path"] : "";
3809
+ const inspected = archivePath ? await inspectBackup(archivePath) : null;
3810
+ return {
3811
+ ok: false,
3812
+ reason: "daemon_running",
3813
+ ...(inspected?.ok
3814
+ ? { archive_verified: true, backup_created_at: new Date(inspected.createdAt).toISOString() }
3815
+ : inspected
3816
+ ? { archive_verified: false, archive_problem: inspected.reason }
3817
+ : {}),
3818
+ guidance: (inspected?.ok
3819
+ ? `That archive is valid (taken ${new Date(inspected.createdAt).toISOString()}). `
3820
+ : inspected
3821
+ ? `WARNING: that archive did not validate (${inspected.reason}) — fix that before going further. `
3822
+ : "") +
3823
+ "Restoring REPLACES this machine's agent database, and it cannot be done while the daemon " +
3824
+ "is holding it open — the running daemon could flush its own pages back over the restored " +
3825
+ "ones and leave a database that is half one identity and half another. Stop the daemon " +
3826
+ "first:\n\n cello logout\n cello restore " +
3827
+ (archivePath || "<archive>") +
3828
+ "\n cello login\n\n" +
3829
+ "Anything that happened on this machine since the backup was taken will be gone — restore " +
3830
+ "replaces, it does not merge.",
3831
+ };
3832
+ });
3211
3833
  // ─── MCP-001: stubs for tools registered in cello-mcp.ts but not yet implemented ───
3212
3834
  // These return not_implemented (same as session tools) so LLMs get consistent guidance.
3213
- for (const tool of ["cello_backup", "cello_restore", "cello_get_inclusion_proof"]) {
3835
+ for (const tool of ["cello_get_inclusion_proof"]) {
3214
3836
  handlers.set(tool, async (_params, _connectionId) => {
3215
3837
  return { ok: false, reason: "not_implemented", guidance: `'${tool}' is not yet implemented in the daemon. This feature will be available in a future milestone.` };
3216
3838
  });
@@ -3232,6 +3854,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
3232
3854
  logger,
3233
3855
  sessionNodeManager,
3234
3856
  loadedAgents,
3857
+ // DOD-M15-CLOSEWAIT-1 review HIGH-2: the SAME predicate cello_status uses, so the two surfaces
3858
+ // cannot disagree about whether a ceremony is in flight. Both maps, because either can be the
3859
+ // one running — pendingSealWaiters is the active close, sealInterruptedInProgress the interrupted.
3860
+ isSealing: (agentName, sessionId) => pendingSealWaiters.has(sealKey(agentName, sessionId)) ||
3861
+ sealInterruptedInProgress.has(sealKey(agentName, sessionId)),
3862
+ // DOD-M15-SEAL-FAILED-TERMINAL-1: the SAME store the close handler writes, so a failure recorded
3863
+ // by the detached tail is the one the receipt surface reads.
3864
+ getSealFailure: (agentName, sessionId) => sealFailures.get(agentName, sessionId),
3235
3865
  getConnState: (connectionId) => perConnectionState.get(connectionId),
3236
3866
  resolveCurrentAgent,
3237
3867
  NO_CURRENT_AGENT_RESPONSE,
@@ -3306,7 +3936,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
3306
3936
  return { error: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass agentName." };
3307
3937
  }
3308
3938
  const agentId = sessionNodeManager.resolveAgentId(agentName);
3309
- retryQueue.enqueueAwaitingContent(agentId, sessionId, Buffer.from(contentHashHex, "hex"), Buffer.from(contentHex, "hex"));
3939
+ // The fault-injection IPC seam. It states all three trailing values explicitly rather than
3940
+ // relying on defaults (B2b-1 pass-2 F1): `undefined` for the ordering record and the algorithm
3941
+ // is what this path genuinely has — it injects a bare queued entry — and saying so keeps the
3942
+ // seam honest about what it is producing rather than inheriting whatever the signature assumed.
3943
+ retryQueue.enqueueAwaitingContent(agentId, sessionId, Buffer.from(contentHashHex, "hex"), Buffer.from(contentHex, "hex"), undefined, undefined, undefined);
3310
3944
  return { queued: true, awaitingDepth: retryQueue.getAwaitingDepth(agentId, sessionId) };
3311
3945
  });
3312
3946
  // CELLO-M7-MSG-001: a `persisted` delivery ACK (or a confirmed park) clears the durable
@@ -3393,7 +4027,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
3393
4027
  const sessionId = params?.sessionId;
3394
4028
  if (!agentName || !sessionId)
3395
4029
  return { error: "missing_params", guidance: "Provide agentName and sessionId." };
3396
- recordRefusal(agentName, sessionId, params?.counterpartyPubkey ?? "", params?.reason ?? "test");
4030
+ // The reason is VALIDATED against the closed union rather than cast through it. A test seam
4031
+ // that can inject a reason production cannot produce would let a test prove the inbox handles a
4032
+ // code no refusal path emits — which is the shape DOD-M15-GUARD-HEARD-1 exists to remove, and a
4033
+ // seam is not exempt from it.
4034
+ const asked = params?.reason ?? CAPACITY_REASONS.ABUSE_BOUND_SESSIONS_PER_SENDER;
4035
+ const known = [
4036
+ ...Object.values(REFUSAL_REASONS),
4037
+ ...Object.values(CAPACITY_REASONS),
4038
+ ];
4039
+ if (!known.includes(asked)) {
4040
+ return {
4041
+ error: "unknown_refusal_reason",
4042
+ guidance: `"${asked}" is not a reason any refusal path can emit. Use one of: ${known.join(", ")}. ` +
4043
+ `Seeding an invented reason would test the inbox against a code production never produces.`,
4044
+ };
4045
+ }
4046
+ recordRefusal(agentName, sessionId, params?.counterpartyPubkey ?? "", asked);
3397
4047
  return { ok: true };
3398
4048
  });
3399
4049
  handlers.set("__test_delivery_open_begin", async (params, _connectionId) => {
@@ -3570,12 +4220,27 @@ async function startDaemonHoldingLock(config, singletonLock) {
3570
4220
  if (!handler)
3571
4221
  return undefined;
3572
4222
  return async (params, connectionId) => {
3573
- const result = await handler(params, connectionId);
4223
+ /**
4224
+ * ONE request, ONE store — and the fallback notice is spread in BEFORE `renderForSurface`.
4225
+ *
4226
+ * `DOD-M15-SELECTION-1` clause 2 first annotated the response out in `ipc-server.ts`, which
4227
+ * is downstream of this wrapper and therefore downstream of surface rendering. The notice
4228
+ * says *"Run cello_use_agent"*; `isInstructionKey` in `vocabulary.ts` rewrites any key
4229
+ * ending in `guidance`, so that WOULD have become `cello use-agent` for a terminal — but it
4230
+ * arrived after the rewrite had already run. An operator running `cello inbox` was handed a
4231
+ * verb that does not exist in a shell, which is the exact failure the vocabulary layer was
4232
+ * built to prevent. Annotating here puts it back in front of the renderer.
4233
+ */
4234
+ const store = {};
4235
+ const result = await fallbackNoticeStore.run(store, () => handler(params, connectionId));
4236
+ const annotated = store.notice && result !== null && typeof result === "object" && !Array.isArray(result)
4237
+ ? { ...result, ...store.notice }
4238
+ : result;
3574
4239
  // Default to "cli": a connection that never sent ipc.connect has no recorded surface, and
3575
4240
  // the CLI verb is the safe answer — it is at least a real command an operator can run,
3576
4241
  // whereas an MCP tool name is useless in a terminal.
3577
4242
  const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
3578
- return renderForSurface(result, surface);
4243
+ return renderForSurface(annotated, surface);
3579
4244
  };
3580
4245
  },
3581
4246
  };
@@ -3629,6 +4294,63 @@ async function startDaemonHoldingLock(config, singletonLock) {
3629
4294
  const documentLayer = createDocumentLayer({
3630
4295
  db: sessionNodeManager.getDb(),
3631
4296
  logger,
4297
+ // DOD-DOC-SCREEN-CONTENT-1 — the gateway, reached with TEXT for once.
4298
+ //
4299
+ // The same program that screens conversation messages, called from the document gate's shadow
4300
+ // with the readable projected text instead of the signed binary envelope the wire carries.
4301
+ //
4302
+ // ── WHICH VERDICTS REFUSE, AND WHY IT IS NOT "ALL OF THEM" ────────────────────────────────
4303
+ //
4304
+ // ONLY the injection verdict. The gateway runs its whole inbound composition, and two of its
4305
+ // other terminal blocks must not refuse a document:
4306
+ //
4307
+ // - the LANGUAGE ALLOWLIST (default: Latin only). A shared document written in Japanese,
4308
+ // Arabic or Russian is ordinary use, and every update would be refused — permanently, since
4309
+ // the same bytes are the same language on redelivery. The allowlist exists so a jailbreak
4310
+ // phrased in a low-resource language cannot dodge English-trained screening; refusing a
4311
+ // colleague's document outright is a far worse trade than that gap, and the character
4312
+ // denylist still runs on every script.
4313
+ // - the SIZE CAP, which is the sanitizer's per-message cap and not this path's limit. The
4314
+ // gate already bounds an update and the whole document by its own numbers.
4315
+ //
4316
+ // A REDACT verdict is never honoured: rewriting one holder's replica is permanent divergence
4317
+ // both sides converge on and neither can see.
4318
+ //
4319
+ // A TRANSIENT block means the gateway is DOWN or its screener faulted. The update is admitted —
4320
+ // holding document convergence hostage to an optional layer breaks a layer that degrades by
4321
+ // design — but it is LOGGED BY NAME, because a weaker guarantee that looks identical to the
4322
+ // stronger one at every surface is how this whole class of defect survives.
4323
+ screenProjected: async (text, ctx) => {
4324
+ const verdict = await config.securityGateway.screenInbound(new TextEncoder().encode(text), {
4325
+ direction: "inbound",
4326
+ agentName: ctx.ownerAgentId,
4327
+ sessionId: ctx.documentId,
4328
+ ...(ctx.correlationId !== undefined ? { correlationId: ctx.correlationId } : {}),
4329
+ });
4330
+ if (verdict.disposition !== "block")
4331
+ return { block: false };
4332
+ if (verdict.terminal !== true) {
4333
+ logger.warn("document.inbound.screen.unavailable", {
4334
+ documentId: ctx.documentId,
4335
+ senderAgentId: ctx.senderAgentId,
4336
+ reason: verdict.reason,
4337
+ consequence: "the update was admitted WITHOUT a semantic screen",
4338
+ correlationId: ctx.correlationId,
4339
+ });
4340
+ return { block: false };
4341
+ }
4342
+ if (verdict.reason !== INBOUND_INJECTION_BLOCKED) {
4343
+ logger.info("document.inbound.screen.not_applicable", {
4344
+ documentId: ctx.documentId,
4345
+ senderAgentId: ctx.senderAgentId,
4346
+ reason: verdict.reason,
4347
+ consequence: "a terminal block this path does not apply to documents — admitted",
4348
+ correlationId: ctx.correlationId,
4349
+ });
4350
+ return { block: false };
4351
+ }
4352
+ return { block: true, reason: verdict.reason };
4353
+ },
3632
4354
  // M14-D5: a remote agent's id IS its K_local pubkey hex, so this needs no lookup — and a lookup
3633
4355
  // on the critical path of every signature check is precisely what it must not have.
3634
4356
  publicKeyFor: agentPublicKeyFromId,
@@ -3694,7 +4416,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
3694
4416
  return provider.sign(tbs);
3695
4417
  },
3696
4418
  });
3697
- sessionNodeManager.setOnDocumentFrame(documentLayer.onDocumentFrame);
4419
+ // The classify-only half rides the same setter so the router and the ingest cannot disagree
4420
+ // about what a document frame is (DOD-DOC-SCREEN-CLASSIFY-1).
4421
+ sessionNodeManager.setOnDocumentFrame(documentLayer.onDocumentFrame, isDocumentFrame);
3698
4422
  // M14 / DOD-DOC-DELIVERY-2 — the outbound half, wired only now that INBOUND-2 is. The ordering
3699
4423
  // constraint on the DoD line is real and this is where it is honoured: a delivery worker with no
3700
4424
  // inbound counterpart publishes envelopes nobody can answer, so every document would stall at the
@@ -3767,7 +4491,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
3767
4491
  logger.error("document.delivery.seal_failed", { agent, sessionId, reason: "close_handler_missing", correlationId });
3768
4492
  return;
3769
4493
  }
3770
- const sealed = (await close({ session_id: sessionId, agent }, `doc-delivery-${correlationId}`));
4494
+ /**
4495
+ * `wait_for_seal: true` — DOD-M15-CLOSEWAIT-1 review MEDIUM-5.
4496
+ *
4497
+ * This is NOT an IPC caller. It is an in-process worker awaiting the close for a session
4498
+ * it opened itself, and the `ok !== true` check below is the only thing that ever reports
4499
+ * a failed document-delivery seal. The new default answers `ok: true` at COMMITMENT, so
4500
+ * without this flag that check could never fire for a ceremony failure again — §16.4's
4501
+ * "the autonomous session still carries the seal" would rest on a detached task nobody
4502
+ * awaited, retried or reported on.
4503
+ *
4504
+ * The whole point of answering early is that a human is watching a terminal. Nobody is
4505
+ * watching this one, so it takes the blocking form — which also stops each delivery
4506
+ * leaving a detached ceremony holding a visiting connection for up to eleven minutes,
4507
+ * overlapping instead of serialising.
4508
+ */
4509
+ const sealed = (await close({ session_id: sessionId, agent, wait_for_seal: true }, `doc-delivery-${correlationId}`));
3771
4510
  if (sealed?.ok !== true) {
3772
4511
  // `cello_close_session` has distinct failure codes — session_already_sealed,
3773
4512
  // seal_interrupted_*, signaling_reconnecting — and every one of them landed nowhere.
@@ -3780,7 +4519,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
3780
4519
  // transport asked for 0x04, the composition root threw it away, and every document leaf
3781
4520
  // still reached the relay as a MESSAGE. Verified on live traffic: daemon 0.0.145 shipped
3782
4521
  // the fix everywhere except here and the wire was unchanged.
3783
- sendContent: (agent, sessionId, content, contentHash, correlationId, leafKind) => sessionNodeManager.sendContent(agent, sessionId, content, contentHash, correlationId, leafKind),
4522
+ //
4523
+ // ⚠️ AND `contentHashAlg` IS THE SECOND PARAMETER THIS ADAPTER MUST NOT DROP. The note above
4524
+ // is about `leafKind`, which this wrapper silently swallowed while every other caller passed
4525
+ // it — a thin pass-through is exactly where a new argument goes missing, because nothing
4526
+ // about the call site looks wrong afterwards. B2b's failure mode if it happens again: the
4527
+ // document path sends a salted hash labelled `sha256`, and every peer refuses it.
4528
+ sendContent: (agent, sessionId, content, contentHash, correlationId, leafKind, contentHashAlg) => sessionNodeManager.sendContent(agent, sessionId, content, contentHash, correlationId, leafKind, contentHashAlg),
4529
+ contentHashForSession: (agent, sessionId, content) => sessionNodeManager.contentHashForSession(agent, sessionId, content),
3784
4530
  // The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
3785
4531
  // successful send. See the comment at the call site for why this is delivery-critical and
3786
4532
  // not audit bookkeeping.
@@ -3951,6 +4697,33 @@ async function startDaemonHoldingLock(config, singletonLock) {
3951
4697
  // MCP-001: Clean up per-connection state when a connection disconnects
3952
4698
  // MCP-002: Also unregister from notification dispatcher
3953
4699
  ipcServer.onDisconnect((connectionId) => {
4700
+ /**
4701
+ * DOD-M15-IPCVISIBLE-1: SAY THAT IT CLOSED, and say what it was attending.
4702
+ *
4703
+ * `daemon.ipc.connected` fired on every open and nothing on close, so a live client and a dead
4704
+ * one that was never cleaned up looked identical in the log. The attended agent is the field
4705
+ * that matters: attendance dropping was silent, and an agent losing its last attendee changes
4706
+ * whether away-messages fire and who receives doorbells — so a session that stopped waking is
4707
+ * diagnosable from the log rather than by guesswork.
4708
+ */
4709
+ const closing = perConnectionState.get(connectionId);
4710
+ const stillAttending = closing?.currentAgent
4711
+ ? countAttendance(perConnectionState, closing.currentAgent) - 1
4712
+ : null;
4713
+ // RETURNED, not logged here — `ipcServer` merges this into its single
4714
+ // `daemon.ipc.disconnected` line. A second line under the same name left neither carrying the
4715
+ // whole picture and doubled every count (review F8).
4716
+ const disconnectContext = {
4717
+ clientType: closing?.clientType ?? "unknown",
4718
+ attendedAgent: closing?.currentAgent ?? null,
4719
+ ...(stillAttending !== null ? { remainingAttendance: stillAttending } : {}),
4720
+ ...(stillAttending === 0
4721
+ ? {
4722
+ impact: "that agent has no attending session left — inbound sessions are now answered with " +
4723
+ "its away message rather than a live reply, and its doorbells reach nobody",
4724
+ }
4725
+ : {}),
4726
+ };
3954
4727
  perConnectionState.delete(connectionId);
3955
4728
  connectionCursors.delete(connectionId); // M8C-CURSOR-1: cursor is connection-scoped, dies with it
3956
4729
  // ...and so is the delivery bookmark (review F1). It is a SEPARATE map from the gate's cursor
@@ -3983,6 +4756,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
3983
4756
  else
3984
4757
  inboundSessionWaiters.delete(agentName);
3985
4758
  }
4759
+ return disconnectContext;
3986
4760
  });
3987
4761
  // Log daemon.login.validation.complete (stub — all unverified until SIGNAL-001)
3988
4762
  logger.info("daemon.login.validation.complete", {
@@ -4033,6 +4807,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
4033
4807
  if (manifestPollScheduler) {
4034
4808
  manifestPollScheduler.cancel();
4035
4809
  }
4810
+ // DOD-M15-STALEROSTER-1: same discipline as the manifest poll — the stop function sets the
4811
+ // flag so an in-flight sweep cannot re-arm, and the belt-and-suspenders cancel covers the
4812
+ // scheduler-without-sweep case.
4813
+ stopRosterSweep?.();
4814
+ rosterSweepScheduler?.cancel();
4036
4815
  stopRegistryPoll?.();
4037
4816
  if (config.registryPollScheduler) {
4038
4817
  config.registryPollScheduler.cancel();
@@ -4042,6 +4821,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
4042
4821
  // outbound work is not draining. Above the `daemon.stopped` log with the other cancels, because
4043
4822
  // this is "stop making new work", not "tear down transports".
4044
4823
  await restartSealResolver?.stop();
4824
+ /**
4825
+ * DRAIN THE DETACHED SEAL TAILS — review MEDIUM-6, and it sits HERE for the same reason
4826
+ * `restartSealResolver.stop()` does: both are directory ceremonies that must not be cut with
4827
+ * the counterparty holding a commitment this side never acknowledged. Before
4828
+ * `stopAllSignaling()`, because that is what severs the transport underneath them.
4829
+ *
4830
+ * BOUNDED. A ceremony can legitimately wait eleven minutes for a counterparty, and a shutdown
4831
+ * must not. Past the bound they are abandoned deliberately and said out loud — the next boot
4832
+ * resolves them, which is exactly what the restart seal resolver is for.
4833
+ */
4834
+ if (backgroundSeals.size > 0) {
4835
+ const SHUTDOWN_SEAL_DRAIN_MS = 5_000;
4836
+ logger.info("session.seal.background.draining", { count: backgroundSeals.size, budgetMs: SHUTDOWN_SEAL_DRAIN_MS });
4837
+ const drained = await Promise.race([
4838
+ Promise.allSettled([...backgroundSeals]).then(() => true),
4839
+ new Promise((r) => { const t = setTimeout(() => r(false), SHUTDOWN_SEAL_DRAIN_MS); t.unref?.(); }),
4840
+ ]);
4841
+ if (!drained) {
4842
+ logger.warn("session.seal.background.abandoned", {
4843
+ count: backgroundSeals.size,
4844
+ impact: "shutdown did not wait for these seal ceremonies. Each session holds a durable commitment " +
4845
+ "but no receipt yet, and the counterparty may hold a commitment this side never acknowledged.",
4846
+ guidance: "The next daemon start resolves them via the restart seal resolver; no operator action is needed.",
4847
+ });
4848
+ }
4849
+ }
4045
4850
  logger.info("daemon.stopped", { pid: process.pid, reason });
4046
4851
  // DOD-LOGOUT-EXIT-1: what the teardown actually DID, carried to onStopped so the binary can
4047
4852
  // exit non-zero on a dirty stop. Without it a shutdown that threw halfway — sessions never