@cello-protocol/daemon 0.0.181 → 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 (164) 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 +866 -122
  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/inbound-sessions.d.ts +31 -1
  54. package/dist/inbound-sessions.d.ts.map +1 -1
  55. package/dist/inbound-sessions.js +276 -16
  56. package/dist/inbound-sessions.js.map +1 -1
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +3 -0
  60. package/dist/index.js.map +1 -1
  61. package/dist/ipc-server.d.ts +5 -1
  62. package/dist/ipc-server.d.ts.map +1 -1
  63. package/dist/ipc-server.js +19 -3
  64. package/dist/ipc-server.js.map +1 -1
  65. package/dist/manifest-validity.d.ts +151 -0
  66. package/dist/manifest-validity.d.ts.map +1 -0
  67. package/dist/manifest-validity.js +268 -0
  68. package/dist/manifest-validity.js.map +1 -0
  69. package/dist/notification-handlers.d.ts.map +1 -1
  70. package/dist/notification-handlers.js +15 -0
  71. package/dist/notification-handlers.js.map +1 -1
  72. package/dist/outbound-sessions.d.ts +31 -0
  73. package/dist/outbound-sessions.d.ts.map +1 -1
  74. package/dist/outbound-sessions.js +198 -17
  75. package/dist/outbound-sessions.js.map +1 -1
  76. package/dist/park-envelope.d.ts +72 -1
  77. package/dist/park-envelope.d.ts.map +1 -1
  78. package/dist/park-envelope.js +119 -4
  79. package/dist/park-envelope.js.map +1 -1
  80. package/dist/refusal-reasons.d.ts +72 -0
  81. package/dist/refusal-reasons.d.ts.map +1 -0
  82. package/dist/refusal-reasons.js +69 -0
  83. package/dist/refusal-reasons.js.map +1 -0
  84. package/dist/registration-manager.d.ts.map +1 -1
  85. package/dist/registration-manager.js +20 -1
  86. package/dist/registration-manager.js.map +1 -1
  87. package/dist/restart-seal-resolver.d.ts.map +1 -1
  88. package/dist/restart-seal-resolver.js +6 -0
  89. package/dist/restart-seal-resolver.js.map +1 -1
  90. package/dist/retry-queue.d.ts +23 -1
  91. package/dist/retry-queue.d.ts.map +1 -1
  92. package/dist/retry-queue.js +37 -9
  93. package/dist/retry-queue.js.map +1 -1
  94. package/dist/roster-freshness.d.ts +169 -0
  95. package/dist/roster-freshness.d.ts.map +1 -0
  96. package/dist/roster-freshness.js +249 -0
  97. package/dist/roster-freshness.js.map +1 -0
  98. package/dist/seal-certificate-pull.d.ts +17 -1
  99. package/dist/seal-certificate-pull.d.ts.map +1 -1
  100. package/dist/seal-certificate-pull.js +37 -3
  101. package/dist/seal-certificate-pull.js.map +1 -1
  102. package/dist/seal-coordinator.d.ts +17 -1
  103. package/dist/seal-coordinator.d.ts.map +1 -1
  104. package/dist/seal-coordinator.js +71 -0
  105. package/dist/seal-coordinator.js.map +1 -1
  106. package/dist/seal-failure-store.d.ts +104 -0
  107. package/dist/seal-failure-store.d.ts.map +1 -0
  108. package/dist/seal-failure-store.js +126 -0
  109. package/dist/seal-failure-store.js.map +1 -0
  110. package/dist/seal-flows.d.ts.map +1 -1
  111. package/dist/seal-flows.js +35 -6
  112. package/dist/seal-flows.js.map +1 -1
  113. package/dist/seal-upgrade.d.ts +20 -5
  114. package/dist/seal-upgrade.d.ts.map +1 -1
  115. package/dist/seal-upgrade.js +28 -5
  116. package/dist/seal-upgrade.js.map +1 -1
  117. package/dist/session-assignment-parser.d.ts +41 -5
  118. package/dist/session-assignment-parser.d.ts.map +1 -1
  119. package/dist/session-assignment-parser.js +77 -14
  120. package/dist/session-assignment-parser.js.map +1 -1
  121. package/dist/session-ceremony.d.ts +29 -0
  122. package/dist/session-ceremony.d.ts.map +1 -1
  123. package/dist/session-ceremony.js +71 -0
  124. package/dist/session-ceremony.js.map +1 -1
  125. package/dist/session-connection-gater.d.ts +46 -7
  126. package/dist/session-connection-gater.d.ts.map +1 -1
  127. package/dist/session-connection-gater.js +163 -12
  128. package/dist/session-connection-gater.js.map +1 -1
  129. package/dist/session-content-handlers.d.ts.map +1 -1
  130. package/dist/session-content-handlers.js +110 -4
  131. package/dist/session-content-handlers.js.map +1 -1
  132. package/dist/session-node-manager.d.ts +406 -36
  133. package/dist/session-node-manager.d.ts.map +1 -1
  134. package/dist/session-node-manager.js +3025 -245
  135. package/dist/session-node-manager.js.map +1 -1
  136. package/dist/session-read-handlers.d.ts +27 -0
  137. package/dist/session-read-handlers.d.ts.map +1 -1
  138. package/dist/session-read-handlers.js +112 -6
  139. package/dist/session-read-handlers.js.map +1 -1
  140. package/dist/session-relay-client.d.ts +61 -2
  141. package/dist/session-relay-client.d.ts.map +1 -1
  142. package/dist/session-relay-client.js +170 -11
  143. package/dist/session-relay-client.js.map +1 -1
  144. package/dist/session-salt-agreement.d.ts +284 -0
  145. package/dist/session-salt-agreement.d.ts.map +1 -0
  146. package/dist/session-salt-agreement.js +371 -0
  147. package/dist/session-salt-agreement.js.map +1 -0
  148. package/dist/signal-submission.d.ts.map +1 -1
  149. package/dist/signal-submission.js +10 -1
  150. package/dist/signal-submission.js.map +1 -1
  151. package/dist/signaling-connect.d.ts.map +1 -1
  152. package/dist/signaling-connect.js +42 -5
  153. package/dist/signaling-connect.js.map +1 -1
  154. package/dist/types.d.ts +54 -0
  155. package/dist/types.d.ts.map +1 -1
  156. package/dist/types.js.map +1 -1
  157. package/dist/vocabulary.d.ts.map +1 -1
  158. package/dist/vocabulary.js +10 -0
  159. package/dist/vocabulary.js.map +1 -1
  160. package/dist/wire-content-hash.d.ts +56 -0
  161. package/dist/wire-content-hash.d.ts.map +1 -1
  162. package/dist/wire-content-hash.js +71 -0
  163. package/dist/wire-content-hash.js.map +1 -1
  164. 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";
@@ -71,7 +77,10 @@ import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
71
77
  import { isDocumentFrame } from "./document-frame-router.js";
72
78
  import { INBOUND_INJECTION_BLOCKED } from "@cello-protocol/gateway";
73
79
  import { registerDocumentHandlers } from "./document-handlers.js";
74
- import { wireContentHash } from "./wire-content-hash.js";
80
+ // `wireContentHash` is no longer imported here: every outbound hash in this file now comes from
81
+ // `SessionNodeManager.contentHashForSession`, which returns the hash and its ALGORITHM together
82
+ // (`DOD-M15-SEALWIRE-1` part B2b). A direct call would be a hash computed without deciding — or
83
+ // recording — how it was made, which is the state that made a version skew look like a tamper.
75
84
  import { DocumentPublish } from "./document-publish.js";
76
85
  import { createDocumentDeliveryTransport } from "./document-delivery-transport.js";
77
86
  import { ReconcileScheduler } from "./document-reconcile-scheduler.js";
@@ -81,6 +90,9 @@ import { createInboundSessions } from "./inbound-sessions.js";
81
90
  import { createOutboundSessions } from "./outbound-sessions.js";
82
91
  import { registerSessionReadHandlers } from "./session-read-handlers.js";
83
92
  import { pullSealCertificate } from "./seal-certificate-pull.js";
93
+ import { createBackup, inspectBackup } from "./backup-restore.js";
94
+ import { resolveCurrentAgentFor } from "./agent-selection.js";
95
+ import { REFUSAL_REASONS, CAPACITY_REASONS } from "./refusal-reasons.js";
84
96
  import { revocabilityOf } from "./signal-revocability.js";
85
97
  import { registerAgentHandlers } from "./agent-handlers.js";
86
98
  import { registerRegisterHandler } from "./register-handler.js";
@@ -109,6 +121,20 @@ const SESSION_NODE_KEY_STUB = {
109
121
  };
110
122
  // Production session node factory — wraps createNode from @cello-protocol/transport
111
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
+ }
112
138
  async createNode(config) {
113
139
  // DOD-NAT-REACHABILITY-1: the STANDING RECEIVER — the node that accepts every
114
140
  // inbound session — must bind a ROUTABLE interface by default. The old
@@ -145,6 +171,106 @@ export class ProductionSessionNodeFactory {
145
171
  // than passed as undefined) when absent, keeping createNode's "generate a fresh key" default
146
172
  // for every node that is not session-scoped.
147
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;
148
274
  });
149
275
  }
150
276
  }
@@ -195,6 +321,43 @@ async function startDaemonHoldingLock(config, singletonLock) {
195
321
  if (manifestProvider && (!manifestRootKeys || !manifestThreshold || manifestThreshold <= 0)) {
196
322
  throw new Error("DaemonConfig: manifestProvider requires manifestRootKeys (non-empty) and manifestThreshold (positive integer >= 1)");
197
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
+ }
198
361
  // ── PERSIST-002: open the encrypted store FIRST (runs the one-time flat-file → SQLCipher migration
199
362
  // (AC-006) + creates the agents/manifest_state schema), under the single-instance lock. This must
200
363
  // precede the manifest verification below because the manifest version is now stored in the
@@ -230,7 +393,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
230
393
  // screening, so a wiring mistake shows up here instead of hiding behind a correct-looking line.
231
394
  logger.info("security.gateway.connected", { mode: securityGateway.mode });
232
395
  const sessionNodeManager = new SessionNodeManager({
233
- factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(),
396
+ factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(logger),
234
397
  logger,
235
398
  dbPath: join(celloDir, "sessions.db"),
236
399
  contentTtfMs: config.contentTtfMs,
@@ -250,6 +413,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
250
413
  manifestThreshold,
251
414
  manifestVersionStore,
252
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 } : {}),
253
419
  });
254
420
  // ADV-002: an operator who configures manifestProvider has opted INTO manifest enforcement, so a
255
421
  // failed verification is fatal — never a warning we start anyway on.
@@ -268,7 +434,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
268
434
  "Check the logs for the specific failure reason (manifest_signature_invalid, manifest_expired, or manifest_version_rollback).");
269
435
  }
270
436
  // The manifest poll starts only AFTER the refuse above — a refused startup must not leak a timer.
271
- const { resolveConsortiumRoster, failoverEndpointResolver, getFailoverEndpoint, getUnresolvedNodes, getUnresolvedSweptAt, stopHttpManifestPoll } = createConsortiumRouting({
437
+ const { resolveConsortiumRoster, failoverEndpointResolver, getFailoverEndpoint, getUnresolvedNodes, getUnresolvedSweptAt, getDeclaredNodeCount, stopHttpManifestPoll } = createConsortiumRouting({
272
438
  manifestProvider,
273
439
  manifestRootKeys,
274
440
  manifestThreshold,
@@ -282,7 +448,96 @@ async function startDaemonHoldingLock(config, singletonLock) {
282
448
  initialUnresolvedNodes: startupUnresolvedNodes,
283
449
  initialUnresolvedSweptAt: startupSweptAt,
284
450
  logger,
451
+ ...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
285
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;
286
541
  // DOD-REGISTRY-1: type registry poll — daemon-level, runs even with zero agents.
287
542
  // When registryPubkey is configured, the daemon polls GET /registry, verifies the inner
288
543
  // Ed25519 signature, and updates the in-memory TypeRegistry. A poll failure never blanks
@@ -580,6 +835,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
580
835
  agentName,
581
836
  persistence: getPersistence(agentName),
582
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),
583
842
  keyProvider: agentKeyProvider,
584
843
  getNode: entry.getNode,
585
844
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -592,6 +851,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
592
851
  agentName,
593
852
  persistence: getPersistence(agentName),
594
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),
595
858
  keyProvider: agentKeyProvider,
596
859
  getNode: entry.getNode,
597
860
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -606,6 +869,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
606
869
  wireSessionOfferHandler({
607
870
  agentName,
608
871
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agentName),
872
+ admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agentName, peerId, sessionIdHex),
609
873
  signaling: mgr,
610
874
  logger,
611
875
  });
@@ -654,6 +918,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
654
918
  agentName: agent.name,
655
919
  persistence: getPersistence(agent.name),
656
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),
657
923
  keyProvider: agent.keyProvider,
658
924
  getNode: noSharedDirectoryNode,
659
925
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -665,6 +931,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
665
931
  agentName: agent.name,
666
932
  persistence: getPersistence(agent.name),
667
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),
668
936
  keyProvider: agent.keyProvider,
669
937
  getNode: noSharedDirectoryNode,
670
938
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -675,6 +943,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
675
943
  wireSessionOfferHandler({
676
944
  agentName: agent.name,
677
945
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
946
+ admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agent.name, peerId, sessionIdHex),
678
947
  signaling: mgr,
679
948
  logger,
680
949
  });
@@ -890,6 +1159,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
890
1159
  // whether its counterparty is quiet or its sibling was faster. Written at the destructive drain in
891
1160
  // session-content-handlers; read at that handler's timeout. Delivery itself is unchanged.
892
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();
893
1178
  // DOD-AWAY-WRAP-1 AC1: request text is a leave-a-message greeting; agentName is spliced in at
894
1179
  // the call site so it names the specific away agent.
895
1180
  // DOD-AWAY-ACK-ONESHOT-TEXT-1 (live defect 2026-07-24): the ack must state the one-shot rule —
@@ -979,9 +1264,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
979
1264
  // FRONT precisely so [[WRAP]] keeps the end position the counterparty's detector anchors on.
980
1265
  const rejectText = markAsAutoReply("This inbox only accepts one message per visit. Closing. [[WRAP]]");
981
1266
  const rejectBytes = new TextEncoder().encode(rejectText);
982
- 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);
983
1269
  // Best-effort: a send failure still triggers the seal — we are closing regardless.
984
- const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(rejectHash), randomUUID());
1270
+ const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(reject.hash), randomUUID(), LEAF_KIND_MSG, reject.alg);
985
1271
  // M12-P13: commit the leaf when the rejection went out OR when it is durably queued —
986
1272
  // either way the relay already witnessed its sequence. This caller is the sharpest case
987
1273
  // of the three: the seal is initiated immediately below, so a hole here does not merely
@@ -989,7 +1275,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
989
1275
  // counterparty will still receive content at. The roots then cannot agree, and the
990
1276
  // session is unsealable for good.
991
1277
  if (sendResult.ok || sendResult.durable) {
992
- const rejectHashHex = Buffer.from(rejectHash).toString("hex");
1278
+ const rejectHashHex = Buffer.from(reject.hash).toString("hex");
993
1279
  // DOD-M12B-INDEX-1: at the relay's position, not the tail. This is the riskiest append
994
1280
  // in the codebase for that — the seal is initiated a few lines below, so a leaf at the
995
1281
  // wrong index does not merely stall the far side, it seals a tree the counterparty can
@@ -1015,85 +1301,124 @@ async function startDaemonHoldingLock(config, singletonLock) {
1015
1301
  // not have ingested it yet when the seal request arrives over the signaling channel.
1016
1302
  // The relay path avoids this entirely — it posts a SEAL ctrl leaf and waits for the
1017
1303
  // counterparty to independently co-seal; no bilateral leaf-count comparison needed.
1018
- void (async () => {
1019
- const correlationId = randomUUID();
1020
- const sk = sealKey(agentName, sessionId);
1021
- if (sealInterruptedInProgress.has(sk))
1022
- return;
1023
- sealInterruptedInProgress.add(sk);
1024
- try {
1025
- let resolveSeal;
1026
- const sealedP = new Promise((r) => { resolveSeal = r; });
1027
- pendingSealWaiters.set(sk, resolveSeal);
1028
- const submit = await sessionNodeManager.submitSealLeaf(agentName, sessionId, correlationId);
1029
- if (!submit.ok && submit.reason !== "responder_seal_already_submitted") {
1030
- pendingSealWaiters.delete(sk);
1031
- if (submit.reason === "relay_unavailable") {
1032
- const fallback = await handleActiveSealFlow(sessionId, record2, correlationId);
1033
- if (fallback.ok) {
1034
- logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "signaling_fallback" });
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
+ }
1035
1351
  }
1036
1352
  else {
1037
- 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 });
1038
1354
  }
1355
+ return;
1039
1356
  }
1040
- else {
1041
- 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;
1042
1379
  }
1043
- return;
1044
- }
1045
- logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "relay" });
1046
- const bilateralTimeoutMs = Number(process.env["CELLO_SEAL_BILATERAL_TIMEOUT_MS"]) || 660_000;
1047
- let timer;
1048
- const timeoutP = new Promise((r) => { timer = setTimeout(() => r(null), bilateralTimeoutMs); });
1049
- const sealedCompletion = await Promise.race([sealedP, timeoutP]);
1050
- clearTimeout(timer);
1051
- pendingSealWaiters.delete(sk);
1052
- if (sealedCompletion !== null) {
1053
- logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: sealedCompletion.rootHex });
1054
- return;
1055
- }
1056
- // Bilateral timeout — escalate to unilateral seal.
1057
- const escalation = submit.ok
1058
- ? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
1059
- : submit.reason === "responder_seal_already_submitted" &&
1060
- typeof submit.reportedRootHex === "string" &&
1061
- typeof submit.sequenceNumber === "number"
1380
+ // Bilateral timeout — escalate to unilateral seal.
1381
+ const escalation = submit.ok
1062
1382
  ? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
1063
- : null;
1064
- if (!escalation) {
1065
- logger.warn("session.away.inbox.oneshot.seal_pending", { agentName, sessionId });
1066
- return;
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
+ }
1067
1416
  }
1068
- // DOD-M12B-SEAL-ESCALATE-DUP-1: THE SHARED ESCALATION, not a second copy.
1069
- //
1070
- // This used to be a line-for-line duplicate with its own hardcoded 30 s timeout, and
1071
- // it missed every refusal the other one gained: an empty carry, a gappy chain, two of
1072
- // our own ctrl leaves (permanently unsealable), and a bilateral seal already running.
1073
- // It spent the full timeout on each and then reported `seal_unilateral_timeout` — the
1074
- // label that names our own wait. Sharing the body is what stops that drifting again.
1075
- const uni = await runUnilateralEscalation({
1076
- logger, sessionNodeManager, sendOver, pendingUnilateralWaiters, sealKey,
1077
- getKeyProvider: (a) => keyProviders.get(a),
1078
- timeoutMs: UNILATERAL_SEAL_TIMEOUT_MS,
1079
- }, agentName, sessionId, escalation, correlationId, { refuseOnUnusableCarry: true });
1080
- if (uni.ok) {
1081
- logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: uni.sealed_root, sealType: "unilateral" });
1417
+ finally {
1418
+ sealInterruptedInProgress.delete(sealKey(agentName, sessionId));
1082
1419
  }
1083
- else {
1084
- // CARRY THE GUIDANCE. The four refusals this path just gained come with the
1085
- // sentence that tells an operator whether to retry or force-abandon — and the log
1086
- // is this path's ONLY surface, so dropping it leaves a nameable cause with no
1087
- // action attached. The old reasons had no guidance to lose; these do.
1088
- logger.warn("session.away.inbox.oneshot.seal_unilateral_failed", {
1089
- agentName, sessionId, reason: uni.reason, guidance: uni.guidance,
1090
- });
1091
- }
1092
- }
1093
- finally {
1094
- sealInterruptedInProgress.delete(sealKey(agentName, sessionId));
1095
- }
1096
- })();
1420
+ })();
1421
+ }
1097
1422
  }
1098
1423
  }
1099
1424
  return;
@@ -1150,8 +1475,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
1150
1475
  ? new Uint8Array(awayVerdict.content)
1151
1476
  : draftBytes;
1152
1477
  const contentBytes = new TextEncoder().encode(markAsAutoReply(new TextDecoder().decode(screenedBytes)));
1153
- const contentHash = wireContentHash(contentBytes);
1154
- const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
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);
1155
1481
  if (!sendResult.ok && !sendResult.durable) {
1156
1482
  // Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
1157
1483
  // away period — clear the guard so the next inbound arrival retries the ack.
@@ -1165,7 +1491,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1165
1491
  });
1166
1492
  return;
1167
1493
  }
1168
- const contentHashHex = Buffer.from(contentHash).toString("hex");
1494
+ const contentHashHex = Buffer.from(away.hash).toString("hex");
1169
1495
  if (!sendResult.ok) {
1170
1496
  // M12-P13 (found live 2026-08-05, M12 Entry 89): the reply is durably queued and already
1171
1497
  // owns the sequence the relay witnessed for it, so its leaf MUST be committed here. Without
@@ -1382,6 +1708,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
1382
1708
  challengeVerifier,
1383
1709
  getManifestVersion: () => verifiedManifestVersion,
1384
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,
1385
1716
  });
1386
1717
  // Both use the same SQLite DB as the SessionNodeManager (daemon.db equivalent).
1387
1718
  // loadFromDb() must complete BEFORE IPC socket opens (AC-007).
@@ -1457,15 +1788,20 @@ async function startDaemonHoldingLock(config, singletonLock) {
1457
1788
  onPersisted: (agentName, sessionId, contentHashHex) => {
1458
1789
  retryQueue.markContentAcked(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"));
1459
1790
  },
1460
- onTtf: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) => {
1461
- 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);
1462
1798
  },
1463
1799
  // M12-P12: same durable destination, different cause — a park deposit the relay refused. The
1464
1800
  // TTF timer is already cancelled on this path, so this is the only thing holding the content.
1465
1801
  // M12-P13 (review HIGH-1): the enqueue's own answer is returned, never a bare `true`. A dropped
1466
1802
  // copy that reports success now buys a committed hash-chain leaf for content that is gone.
1467
- onParkFailed: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) => {
1468
- return retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor);
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);
1469
1805
  },
1470
1806
  });
1471
1807
  // MSG-001-3b (2b): the LIVE content-park deposit. On a not-confirmed send (direct delivery
@@ -1511,7 +1847,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1511
1847
  logger.warn("session.seal.autoack.broker.unreachable", { agentName, brokerNode, correlationId });
1512
1848
  return null;
1513
1849
  });
1514
- 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 }) => {
1515
1851
  const node = sessionNodeManager.getStandingReceiverNode();
1516
1852
  if (!node) {
1517
1853
  const reason = "standing_receiver_unavailable";
@@ -1545,12 +1881,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
1545
1881
  // SEC-1: sealParkEnvelope is the SOLE producer — it signs (sender's K_local, over the
1546
1882
  // session/recipient/content binding) and seals in one place, so the two park sites cannot drift
1547
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.
1548
1890
  const ciphertext = await sealParkEnvelope({
1549
1891
  signer: senderKp,
1550
1892
  sessionIdHex: sessionId,
1551
1893
  recipientPubkey,
1552
1894
  contentHash: contentHashBytes,
1553
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,
1554
1899
  structure1Cbor,
1555
1900
  structure2Cbor,
1556
1901
  });
@@ -1623,12 +1968,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
1623
1968
  // empty after a restart, so arrival order there means a wrong leaf index and a divergent tree.
1624
1969
  // SEC-1: same sole producer as the live hook — the backstop signs from the persisted
1625
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.
1626
1977
  const ciphertext = await sealParkEnvelope({
1627
1978
  signer: senderKp,
1628
1979
  sessionIdHex: entry.sessionId,
1629
1980
  recipientPubkey,
1630
1981
  contentHash: contentHashBytes,
1631
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,
1632
1986
  structure1Cbor: entry.structure1Cbor,
1633
1987
  structure2Cbor: entry.structure2Cbor,
1634
1988
  });
@@ -1789,7 +2143,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
1789
2143
  return {
1790
2144
  name: a.name,
1791
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
+ */
1792
2158
  selected,
2159
+ selected_by_this_connection: selected,
2160
+ attended_by: countAttendance(perConnectionState, a.name),
1793
2161
  pubkey: a.pubkey,
1794
2162
  // M8B F14 (fix 5): per-agent standing-receiver readiness on the MCP surface
1795
2163
  // (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
@@ -2005,6 +2373,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
2005
2373
  //
2006
2374
  // Omitted entirely when nothing is failing, so a healthy status stays quiet.
2007
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
+ }),
2008
2391
  // M8B F14 (fix 5): per-agent standing-receiver readiness, so a deaf agent (online but
2009
2392
  // no armed receiver) is visible in cello_status instead of hiding behind the ANY-agent
2010
2393
  // aggregate below (kept for backward compatibility).
@@ -2117,16 +2500,95 @@ async function startDaemonHoldingLock(config, singletonLock) {
2117
2500
  * alice, alice was stopped, and the work lands on bob reporting success. A lost intent is not the
2118
2501
  * same as no intent, and it must fail loud (no_current_agent) rather than be guessed at.
2119
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();
2120
2539
  function resolveCurrentAgent(connState, explicitAgent) {
2121
- if (explicitAgent)
2122
- return explicitAgent;
2123
- if (connState?.currentAgent)
2124
- return connState.currentAgent;
2125
- if (connState?.clearedAgent)
2126
- return null;
2127
- if (onlineAgents.size === 1)
2128
- return [...onlineAgents][0];
2129
- return null;
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
+ });
2130
2592
  }
2131
2593
  // Agent lifecycle (agent-handlers.ts): create, remove, start, stop, select, list.
2132
2594
  registerAgentHandlers({
@@ -2216,6 +2678,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
2216
2678
  agentName,
2217
2679
  persistence: getPersistence(agentName),
2218
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),
2219
2683
  keyProvider: loaded.keyProvider,
2220
2684
  getNode: entry.getNode,
2221
2685
  getDirectoryEndpoint: getFailoverEndpoint,
@@ -2260,6 +2724,28 @@ async function startDaemonHoldingLock(config, singletonLock) {
2260
2724
  subject_kind: r.subjectKind,
2261
2725
  subject: r.subject,
2262
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,
2263
2749
  status: r.status,
2264
2750
  issued_at: r.issuedAt,
2265
2751
  expires_at: r.expiresAt,
@@ -3049,32 +3535,78 @@ async function startDaemonHoldingLock(config, singletonLock) {
3049
3535
  });
3050
3536
  // ─── MCP-001: cello_status (per-connection perspective) ───
3051
3537
  /**
3052
- * 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`.
3053
3544
  *
3054
- * Reports the LAST resolve sweep. An empty list is not proof of health it also means no sweep
3055
- * has run yet so the shape says which nodes failed and why rather than asserting "all good".
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.
3056
3547
  */
3057
3548
  function unresolvedNodesForStatus() {
3058
3549
  const failures = getUnresolvedNodes();
3059
- 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")
3060
3572
  return undefined;
3061
3573
  return {
3062
3574
  directory_endpoints_unresolved: {
3063
- // WHEN this was measured. Without it the block asserts the PRESENT, and a transient blip
3064
- // reads as an ongoing outage: on 2026-08-09 all three endpoints failed with ENETUNREACH for
3065
- // under a minute a network transition on the operator's machine and the block went on
3066
- // reporting them unreachable long after they answered again. True when taken, false when read.
3067
- checked_at: getUnresolvedSweptAt(),
3068
- nodes: failures.map((f) => ({ node: f.nodeId, endpoint: f.endpoint, reason: f.reason, detail: f.detail })),
3069
- guidance: "AS OF checked_at (this is a point-in-time reading, not necessarily now), this daemon could not "
3070
- + "resolve these directory endpoints, so the consortium roster was short and " +
3071
- "threshold ceremonies will failsessions surface that as counterparty_offline, " +
3072
- "directory_below_threshold, or ceremony_exhausted, none of which name the real cause. " +
3073
- "Agents can still show 'online': signaling dials multiaddrs and does not need DNS. " +
3074
- "If reason is dns_error after a directory restart or wake, the resolver is holding a cached " +
3075
- "negative answer flush it (macOS: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder). " +
3076
- "Verify with node -e 'require(\"dns\").lookup(host,console.log)', NOT dig: dig bypasses the " +
3077
- "cache this daemon is stuck behind, so it reports success while the daemon still fails.",
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.",
3078
3610
  },
3079
3611
  };
3080
3612
  }
@@ -3093,6 +3625,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
3093
3625
  //
3094
3626
  // Omitted entirely when nothing is failing, so a healthy status stays quiet.
3095
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
+ }),
3096
3643
  agents: getAgentsForConnection(connectionId),
3097
3644
  // M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
3098
3645
  // the daemon-wide getStatus() surfaces them.
@@ -3173,6 +3720,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
3173
3720
  // cello_close_session (close-session-handler.ts). Fifteen dependencies — a long list, but a KNOWN
3174
3721
  // one, which is the whole difference from a closure over 73 shared locals.
3175
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
+ },
3176
3731
  // M12-P14: the pre-seal readiness gate drains the parked mailbox before judging, so a close
3177
3732
  // does not refuse over content the relay is still holding for us.
3178
3733
  recoverParkedContent: (agentName, trigger) => autoRecoverForAgent(agentName, trigger),
@@ -3210,9 +3765,74 @@ async function startDaemonHoldingLock(config, singletonLock) {
3210
3765
  handleActiveSealFlow,
3211
3766
  resolveConsortiumRoster,
3212
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
+ });
3213
3833
  // ─── MCP-001: stubs for tools registered in cello-mcp.ts but not yet implemented ───
3214
3834
  // These return not_implemented (same as session tools) so LLMs get consistent guidance.
3215
- for (const tool of ["cello_backup", "cello_restore", "cello_get_inclusion_proof"]) {
3835
+ for (const tool of ["cello_get_inclusion_proof"]) {
3216
3836
  handlers.set(tool, async (_params, _connectionId) => {
3217
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.` };
3218
3838
  });
@@ -3234,6 +3854,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
3234
3854
  logger,
3235
3855
  sessionNodeManager,
3236
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),
3237
3865
  getConnState: (connectionId) => perConnectionState.get(connectionId),
3238
3866
  resolveCurrentAgent,
3239
3867
  NO_CURRENT_AGENT_RESPONSE,
@@ -3308,7 +3936,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
3308
3936
  return { error: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass agentName." };
3309
3937
  }
3310
3938
  const agentId = sessionNodeManager.resolveAgentId(agentName);
3311
- 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);
3312
3944
  return { queued: true, awaitingDepth: retryQueue.getAwaitingDepth(agentId, sessionId) };
3313
3945
  });
3314
3946
  // CELLO-M7-MSG-001: a `persisted` delivery ACK (or a confirmed park) clears the durable
@@ -3395,7 +4027,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
3395
4027
  const sessionId = params?.sessionId;
3396
4028
  if (!agentName || !sessionId)
3397
4029
  return { error: "missing_params", guidance: "Provide agentName and sessionId." };
3398
- 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);
3399
4047
  return { ok: true };
3400
4048
  });
3401
4049
  handlers.set("__test_delivery_open_begin", async (params, _connectionId) => {
@@ -3572,12 +4220,27 @@ async function startDaemonHoldingLock(config, singletonLock) {
3572
4220
  if (!handler)
3573
4221
  return undefined;
3574
4222
  return async (params, connectionId) => {
3575
- 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;
3576
4239
  // Default to "cli": a connection that never sent ipc.connect has no recorded surface, and
3577
4240
  // the CLI verb is the safe answer — it is at least a real command an operator can run,
3578
4241
  // whereas an MCP tool name is useless in a terminal.
3579
4242
  const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
3580
- return renderForSurface(result, surface);
4243
+ return renderForSurface(annotated, surface);
3581
4244
  };
3582
4245
  },
3583
4246
  };
@@ -3828,7 +4491,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
3828
4491
  logger.error("document.delivery.seal_failed", { agent, sessionId, reason: "close_handler_missing", correlationId });
3829
4492
  return;
3830
4493
  }
3831
- 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}`));
3832
4510
  if (sealed?.ok !== true) {
3833
4511
  // `cello_close_session` has distinct failure codes — session_already_sealed,
3834
4512
  // seal_interrupted_*, signaling_reconnecting — and every one of them landed nowhere.
@@ -3841,7 +4519,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
3841
4519
  // transport asked for 0x04, the composition root threw it away, and every document leaf
3842
4520
  // still reached the relay as a MESSAGE. Verified on live traffic: daemon 0.0.145 shipped
3843
4521
  // the fix everywhere except here and the wire was unchanged.
3844
- sendContent: (agent, sessionId, content, contentHash, correlationId, leafKind) => sessionNodeManager.sendContent(agent, sessionId, content, contentHash, correlationId, leafKind),
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),
3845
4530
  // The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
3846
4531
  // successful send. See the comment at the call site for why this is delivery-critical and
3847
4532
  // not audit bookkeeping.
@@ -4012,6 +4697,33 @@ async function startDaemonHoldingLock(config, singletonLock) {
4012
4697
  // MCP-001: Clean up per-connection state when a connection disconnects
4013
4698
  // MCP-002: Also unregister from notification dispatcher
4014
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
+ };
4015
4727
  perConnectionState.delete(connectionId);
4016
4728
  connectionCursors.delete(connectionId); // M8C-CURSOR-1: cursor is connection-scoped, dies with it
4017
4729
  // ...and so is the delivery bookmark (review F1). It is a SEPARATE map from the gate's cursor
@@ -4044,6 +4756,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
4044
4756
  else
4045
4757
  inboundSessionWaiters.delete(agentName);
4046
4758
  }
4759
+ return disconnectContext;
4047
4760
  });
4048
4761
  // Log daemon.login.validation.complete (stub — all unverified until SIGNAL-001)
4049
4762
  logger.info("daemon.login.validation.complete", {
@@ -4094,6 +4807,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
4094
4807
  if (manifestPollScheduler) {
4095
4808
  manifestPollScheduler.cancel();
4096
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();
4097
4815
  stopRegistryPoll?.();
4098
4816
  if (config.registryPollScheduler) {
4099
4817
  config.registryPollScheduler.cancel();
@@ -4103,6 +4821,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
4103
4821
  // outbound work is not draining. Above the `daemon.stopped` log with the other cancels, because
4104
4822
  // this is "stop making new work", not "tear down transports".
4105
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
+ }
4106
4850
  logger.info("daemon.stopped", { pid: process.pid, reason });
4107
4851
  // DOD-LOGOUT-EXIT-1: what the teardown actually DID, carried to onStopped so the binary can
4108
4852
  // exit non-zero on a dirty stop. Without it a shutdown that threw halfway — sessions never