@cello-protocol/daemon 0.0.196 → 0.0.197

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 (39) hide show
  1. package/dist/held-content.d.ts +7 -3
  2. package/dist/held-content.d.ts.map +1 -1
  3. package/dist/held-content.js.map +1 -1
  4. package/dist/session-content-context.d.ts +144 -0
  5. package/dist/session-content-context.d.ts.map +1 -0
  6. package/dist/session-content-context.js +2 -0
  7. package/dist/session-content-context.js.map +1 -0
  8. package/dist/session-content-ingest.d.ts +208 -0
  9. package/dist/session-content-ingest.d.ts.map +1 -0
  10. package/dist/session-content-ingest.js +2216 -0
  11. package/dist/session-content-ingest.js.map +1 -0
  12. package/dist/session-content-send.d.ts +184 -0
  13. package/dist/session-content-send.d.ts.map +1 -0
  14. package/dist/session-content-send.js +1300 -0
  15. package/dist/session-content-send.js.map +1 -0
  16. package/dist/session-lifecycle.d.ts +303 -0
  17. package/dist/session-lifecycle.d.ts.map +1 -0
  18. package/dist/session-lifecycle.js +1643 -0
  19. package/dist/session-lifecycle.js.map +1 -0
  20. package/dist/session-node-manager.d.ts +67 -766
  21. package/dist/session-node-manager.d.ts.map +1 -1
  22. package/dist/session-node-manager.js +966 -7940
  23. package/dist/session-node-manager.js.map +1 -1
  24. package/dist/session-node-types.d.ts +22 -0
  25. package/dist/session-node-types.d.ts.map +1 -1
  26. package/dist/session-node-types.js.map +1 -1
  27. package/dist/session-relay.d.ts +361 -0
  28. package/dist/session-relay.d.ts.map +1 -0
  29. package/dist/session-relay.js +1471 -0
  30. package/dist/session-relay.js.map +1 -0
  31. package/dist/session-salts.d.ts +13 -0
  32. package/dist/session-salts.d.ts.map +1 -1
  33. package/dist/session-salts.js +13 -0
  34. package/dist/session-salts.js.map +1 -1
  35. package/dist/session-seal.d.ts +336 -0
  36. package/dist/session-seal.d.ts.map +1 -0
  37. package/dist/session-seal.js +948 -0
  38. package/dist/session-seal.js.map +1 -0
  39. package/package.json +5 -5
@@ -0,0 +1,1643 @@
1
+ /**
2
+ * CELLO Daemon — A SESSION'S LIFE, FROM OPENED TO GONE
3
+ *
4
+ * Split out of `session-node-manager.ts`, and the last of the four paths to leave it. Everything
5
+ * that changes what a session IS rather than what it carries: opening one as the initiator,
6
+ * accepting one as the responder, connecting to the counterparty, rebuilding a torn-down session on
7
+ * the peer id the other side still holds, moving the row between `active`, `interrupted`, `sealed`
8
+ * and `abandoned`, and tearing the node down when it ends.
9
+ *
10
+ * **Moved verbatim, comments included.**
11
+ *
12
+ * ⚠️ **REVIVAL IS THE HARD PART, FOR A SPECIFIC REASON.** A rebuilt session must come back on the
13
+ * SAME transport peer id the counterparty was handed at establishment, or they can never dial back
14
+ * and the conversation is one-way without saying so. Hence the durable session seed, the rule that
15
+ * a terminal session never revives (its seed is destroyed with it), and the requirement that a
16
+ * revival take every step establishment takes — `msg-022-session-rebuild.test.ts` derives those
17
+ * steps from establishment and requires revival to match, because a revived session that behaves
18
+ * differently from a fresh one is the defect.
19
+ *
20
+ * ⚠️ **WHAT DELIBERATELY STAYED ON THE MANAGER.** `gracefulShutdown` is PROCESS teardown and
21
+ * `#evictSessionCaches` clears the eleven containers every collaborator shares; both would have had
22
+ * to mutate manager state through this context, and neither is about one session. The seam: this
23
+ * file owns what happens to A SESSION, the manager owns the process and the shared state. **The
24
+ * freeze path is the one exception** — `#freezeSession` stayed there and the reader that refuses to
25
+ * revive a frozen session is here, so this file holds the consumer and not the producer.
26
+ */
27
+ import { randomUUID, randomBytes } from "node:crypto";
28
+ import * as lp from "it-length-prefixed";
29
+ import { encodeCbor } from "@cello-protocol/protocol-types";
30
+ import { extractErrorMessage } from "./error-message.js";
31
+ import { MAX_SESSION_NODES } from "./types.js";
32
+ import { SessionConnectionGater } from "./session-connection-gater.js";
33
+ import { NodeAutoNatService } from "@cello-protocol/transport";
34
+ import { SHUTDOWN_STEP_DEADLINE_MS, } from "./session-node-types.js";
35
+ export class SessionLifecycle {
36
+ #ctx;
37
+ constructor(ctx) {
38
+ this.#ctx = ctx;
39
+ }
40
+ /** A getter so the moved queries still read `this.#db` and narrow exactly as they did. */
41
+ get #db() {
42
+ return this.#ctx.db();
43
+ }
44
+ /**
45
+ * The libp2p Peer ID of an active session's node (N_A for an initiated session), or
46
+ * null if no active node exists for it. This is the initiator's session peer id that an
47
+ * inbound session_assignment must carry to the counterparty (so the counterparty gates
48
+ * its handed-off receiver to it). Read-only.
49
+ */
50
+ getSessionNodePeerId(agentName, sessionId) {
51
+ return this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId))?.node.getPeerId() ?? null;
52
+ }
53
+ /**
54
+ * Create a new outbound session node.
55
+ * Called during cello_initiate_session.
56
+ *
57
+ * @param sessionId Unique session ID (hex string)
58
+ * @param agentName Name of the initiating agent
59
+ * @param counterpartyPubkey Counterparty's K_local public key (hex)
60
+ * @param counterpartyPeerId Counterparty's session-layer Peer ID (for gater)
61
+ * @param correlationId Correlation ID minted at session initiation
62
+ */
63
+ async createSessionNode(sessionId, agentName, counterpartyPubkey, counterpartyPeerId, correlationId, reuseStandingReceiver = false, relay) {
64
+ // Cap enforcement (AC-006)
65
+ if (this.#ctx.activeNodes.size >= MAX_SESSION_NODES) {
66
+ this.#ctx.logger.warn("session.node.cap.reached", {
67
+ agentName,
68
+ currentCount: this.#ctx.activeNodes.size,
69
+ maxCount: MAX_SESSION_NODES,
70
+ });
71
+ return {
72
+ ok: false,
73
+ reason: "max_sessions_reached",
74
+ guidance: "The daemon has reached its maximum of 32 concurrent session nodes. " +
75
+ "Close an existing session before starting a new one.",
76
+ };
77
+ }
78
+ /**
79
+ * ⚠️ THE "NO ASSIGNMENT" REFUSAL IS NOT HERE, AND THE PLACE IT MOVED TO IS THE POINT.
80
+ *
81
+ * `DOD-M15-SELFCHAIN-1`, ruled 2026-09-06: a session offered with no directory assignment is
82
+ * suspicious and must be refused and surfaced. It was briefly enforced HERE, and that was the
83
+ * wrong door: `createSessionNode` also runs on this agent's OWN outbound path, where the
84
+ * counterparty has no say in whether an assignment exists. A refusal there fires on our own
85
+ * initiations and says nothing about anyone's conduct.
86
+ *
87
+ * A counterparty can only attempt it INBOUND, so that is where it is refused and recorded —
88
+ * see `inbound-sessions.ts`. What remains true here is the correctness backstop: a session with
89
+ * no anchor cannot sign a chained message, so the SEND path refuses (`session_unchainable`)
90
+ * rather than emitting a message whose place could never be proven.
91
+ */
92
+ // The session node N_A: either a FRESH ephemeral node (default), or — for the initiator
93
+ // path (reuseStandingReceiver) — the standing receiver handed off as the session node. The
94
+ // latter makes N_A's peer id equal the SESSION endpoint the initiator ADVERTISED to the
95
+ // directory (its standing receiver), so the counterparty's connection gater (set to that
96
+ // advertised peer id) admits N_A's dial. Mirrors acceptSession, which already hands off the
97
+ // standing receiver on the receiver side. WIRE-001/INV-5: a fully-fresh ephemeral initiator
98
+ // node would require advertising N_A's peer id pre-negotiation (a session-node lifecycle
99
+ // split); the symmetric standing-receiver handoff is the consistent interim model.
100
+ let node;
101
+ let gater;
102
+ let autoNat;
103
+ // DOD-M12B-SESSION-SEED-1: whichever branch below runs, the session ends up owning a seed.
104
+ // Promotion inherits the receiver's; a freshly-built node mints its own.
105
+ let seed;
106
+ if (reuseStandingReceiver) {
107
+ const sr = this.#ctx.standingReceivers.get(agentName);
108
+ if (!sr) {
109
+ // DOD-LOOP-1: this agent has no standing receiver ready — kick off (idempotent) creation
110
+ // so a retry finds it, and report unavailable. Per-agent, so the initiator consuming its
111
+ // OWN agent's receiver never contends with a co-resident responder agent (the loopback case).
112
+ void this.#ctx.receivers.ensureStandingReceiver(agentName, correlationId);
113
+ return {
114
+ ok: false,
115
+ reason: "standing_receiver_unavailable",
116
+ guidance: "The standing receiver node is initializing (completes within 200ms). Retry the session in a moment.",
117
+ };
118
+ }
119
+ ({ node, gater, autoNat, seed } = sr);
120
+ gater.setAllowedPeer(counterpartyPeerId);
121
+ await this.#evictPeersOutsideGate(node, gater, sessionId, counterpartyPeerId, "outbound_promotion");
122
+ // Hand this agent's standing receiver off to this session; a replacement is spun up below.
123
+ this.#ctx.standingReceivers.delete(agentName);
124
+ }
125
+ else {
126
+ gater = new SessionConnectionGater({
127
+ sessionId,
128
+ allowedPeerId: counterpartyPeerId,
129
+ logger: this.#ctx.logger,
130
+ });
131
+ try {
132
+ seed = randomBytes(32);
133
+ node = await this.#ctx.receivers.createAgentNode(agentName, { sessionId, connectionGater: gater, nodeType: "session", transportPrivateKey: seed });
134
+ await node.start();
135
+ }
136
+ catch (err) {
137
+ const errorMessage = err instanceof Error ? err.message : String(err);
138
+ this.#ctx.logger.error("session.node.create.failed", {
139
+ sessionId,
140
+ agentName,
141
+ error: errorMessage,
142
+ correlationId,
143
+ });
144
+ return {
145
+ ok: false,
146
+ reason: "session_node_creation_failed",
147
+ guidance: "Failed to create session transport node. The daemon logged the cause in " +
148
+ "session.node.create.failed. Check that the system has available ports and sufficient memory.",
149
+ };
150
+ }
151
+ // CELLO-M7-TRANSPORT-001: session nodes also need dialability awareness for the
152
+ // dcutr decision path (AC-002). Wrap the node in a NodeAutoNatService and emit
153
+ // its initial result (nodeType: 'session').
154
+ autoNat = new NodeAutoNatService({
155
+ node,
156
+ logger: this.#ctx.logger,
157
+ nodeType: "session",
158
+ probers: this.#ctx.autoNatProbers(),
159
+ });
160
+ autoNat.emitInitialResult();
161
+ }
162
+ const peerId = node.getPeerId();
163
+ const addrs = node.listenAddresses();
164
+ // Persist to SQLite. D4 review F1: #insertSessionRow swallows the write failure (returns
165
+ // false) — ignoring it let a session go fully live with NO sessions row, which after D4a means
166
+ // every inbound message is refused session_orphaned while the session looks healthy to both
167
+ // operators. A rowless session is a dead session by definition — fail ONCE, here, at creation.
168
+ if (!this.#insertSessionRow(sessionId, agentName, counterpartyPubkey, "active")) {
169
+ try {
170
+ await node.stop();
171
+ }
172
+ catch (err) {
173
+ this.#ctx.logger.warn("session.node.stop.failed", {
174
+ sessionId,
175
+ agentName,
176
+ error: err instanceof Error ? err.message : String(err),
177
+ correlationId,
178
+ });
179
+ }
180
+ // The handed-off standing receiver was consumed above — rebuild it (idempotent).
181
+ if (reuseStandingReceiver)
182
+ void this.#ctx.receivers.ensureStandingReceiver(agentName, correlationId);
183
+ return {
184
+ ok: false,
185
+ reason: "session_persist_failed",
186
+ guidance: "The daemon could not persist the session row (see session.row.write.failed in the log). " +
187
+ "The session was not created — a session without a durable row cannot receive content. " +
188
+ "Check the daemon's database (disk space, permissions) and retry.",
189
+ };
190
+ }
191
+ // Log observability event (session.node.created)
192
+ //
193
+ // `counterpartySessionPeerId` IS LOGGED because it is recorded here ONCE and never refreshed,
194
+ // while a standing receiver is rebuilt with a fresh libp2p keypair on a lost relay reservation
195
+ // and every lost reservation. If the peer rebuilds between advertising its endpoint and this
196
+ // handoff, we record an identity that no longer exists — and since `newStream` never dials, it
197
+ // only ever looks for an ALREADY-OPEN connection filed under exactly this string, so every send
198
+ // in this direction parks forever while the reverse direction works fine.
199
+ //
200
+ // Both sides of a local session log this event, so recording the id we will dial makes that
201
+ // mismatch a direct comparison in the log instead of an unfalsifiable hypothesis.
202
+ this.#ctx.logger.info("session.node.created", {
203
+ sessionId,
204
+ agentName,
205
+ sessionPeerId: peerId,
206
+ counterpartySessionPeerId: counterpartyPeerId,
207
+ correlationId,
208
+ });
209
+ // Add to active map (keyed by (agentName, sessionId) — DOD-LOOP-1)
210
+ // 006-CRYPTO: the session's throwaway keypair is minted here, with the node, so "a session is
211
+ // active" and "a session has a key" are the same moment. All THREE activation paths mint.
212
+ this.#ctx.ephemerals.mintSessionEphemeral(agentName, sessionId);
213
+ this.#ctx.activeNodes.set(this.#ctx.sessionKey(agentName, sessionId), {
214
+ node,
215
+ agentName,
216
+ sessionId,
217
+ counterpartyPubkey,
218
+ gater,
219
+ correlationId,
220
+ counterpartySessionPeerId: counterpartyPeerId,
221
+ autoNat,
222
+ });
223
+ this.#rememberSessionSeed(agentName, sessionId, seed, counterpartyPeerId, counterpartyPubkey);
224
+ // DAEMON-004: register the content stream handler so inbound content_frames
225
+ // are cross-checked, appended to the daemon-owned tree, and buffered.
226
+ await this.#ctx.contentIn.registerContentHandler(agentName, sessionId, node, counterpartyPubkey);
227
+ // M7-SESSION-003 AC-004: act on the session node's peer events for direct-path
228
+ // liveness. The session connection IS the authority for a direct session.
229
+ this.#ctx.liveness.wireSessionLiveness(agentName, sessionId, node, counterpartyPubkey, correlationId, counterpartyPeerId);
230
+ // M7 DOD-SPINE-6 / MSG-001-3b: connect this session node to the relay as the
231
+ // Structure-2 witness (non-fatal — direct content still works without it).
232
+ if (relay) {
233
+ await this.#ctx.relay.connectSessionRelay(sessionId, node, agentName, relay, correlationId);
234
+ }
235
+ // If we consumed this agent's standing receiver, spin up a replacement (async — do NOT await).
236
+ if (reuseStandingReceiver) {
237
+ void this.#ctx.receivers.ensureStandingReceiver(agentName, correlationId);
238
+ }
239
+ return { ok: true, peerId, addrs };
240
+ }
241
+ /**
242
+ * Hand the standing receiver to an inbound session.
243
+ * Called during cello_await_session.
244
+ *
245
+ * CRITICAL (AC-015): gater.setAllowedPeer() is called BEFORE returning
246
+ * the node's multiaddr to the caller. This closes the window where an
247
+ * unexpected peer could connect during the hand-off.
248
+ */
249
+ async acceptSession(sessionId, agentName, counterpartyPubkey, initiatorPeerId, correlationId, relay) {
250
+ // DOD-M15-OFFER-SIGNED-1 review N5: the offer record has done its job the moment this session is
251
+ // claimed. Keying it by session (the F1 fix) removed the accidental bound that agent-keying gave
252
+ // it — each new offer used to overwrite the last — so without a clear on the SUCCESS path the
253
+ // map gained one permanent entry per offer ever received, on directory-supplied keys. Cleared
254
+ // here rather than only on refusal, which is what the doc comment always claimed.
255
+ this.#ctx.receivers.clearOfferedDialer(agentName, sessionId);
256
+ const inboundSr = this.#ctx.standingReceivers.get(agentName);
257
+ if (!inboundSr) {
258
+ // DOD-LOOP-1: per-agent — kick off (idempotent) creation so a retry finds it.
259
+ void this.#ctx.receivers.ensureStandingReceiver(agentName, correlationId);
260
+ return {
261
+ ok: false,
262
+ reason: "standing_receiver_unavailable",
263
+ guidance: "The standing receiver node is initializing (completes within 200ms). " +
264
+ "Retry cello_await_session in a moment.",
265
+ };
266
+ }
267
+ // Cap enforcement — inbound sessions count against the same limit (AC-006)
268
+ if (this.#ctx.activeNodes.size >= MAX_SESSION_NODES) {
269
+ this.#ctx.logger.warn("session.node.cap.reached", {
270
+ agentName,
271
+ currentCount: this.#ctx.activeNodes.size,
272
+ maxCount: MAX_SESSION_NODES,
273
+ });
274
+ return {
275
+ ok: false,
276
+ reason: "max_sessions_reached",
277
+ guidance: "The daemon has reached its maximum of 32 concurrent session nodes. " +
278
+ "Close an existing session before starting a new one.",
279
+ };
280
+ }
281
+ const { node, gater, autoNat, seed } = inboundSr;
282
+ // AC-015: update gater BEFORE retrieving multiaddr / returning to caller
283
+ gater.setAllowedPeer(initiatorPeerId);
284
+ await this.#evictPeersOutsideGate(node, gater, sessionId, initiatorPeerId, "inbound_promotion");
285
+ const peerId = node.getPeerId();
286
+ const addrs = node.listenAddresses();
287
+ // Persist to SQLite. D4 review F1 (same as createSessionNode): a swallowed row-write failure
288
+ // must fail the accept ONCE here — after D4a a rowless session refuses every ingest. The
289
+ // standing receiver (this node) is consumed and rebuilt rather than left with its gater
290
+ // pointed at this initiator.
291
+ if (!this.#insertSessionRow(sessionId, agentName, counterpartyPubkey, "active")) {
292
+ this.#ctx.standingReceivers.delete(agentName);
293
+ // DOD-M12B-SESSION-SEED-1 (review F8): this abort happens BEFORE `#rememberSessionSeed`, so
294
+ // the identity is not being handed to a session — it is being discarded, and is zeroed like
295
+ // any other discard. (The two PROMOTION sites deliberately do not zero: there the same bytes
296
+ // become the session's.)
297
+ inboundSr.seed.fill(0);
298
+ try {
299
+ await node.stop();
300
+ }
301
+ catch (err) {
302
+ this.#ctx.logger.warn("session.node.stop.failed", {
303
+ sessionId,
304
+ agentName,
305
+ error: err instanceof Error ? err.message : String(err),
306
+ correlationId,
307
+ });
308
+ }
309
+ void this.#ctx.receivers.ensureStandingReceiver(agentName, correlationId);
310
+ return {
311
+ ok: false,
312
+ reason: "session_persist_failed",
313
+ guidance: "The daemon could not persist the session row (see session.row.write.failed in the log). " +
314
+ "The inbound session was not accepted — a session without a durable row cannot receive content. " +
315
+ "Check the daemon's database (disk space, permissions).",
316
+ };
317
+ }
318
+ // Log observability event. `counterpartySessionPeerId` for the same reason as the initiator
319
+ // side: this is the identity every later send will look for an open connection under, it is
320
+ // never refreshed, and the peer's standing receiver may already have been rebuilt under a new
321
+ // one. The RESPONDER is the side that can go stale — only the initiator dials, so this is the
322
+ // half that inherits an id it never verified.
323
+ this.#ctx.logger.info("session.node.created", {
324
+ sessionId,
325
+ agentName,
326
+ sessionPeerId: peerId,
327
+ counterpartySessionPeerId: initiatorPeerId,
328
+ correlationId,
329
+ });
330
+ // Remove this agent's standing receiver from the slot and add to active map. The handed-off
331
+ // node keeps its AutoNAT service (it continues to surface dialability).
332
+ this.#ctx.standingReceivers.delete(agentName);
333
+ // 006-CRYPTO: the hand-off path. A session promoted out of the standing receiver is as new as
334
+ // one opened outbound, so it mints here too.
335
+ this.#ctx.ephemerals.mintSessionEphemeral(agentName, sessionId);
336
+ this.#ctx.activeNodes.set(this.#ctx.sessionKey(agentName, sessionId), {
337
+ node,
338
+ agentName,
339
+ sessionId,
340
+ counterpartyPubkey,
341
+ gater,
342
+ correlationId,
343
+ counterpartySessionPeerId: initiatorPeerId,
344
+ autoNat,
345
+ });
346
+ this.#rememberSessionSeed(agentName, sessionId, seed, initiatorPeerId, counterpartyPubkey);
347
+ // DAEMON-004: register the content stream handler for the inbound session.
348
+ await this.#ctx.contentIn.registerContentHandler(agentName, sessionId, node, counterpartyPubkey);
349
+ // M7-SESSION-003 AC-004: act on the inbound session node's peer events too.
350
+ /**
351
+ * DOD-M12B-RESPONDER-ADDR-1 — LEARN THE INITIATOR'S ADDRESS, because we will need it and this is
352
+ * the only moment we have it.
353
+ *
354
+ * MEASURED LIVE 2026-08-18. After an interruption the responder's re-dial reported
355
+ * `session.transport.redial.unavailable` — *"this side holds no address for the counterparty, so
356
+ * every send parks until they re-establish"* — and every reply it tried to send failed. The
357
+ * initiator can always come back because it kept the addresses it dialled; the responder dialled
358
+ * nothing, so it kept nothing.
359
+ *
360
+ * In plain terms that meant: whoever ANSWERED a conversation could not restart it. Their replies
361
+ * went nowhere until the other side spoke first.
362
+ *
363
+ * The live connection has known the address all along — the responder is holding it right now,
364
+ * because the initiator just dialled in on it. `#counterpartyAddrs` is the same store the
365
+ * initiator fills from its signed relay assignment, and `#evictSessionCaches` hands both to the
366
+ * revival record on the way down, so this needs no separate lifetime.
367
+ */
368
+ const inboundAddrs = node
369
+ .getConnections()
370
+ .filter((c) => c.peerId === initiatorPeerId && typeof c.remoteAddr === "string")
371
+ .map((c) => c.remoteAddr);
372
+ if (inboundAddrs.length > 0) {
373
+ this.#ctx.counterpartyAddrs.set(this.#ctx.sessionKey(agentName, sessionId), [...new Set(inboundAddrs)]);
374
+ this.#ctx.logger.info("session.counterparty.addr.learned", {
375
+ agentName,
376
+ sessionId,
377
+ addrs: inboundAddrs.length,
378
+ source: "inbound_connection",
379
+ impact: "this side can now re-dial after an interruption instead of parking every reply",
380
+ });
381
+ }
382
+ else {
383
+ // NOT A WARNING. Review MEDIUM-4: accept runs off a signaling frame and the initiator dials
384
+ // separately, so "no connection yet" is the ordinary in-flight case — warning on it puts a
385
+ // signal on the normal path, which is how the one occurrence that matters gets buried. The
386
+ // race-free capture is in `#wireSessionLiveness`'s onPeerConnect, which fires when the dial
387
+ // actually lands; this read is only a fast path for when it already has.
388
+ this.#ctx.logger.debug("session.counterparty.addr.deferred", {
389
+ agentName,
390
+ sessionId,
391
+ initiatorPeerId,
392
+ impact: "no connection observed yet; the address is captured when the counterparty connects",
393
+ });
394
+ }
395
+ this.#ctx.liveness.wireSessionLiveness(agentName, sessionId, node, counterpartyPubkey, correlationId, initiatorPeerId);
396
+ // M7 DOD-SPINE-6 / MSG-001-3b: the receiver also connects to the relay witness so
397
+ // the relay can deliver the initiator's witnessed leaves (leaf_deliver) to it.
398
+ if (relay) {
399
+ await this.#ctx.relay.connectSessionRelay(sessionId, node, agentName, relay, correlationId);
400
+ }
401
+ // Immediately spin up a replacement for THIS agent (async — do NOT await, AC-003)
402
+ void this.#ctx.receivers.ensureStandingReceiver(agentName, correlationId);
403
+ return { ok: true, peerId, addrs };
404
+ }
405
+ /**
406
+ * Destroy a session node after seal or on error teardown.
407
+ * Status written to SQLite.
408
+ */
409
+ async destroySessionNode(agentName, sessionId, reason) {
410
+ // F1-b: record the terminal answer BEFORE the caches are evicted (and before the
411
+ // early-return below), so a blocking cello_receive that was waiting when the seal fired
412
+ // returns "session_sealed" (with how many buffered messages it never read) instead of
413
+ // hanging to timeout or 404ing. Set even if the node was already retired — a late receive
414
+ // on a sealed session should always learn it is sealed. The receiver (the party that races
415
+ // the seal on cello_receive) is torn down through THIS path; the closer goes through
416
+ // retireSessionNode and is not blocking on receive.
417
+ if (reason === "sealed") {
418
+ const tkey = this.#ctx.sessionKey(agentName, sessionId);
419
+ // DOD-COATTEND-1: counted from the DURABLE read watermark, not the buffer's length.
420
+ // Delivery no longer drains that buffer (it reads the transcript against a per-connection
421
+ // bookmark), so its length is now "everything that ever arrived", not "what nobody read" —
422
+ // reporting it would tell the operator every message of a healthy conversation went unread.
423
+ const unreadCount = this.#ctx.records.getUnreadReceivedCount(agentName, sessionId);
424
+ this.#ctx.sessionTerminal.set(tkey, { type: "sealed", unreadCount });
425
+ }
426
+ const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
427
+ if (!entry)
428
+ return;
429
+ entry.autoNat.stop();
430
+ // M7 DOD-SPINE-6 / MSG-001-3b: close the relay witness stream so we don't leak it.
431
+ this.#ctx.relay.detachSessionRelay(entry);
432
+ try {
433
+ await entry.node.stop();
434
+ }
435
+ catch (err) {
436
+ this.#ctx.logger.error("session.node.stop.failed", {
437
+ sessionId,
438
+ agentName: entry.agentName,
439
+ error: err instanceof Error ? err.message : String(err),
440
+ correlationId: entry.correlationId,
441
+ });
442
+ // Fall through — still remove from active map and update DB
443
+ }
444
+ // Update SQLite — 'sealed' → 'sealed', 'interrupted'/'error' → 'interrupted'.
445
+ // 'error' is not a valid SessionStatus in SQLite; error-torn-down sessions
446
+ // surface as interrupted so AC-010 recovery handles them at next login.
447
+ // The session.node.destroyed log preserves the original reason for observability.
448
+ const dbStatus = reason === "sealed" ? "sealed" : "interrupted";
449
+ // DOD-CAP-SELF-HEAL-1: OURS. Every caller of this with a non-sealed reason is a local teardown
450
+ // — the operator's kill switch (`cello_set_agent_offline`), an internal error, a node replaced.
451
+ // The counterparty did nothing, so they must not be charged a cap slot for it.
452
+ this.updateSessionStatus(agentName, sessionId, dbStatus, dbStatus === "interrupted" ? "local" : undefined);
453
+ this.#ctx.activeNodes.delete(this.#ctx.sessionKey(agentName, sessionId));
454
+ // Evict the in-memory per-session caches on teardown. The tree is durable in
455
+ // SQLite (getSessionTree reloads it on demand), and the received-content buffer
456
+ // holds plaintext that must not linger after a session ends. Without this, both
457
+ // maps grow unbounded by total sessions seen over a long-lived daemon.
458
+ // (#evictSessionCaches also drops the M7-SESSION-003 liveness flag, so both the
459
+ // destroy and retire teardown paths clear it — no stale verdict survives.)
460
+ this.#ctx.evictSessionCaches(agentName, sessionId);
461
+ this.#ctx.logger.info("session.node.destroyed", {
462
+ sessionId,
463
+ agentName: entry.agentName,
464
+ reason,
465
+ });
466
+ // M8B F14 (fix 1): the torn-down node has just released its port — on a fixed-port
467
+ // deployment this is the FIRST moment a previously-failed re-arm can succeed. Re-arm
468
+ // the standing receiver for an online agent that has none (async, never awaited).
469
+ this.#rearmAfterTeardown(agentName);
470
+ }
471
+ /**
472
+ * M8B F14: re-arm an online agent's standing receiver after a session-node teardown
473
+ * freed resources (notably the fixed port). No-op when the agent is offline, already
474
+ * has a receiver, or one is being created. The re-arm is a NEW async flow — it mints
475
+ * its own correlationId (via the ensure default) rather than inheriting the torn-down
476
+ * session's.
477
+ */
478
+ #rearmAfterTeardown(agentName) {
479
+ if (this.#ctx.shuttingDown)
480
+ return;
481
+ if (!this.#ctx.agentsWantingReceiver.has(agentName))
482
+ return;
483
+ if (this.#ctx.standingReceivers.has(agentName) || this.#ctx.standingReceiverCreating.has(agentName))
484
+ return;
485
+ void this.#ctx.receivers.ensureStandingReceiver(agentName);
486
+ }
487
+ /**
488
+ * round-2 finding #5: retire a session's live libp2p node WITHOUT changing its
489
+ * DB status. Used after the active-session bilateral seal commitment has already
490
+ * advanced the row to 'seal_interrupted_pending': the session is frozen, so we
491
+ * stop the node and unregister its /cello/content handler (no more inbound leaves,
492
+ * no leaked node per active close) but must NOT overwrite the pending/sealed status
493
+ * the way destroySessionNode would. The durable tree stays in SQLite (getSessionTree
494
+ * reloads it); the in-memory plaintext buffer is evicted.
495
+ */
496
+ async retireSessionNode(agentName, sessionId) {
497
+ const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
498
+ if (!entry)
499
+ return;
500
+ this.#ctx.relay.detachSessionRelay(entry);
501
+ try {
502
+ await entry.node.stop();
503
+ }
504
+ catch (err) {
505
+ this.#ctx.logger.error("session.node.stop.failed", {
506
+ sessionId,
507
+ agentName: entry.agentName,
508
+ error: err instanceof Error ? err.message : String(err),
509
+ correlationId: entry.correlationId,
510
+ });
511
+ // Fall through — still remove from active map.
512
+ }
513
+ this.#ctx.activeNodes.delete(this.#ctx.sessionKey(agentName, sessionId));
514
+ this.#ctx.evictSessionCaches(agentName, sessionId);
515
+ this.#ctx.logger.info("session.node.destroyed", {
516
+ sessionId,
517
+ agentName: entry.agentName,
518
+ reason: "sealing",
519
+ });
520
+ // M8B F14 (fix 1): same re-arm point as destroySessionNode — the retired node freed its port.
521
+ this.#rearmAfterTeardown(agentName);
522
+ }
523
+ /**
524
+ * Graceful shutdown: mark all active sessions as interrupted, stop all nodes.
525
+ * Called from the SIGTERM / cello logout path (AC-009).
526
+ * SQLite writes complete before this method returns.
527
+ */
528
+ /**
529
+ * DOD-M12B-SHUTDOWN-1 — wait for a teardown step, but never forever.
530
+ *
531
+ * Every step of shutdown used to be an unbounded `await` on libp2p. That is what makes "the
532
+ * daemon acknowledged the request but is still running" possible: nothing on the daemon side
533
+ * emits a word while it hangs, so the operator's own message ("it may be stuck closing sessions
534
+ * or its database") was a guess. Past the deadline the step is ABANDONED and SAID — the resources
535
+ * it was closing are reclaimed by the OS on exit, and an exit is worth more than a tidy one.
536
+ */
537
+ async boundedTeardown(work, step, count) {
538
+ if (count === 0)
539
+ return;
540
+ const started = Date.now();
541
+ let timer;
542
+ const deadline = new Promise((resolve) => {
543
+ timer = setTimeout(() => resolve("timeout"), SHUTDOWN_STEP_DEADLINE_MS);
544
+ timer.unref?.();
545
+ });
546
+ const outcome = await Promise.race([work.then(() => "done"), deadline]);
547
+ if (timer)
548
+ clearTimeout(timer);
549
+ if (outcome === "timeout") {
550
+ this.#ctx.logger.error("session.shutdown.step.timeout", {
551
+ step, count, waitedMs: Date.now() - started,
552
+ impact: "this teardown step did not finish and was abandoned so the daemon can exit; the OS reclaims what it held",
553
+ });
554
+ }
555
+ else {
556
+ this.#ctx.logger.debug("session.shutdown.step.done", { step, count, tookMs: Date.now() - started });
557
+ }
558
+ }
559
+ /**
560
+ * M7-SESSION-001: Mark a session as interrupted with message count and timestamp.
561
+ * Called when a relay session_interrupted frame arrives or a relay stream closes.
562
+ * Also tears down the in-memory session node if one exists for this sessionId.
563
+ *
564
+ * @param sessionId The hex session ID from the relay frame
565
+ * @param messageCount Number of message leaves at interruption
566
+ * @param source 'relay_frame' | 'stream_close'
567
+ */
568
+ async markInterruptedWithDetails(agentName, sessionId, messageCount,
569
+ /**
570
+ * WHAT ACTUALLY HAPPENED, and it is written to the row — review F3.
571
+ *
572
+ * `key_refused` is its own source rather than a borrowed `stream_close`, because the row's
573
+ * `interrupted_by` is what an operator reads days later: labelling a key-authentication refusal
574
+ * `relay_stream_close` sends them to the relay fleet for a fault in the payload.
575
+ */
576
+ source) {
577
+ if (!this.#db)
578
+ return false;
579
+ // H-3 SECURITY: only an 'active' session may transition to 'interrupted'.
580
+ // A late or forged relay frame must NOT revert a 'sealed', 'seal_interrupted_pending',
581
+ // or already-'interrupted' session back to 'interrupted'. This mirrors the
582
+ // stream-close guard in `#watchRelayStream` (`session-relay.ts`) — the two paths must agree.
583
+ // Not "below" any more: that invariant is now a claim about two FILES, so it is named.
584
+ const existing = this.#ctx.queries.getSessionRecord(agentName, sessionId);
585
+ if (!existing || existing.status !== "active") {
586
+ this.#ctx.logger.warn("session.interrupt.ignored", {
587
+ sessionId,
588
+ source,
589
+ currentStatus: existing?.status ?? "absent",
590
+ reason: "session_not_active",
591
+ });
592
+ // FALSE, not void — the caller needs to know nothing was torn down (review F11).
593
+ return false;
594
+ }
595
+ const now = Date.now();
596
+ const interruptedAt = new Date(now).toISOString();
597
+ // round-2 finding #7: the daemon-owned tree is the authoritative transcript
598
+ // length. The `messageCount` arg comes from registerRelayStream time and defaults
599
+ // to 0, so writing it blindly would clobber the column out of sync with the tree
600
+ // (both seal flows prefer tree.size(), but the column must not lie). When a tree
601
+ // exists for this session, persist its size; otherwise fall back to the arg.
602
+ const treeSize = this.#ctx.getSessionTree(agentName, sessionId).size();
603
+ const authoritativeCount = treeSize > 0 ? treeSize : messageCount;
604
+ try {
605
+ // The `AND status = 'active'` predicate is the authoritative guard: even if
606
+ // the pre-check above raced (it cannot — DatabaseSync is synchronous), the
607
+ // UPDATE only mutates a row that is still active.
608
+ this.#db
609
+ .prepare(
610
+ // DOD-CAP-SELF-HEAL-1: labelled by SOURCE, because the two are not the same event.
611
+ //
612
+ // relay_frame — the relay telling us the counterparty went. THEIRS. The D18
613
+ // disconnect-evasion move, and it must keep counting.
614
+ // stream_close — OUR witness stream to the relay ended. That fires on a relay restart,
615
+ // a relay fleet roll, or a local network blip. Claiming the counterparty
616
+ // did it means three relay deploys permanently refuse a peer who was
617
+ // never involved — and relay deploys are routine, so it ratchets faster
618
+ // than daemon restarts do.
619
+ //
620
+ // `relay_stream_close` is its own label and STILL COUNTS (the bound excuses only 'local'),
621
+ // because an attacker who can disturb our relay link must not get a free cap reset. It is
622
+ // recorded honestly rather than blamed on the wrong party.
623
+ `UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ?, interrupted_by = '${source === "relay_frame" ? "counterparty" : source === "key_refused" ? "key_refused" : "relay_stream_close"}' WHERE agent_id = ? AND session_id = ? AND status = 'active'`)
624
+ .run(now, authoritativeCount, interruptedAt, this.#ctx.requireAgentId(agentName), sessionId);
625
+ }
626
+ catch (err) {
627
+ this.#ctx.logger.error("session.interrupt.db.write.failed", {
628
+ sessionId,
629
+ error: err instanceof Error ? err.message : String(err),
630
+ });
631
+ }
632
+ // Look up the in-memory entry (keyed by (agent, session)) for teardown.
633
+ const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
634
+ // Tear down the in-memory session node if it exists
635
+ if (entry) {
636
+ entry.autoNat.stop();
637
+ this.#ctx.relay.detachSessionRelay(entry);
638
+ try {
639
+ await entry.node.stop();
640
+ }
641
+ catch (err) {
642
+ this.#ctx.logger.error("session.node.stop.failed", {
643
+ sessionId,
644
+ agentName,
645
+ error: err instanceof Error ? err.message : String(err),
646
+ correlationId: entry.correlationId,
647
+ });
648
+ // Fall through — still remove from active map
649
+ }
650
+ this.#ctx.activeNodes.delete(this.#ctx.sessionKey(agentName, sessionId));
651
+ /**
652
+ * THE SECRET GOES WITH THE ENTRY — 006-CRYPTO, review pass 2 finding 2.
653
+ *
654
+ * This is the path an interrupted session actually takes, and it is the ORDINARY way a
655
+ * session ends badly: a relay blip, a closed stream, a sleeping laptop. Because it does not
656
+ * evict (see below) the secret used to survive here, and when the session later sealed
657
+ * `destroySessionNode` returned at its `if (!entry) return` without evicting either — so the
658
+ * receipt landed, the session was over, and the key stayed resident until the process exited.
659
+ *
660
+ * The reasons below for KEEPING the other caches do not transfer to key material: buffered
661
+ * plaintext must stay drainable and TTF timers must stay armed, whereas a secret nothing
662
+ * reads must not stay alive. A revived session mints a fresh one and re-keys, which is
663
+ * Decisions Carried #5 and is only true because of this line.
664
+ */
665
+ this.#ctx.ephemerals.destroySessionEphemeralFor(agentName, sessionId, entry.correlationId);
666
+ this.#ctx.logger.info("session.node.destroyed", {
667
+ sessionId,
668
+ agentName,
669
+ reason: "interrupted",
670
+ });
671
+ // DELIBERATELY NOT #evictSessionCaches here (unlike destroySessionNode/retireSessionNode):
672
+ // an interrupted session is not terminal. (1) #receivedContent must stay drainable — the
673
+ // record survives, and cello_receive legitimately reads buffered unread messages after a
674
+ // transient relay blip; evicting would silently discard deliverable plaintext. (2) Evict
675
+ // also cancels armed TTF timers (`clearAwaitingForSession`, in `session-content-send.ts`) — on
676
+ // a dying session the TTF
677
+ // park backstop is exactly what must fire for un-acked content (MSG-001). The caches are
678
+ // reclaimed when the session later seals (destroy/retire paths) or at daemon restart.
679
+ // M8B F14 (fix 1): the relay-detected interruption is the THIRD teardown path that
680
+ // frees the fixed port — it must re-arm too, or a session ending on a network blip
681
+ // leaves the agent deaf again (review finding on the F14 fix).
682
+ this.#rearmAfterTeardown(agentName);
683
+ }
684
+ this.#ctx.logger.warn("session.interrupted.detected", {
685
+ sessionId,
686
+ agentName,
687
+ source,
688
+ });
689
+ // M7-SESSION-001 (M-1 PUSH): notify live MCP clients that this session is now
690
+ // interrupted. Only fires on a real active→interrupted transition (the guard
691
+ // above already returned for any non-active session).
692
+ try {
693
+ this.#ctx.onSessionStateChanged?.(agentName, sessionId, "interrupted", existing.counterparty_pubkey);
694
+ }
695
+ catch (err) {
696
+ this.#ctx.logger.debug("session.state.notify.failed", {
697
+ sessionId,
698
+ error: err instanceof Error ? err.message : String(err),
699
+ });
700
+ }
701
+ return true;
702
+ }
703
+ /**
704
+ * SEAM 1b (dialer ⇄ session-node reconciliation): dial the counterparty THROUGH
705
+ * this session's OWN node, so the session node N_A holds the connection its content
706
+ * newStream actually rides. TRANSPORT-001's transport selector dialed on a separate
707
+ * (composition-root) node whose connection N_A could not use — the per-session node
708
+ * must be the dialer. Direct mode only here (the default content path, Part 4 D-a);
709
+ * relay-circuit + dcutr strategy via N_A is a later seam. Tries each addr in turn;
710
+ * succeeds on the first connection, returns a named failure if none connect.
711
+ */
712
+ async connectToCounterparty(agentName, sessionId, addrs) {
713
+ const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
714
+ if (!entry) {
715
+ return { ok: false, reason: "session_node_unavailable", error: "no active session node for this session" };
716
+ }
717
+ if (addrs.length === 0) {
718
+ return { ok: false, reason: "no_counterparty_addrs", error: "the assignment carried no counterparty session addrs to dial" };
719
+ }
720
+ // DOD-NAT-REACHABILITY-1: a /p2p-circuit counterparty address is dialed
721
+ // THROUGH its relay, so the gater must admit that relay peer OUTBOUND. The
722
+ // relay id is embedded in the address, which arrived inside the FROST-signed
723
+ // assignment — the same authorization rail as the assigned witness relay.
724
+ for (const addr of addrs) {
725
+ const viaRelay = addr.match(/\/p2p\/([^/]+)\/p2p-circuit/);
726
+ if (viaRelay)
727
+ entry.gater.setAllowedOutboundPeer(viaRelay[1]);
728
+ }
729
+ // DOD-M15-RELAYAUTH-1 review H1: the RELAY's gater must also admit this dial, and it only does
730
+ // so once it holds the assignment. Await that here — see the method's own comment for why the
731
+ // counterparty presenting it cannot be relied on.
732
+ await this.#ctx.relay.authorizeCircuitDialsToCounterparty(agentName, sessionId, entry, addrs);
733
+ let lastError = "";
734
+ for (const addr of addrs) {
735
+ try {
736
+ await entry.node.dial(addr);
737
+ // DOD-M12B-REDIAL-1: keep them. They arrived in the signed assignment and were used once
738
+ // and dropped, which is the reason nothing could ever dial this counterparty again.
739
+ this.#ctx.counterpartyAddrs.set(this.#ctx.sessionKey(agentName, sessionId), [...addrs]);
740
+ this.#ctx.logger.info("session.transport.connected", {
741
+ sessionId,
742
+ addr,
743
+ correlationId: entry.correlationId,
744
+ });
745
+ return { ok: true };
746
+ }
747
+ catch (err) {
748
+ // extractErrorMessage handles the transport's structured plain-object
749
+ // throws (dial() never throws Error instances) — the old
750
+ // `instanceof Error` idiom logged "[object Object]" on every dial
751
+ // failure; try the next addr.
752
+ lastError = extractErrorMessage(err);
753
+ }
754
+ }
755
+ this.#ctx.logger.warn("session.transport.connect.failed", {
756
+ sessionId,
757
+ reason: "counterparty_dial_failed",
758
+ error: lastError,
759
+ correlationId: entry.correlationId,
760
+ });
761
+ return { ok: false, reason: "counterparty_dial_failed", error: lastError };
762
+ }
763
+ /**
764
+ * DOD-M12B-ABANDON-NOTIFY-1 — tell the counterparty we have hung up. Best effort, never blocking.
765
+ *
766
+ * A force-abandon marks the session terminal HERE and did nothing else, so the other side kept
767
+ * its half live, kept retrying delivery into it, and kept trying to re-establish — forever,
768
+ * because nothing would ever answer. That is what produced the 2026-08-17 notification storm:
769
+ * surviving halves calling continuously while the operator saw connection requests from agents
770
+ * nobody was driving.
771
+ *
772
+ * BEST EFFORT, and every caller must treat it that way. A peer that is offline cannot be told, so
773
+ * this is an improvement on silence rather than a guarantee — and it must never delay or fail the
774
+ * abandon, which is the operator's escape hatch out of a session that can never seal.
775
+ */
776
+ async notifyCounterpartyAbandon(agentName, sessionId, correlationId) {
777
+ const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
778
+ if (!entry) {
779
+ // NAMES ITS CAUSE, and it is not the network. An `interrupted` session has no node — the
780
+ // restart sweep and markInterrupted both tear it down — and `interrupted` is exactly the
781
+ // status force-abandon exists for. Reporting this as "could not be reached" sends the
782
+ // operator to debug a connection when the answer is in our own process. At INFO, not debug,
783
+ // because it is the common case and it changes what the operator is told.
784
+ this.#ctx.logger.info("session.abandon.notice.skipped", {
785
+ agentName, sessionId, reason: "no_local_node", correlationId,
786
+ impact: "this side had already torn the session down, so there was nothing to send on — the counterparty was not told",
787
+ });
788
+ return { told: false, reason: "no_local_node" };
789
+ }
790
+ let stream;
791
+ try {
792
+ // Through the RE-DIAL path, not a bare newStream. A session worth force-abandoning is very
793
+ // often one whose connection blipped — the peer is online and calling us, which is the whole
794
+ // complaint — so one demand-driven dial is the difference between telling them and not.
795
+ stream = await this.#ctx.contentOut.openContentStream(agentName, sessionId, entry, correlationId);
796
+ // Typed against protocol-types so the shape cannot drift from the declaration the receiving
797
+ // side (and any second client implementation) reads.
798
+ const notice = {
799
+ type: "session_abandoned_notice",
800
+ session_id: sessionId,
801
+ ...(correlationId === undefined ? {} : { correlation_id: correlationId }),
802
+ };
803
+ const frame = encodeCbor(notice);
804
+ stream.send(lp.encode.single(frame));
805
+ await stream.close();
806
+ this.#ctx.logger.info("session.abandon.notice.sent", { agentName, sessionId, correlationId });
807
+ return { told: true, reason: "sent" };
808
+ }
809
+ catch (err) {
810
+ if (stream !== undefined) {
811
+ try {
812
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
813
+ }
814
+ catch { /* already gone */ }
815
+ }
816
+ this.#ctx.logger.warn("session.abandon.notice.failed", {
817
+ agentName, sessionId, correlationId,
818
+ error: err instanceof Error ? err.message : String(err),
819
+ impact: "the counterparty was not told and may keep calling until it gives up",
820
+ });
821
+ return { told: false, reason: "send_failed" };
822
+ }
823
+ }
824
+ /**
825
+ * DOD-M12B-ABANDON-NOTIFY-1 — the receiving half: our counterparty has abandoned, so retire.
826
+ *
827
+ * RETIRING IS NOT DELETING. The counterparty walking away forfeits the notarized receipt; it must
828
+ * not also cost the operator the record of what was actually said. The transcript and the tree
829
+ * stay exactly as they are.
830
+ *
831
+ * Only an `active` or `interrupted` session moves. A SEALED session has a notarized receipt and
832
+ * must never be turned into an abandoned one by a late or duplicated notice — that would destroy
833
+ * the artifact this protocol exists to produce. An unknown session is refused rather than
834
+ * created: an authenticated stream proves who is speaking, not that a session exists.
835
+ */
836
+ async retireOnCounterpartyAbandon(agentName, sessionId, correlationId) {
837
+ const record = this.#ctx.queries.getSessionRecord(agentName, sessionId);
838
+ if (!record) {
839
+ this.#ctx.logger.warn("session.abandon.notice.unknown_session", { agentName, sessionId, correlationId });
840
+ return false;
841
+ }
842
+ if (record.status !== "active" && record.status !== "interrupted") {
843
+ this.#ctx.logger.debug("session.abandon.notice.ignored", {
844
+ agentName, sessionId, status: record.status, correlationId,
845
+ reason: "session already terminal",
846
+ });
847
+ return false;
848
+ }
849
+ // THE TRANSPORT IS RETIRED. THE SESSION IS NOT.
850
+ //
851
+ // The first build flipped the status to `abandoned`, and that was wrong twice over. It handed
852
+ // the abandoning party a button that DENIES US OUR RECEIPT: the unilateral seal exists for
853
+ // exactly this case — "the counterparty never co-closes" — and produces a notarized certificate
854
+ // after a grace period, but `cello_close_session` refuses an `abandoned` session outright. So
855
+ // one frame from them destroyed a recovery path that already existed, remotely and for free.
856
+ // Today the abandoner can only go silent, and going silent is what the unilateral seal was
857
+ // built to survive.
858
+ //
859
+ // What the DoD actually asks for is that we stop calling them. That is a transport concern:
860
+ // mark it, stop re-dialling, stop retrying delivery — and leave the session sealable.
861
+ const marked = this.#ctx.queries.markCounterpartyAbandoned(agentName, sessionId);
862
+ if (!marked)
863
+ return false;
864
+ // The addresses go, so the demand-driven re-dial has nothing to dial. This is the storm.
865
+ const key = this.#ctx.sessionKey(agentName, sessionId);
866
+ this.#ctx.counterpartyAddrs.delete(key);
867
+ this.#ctx.logger.warn("session.counterparty.abandoned", {
868
+ agentName, sessionId, priorStatus: record.status, correlationId,
869
+ impact: "the counterparty ended this session on their side, so nothing more will arrive and replies cannot reach them — this side stops calling. The session is NOT terminal: a unilateral seal is still available, and the transcript is intact",
870
+ });
871
+ // AWAITED, and `retireSessionNode` NOT `destroySessionNode`. The latter writes the status back
872
+ // — `error` maps to `interrupted` — a few hundred milliseconds later, which silently undid the
873
+ // whole unit; the former is the method that tears a node down without touching the status, and
874
+ // it is what the local force-abandon path already uses.
875
+ await this.retireSessionNode(agentName, sessionId);
876
+ return true;
877
+ }
878
+ /**
879
+ * DOD-M15-FRAME-1 — NARROWING THE GATE DOES NOT EVICT ANYONE ALREADY INSIDE. This does.
880
+ *
881
+ * libp2p consults the gater only when a connection is ESTABLISHED, so narrowing it never evicts a
882
+ * peer already attached. That is why this sweep exists and why it cannot be replaced by the gate.
883
+ *
884
+ * A peer that attached early can therefore hold its connection open, still be attached when the
885
+ * receiver is promoted, and be sitting there when the content protocol activates. DOD-M15-ASSIGN-1
886
+ * shrank who can get that foothold — an unclaimed standing receiver now admits nobody inbound,
887
+ * where it used to admit everyone — but it did not, and could not, change the constraint above.
888
+ *
889
+ * That is the foothold the whole injection path depends on — placed before the door narrows. The
890
+ * frame-level gate above refuses what they send; this closes the connection they send it on, so
891
+ * the stranger is not merely ineffective but gone, and is not sitting there for the next protocol
892
+ * to activate.
893
+ *
894
+ * BEST-EFFORT BY CONSTRUCTION, and it must stay that way. A failure to hang up one peer must not
895
+ * fail the session setup that is mid-flight — the frame gate is the load-bearing control and it
896
+ * does not depend on this succeeding. Relay peers are exempt: they are on the OUTBOUND allowlist
897
+ * because reservation refreshes ride them, and hanging one up would cost the agent its inbound
898
+ * reachability to remove a peer that cannot speak the content protocol anyway.
899
+ */
900
+ async #evictPeersOutsideGate(node, gater, sessionId, allowedPeerId, trigger) {
901
+ let connections;
902
+ try {
903
+ connections = node.getConnections();
904
+ }
905
+ catch (err) {
906
+ this.#ctx.logger.debug("session.gate.evict.unavailable", {
907
+ sessionId, trigger, error: extractErrorMessage(err),
908
+ });
909
+ return;
910
+ }
911
+ const toEvict = connections
912
+ .map((c) => c.peerId)
913
+ .filter((peerId) => peerId !== allowedPeerId && !gater.isAllowedOutboundPeer(peerId));
914
+ /**
915
+ * CONCURRENT AND CAPPED, because the count is ATTACKER-CONTROLLED (review F4).
916
+ *
917
+ * This runs inside `acceptSession`, before the session row is written, and the standing receiver
918
+ * used to accept everyone (closed by DOD-M15-ASSIGN-1) — so opening N connections to an agent's advertised receiver used
919
+ * to make every later session setup on that agent wait for N sequential graceful closes.
920
+ * `hangUp` is libp2p's graceful close and takes no timeout, so the wait was unbounded in both
921
+ * directions. Evicting an injection foothold must not itself become the way to stall an agent.
922
+ *
923
+ * The cap is a LOGGED truncation, never a silent one: what is left behind still cannot inject
924
+ * (the frame gate refuses it and now hangs it up on first contact), and the next promotion
925
+ * sweeps again — but an operator reading this needs to know the sweep did not finish.
926
+ */
927
+ const EVICT_CAP = 32;
928
+ const batch = toEvict.slice(0, EVICT_CAP);
929
+ if (toEvict.length > batch.length) {
930
+ this.#ctx.logger.warn("session.gate.evict.capped", {
931
+ sessionId, trigger, attached: toEvict.length, evicting: batch.length,
932
+ impact: "more peers were attached outside the gate than one promotion evicts; the rest keep their connections until a later sweep, and are refused and hung up by the frame gate if they speak",
933
+ });
934
+ }
935
+ await Promise.allSettled(batch.map(async (peerId) => {
936
+ try {
937
+ await node.hangUp(peerId);
938
+ this.#ctx.logger.warn("session.gate.evicted", {
939
+ sessionId, trigger, evictedPeerId: peerId, allowedPeerId,
940
+ impact: "a peer attached to this node before the session narrowed its gate was disconnected; libp2p does not re-run the gater against live connections, so it would otherwise have stayed attached when the content protocol activated",
941
+ });
942
+ }
943
+ catch (err) {
944
+ // Best-effort: the frame gate still refuses anything this peer sends, and now hangs it up.
945
+ this.#ctx.logger.debug("session.gate.evict.failed", {
946
+ sessionId, trigger, peerId, error: extractErrorMessage(err),
947
+ });
948
+ }
949
+ }));
950
+ }
951
+ /**
952
+ * DOD-LOOP-1: public hook for the composition root to create an agent's standing receiver when
953
+ * the agent comes online (cello_start_agent), and to tear it down when it goes offline.
954
+ * M8B F14: also called from the inbound accept path (ensure on demand). Marks the agent as
955
+ * WANTING a receiver, which arms the teardown re-arm in destroySessionNode/retireSessionNode.
956
+ */
957
+ /**
958
+ * DOD-M12B-SESSION-SEED-1 test seam: the seed the agent's current standing receiver holds.
959
+ *
960
+ * The property under test — "the receiver built behind a promoted one never reuses its seed" — is
961
+ * about an identity that by design never leaves the process, so there is no observable surface for
962
+ * it short of a live two-node dial. Reading it here is the narrowest way to pin it.
963
+ */
964
+ /** DOD-M12B-SESSION-SEED-1: record the identity this session must be able to return at. */
965
+ #rememberSessionSeed(agentName, sessionId, seed, counterpartyPeerId, counterpartyPubkey) {
966
+ const key = this.#ctx.sessionKey(agentName, sessionId);
967
+ // Defensive: unreachable today because `insertSessionRow` PK-conflicts on a repeat, but an
968
+ // overwrite that dropped a live seed un-zeroed would leave the one copy we are responsible for
969
+ // in the heap with nothing tracking it.
970
+ this.#ctx.sessionSeeds.get(key)?.seed.fill(0);
971
+ // `counterpartyAddrs` starts empty: at creation the signed assignment has not necessarily
972
+ // arrived yet. It is filled by `#evictSessionCaches` on the way down, which is the last moment
973
+ // the live addresses exist.
974
+ this.#ctx.sessionSeeds.set(key, { seed, counterpartyPeerId, counterpartyPubkey, counterpartyAddrs: [] });
975
+ }
976
+ /**
977
+ * DOD-M12B-SESSION-SEED-1 — destroy a session's transport identity.
978
+ *
979
+ * Called from `#updateSessionStatus` on a terminal status, in the SAME step that writes it, so
980
+ * there is no window in which a session is closed on paper and still revivable in memory.
981
+ *
982
+ * **WHAT THE ZERO-FILL DOES AND DOES NOT DO** — checked against the derivation, not assumed.
983
+ * `createNode` hands the buffer to `generateKeyPairFromSeed`, and `@libp2p/crypto` COPIES it
984
+ * (`uint8arrayConcat([seed, publicKeyRaw])`, then `Uint8Array.from`). Two consequences:
985
+ * - zeroing after the node has started is SAFE — the running node holds its own copy;
986
+ * - it does NOT erase the key from the heap. An identical usable copy is the first 32 bytes of
987
+ * `privateKey.raw` on the node object until that node is dropped.
988
+ * So this removes OUR long-lived copy — the one that would otherwise sit in a map for the life of
989
+ * the process, decoupled from any node — and that is worth doing. It is not a heap scrub, and
990
+ * the DoD already says the bound rather than secrecy is the control.
991
+ */
992
+ destroySessionSeed(agentName, sessionId) {
993
+ const key = this.#ctx.sessionKey(agentName, sessionId);
994
+ const identity = this.#ctx.sessionSeeds.get(key);
995
+ if (identity === undefined)
996
+ return;
997
+ identity.seed.fill(0);
998
+ this.#ctx.sessionSeeds.delete(key);
999
+ this.#ctx.logger.debug("session.seed.destroyed", { agentName, sessionId });
1000
+ }
1001
+ /**
1002
+ * DOD-M12B-SESSION-SEED-1 — bring an interrupted session back on the peer id it already has.
1003
+ *
1004
+ * THE DEFECT THIS CLOSES. `markInterruptedWithDetails` and `destroySessionNode` stop the node and
1005
+ * delete it from `#activeNodes`, and until now **nothing anywhere recreated one**. A laptop-close
1006
+ * session stayed stuck even though both processes were alive and both keypairs were still in
1007
+ * memory — the trace on 2026-08-17 found no missing transport capability, just a missing edge.
1008
+ *
1009
+ * TWO THINGS HAVE TO HAPPEN, and doing only one leaves the session exactly as stuck:
1010
+ * 1. the NODE comes back, at the same peer id, or the counterparty can never dial us again;
1011
+ * 2. the STATUS comes back to `active`, or every send still refuses with `session_not_active`.
1012
+ *
1013
+ * **DEMAND-DRIVEN ONLY.** Nothing calls this on a timer. That is the `REDIAL-1` discipline and it
1014
+ * is also Andre's tenet — a background rebuilder would hold a dialable endpoint open for a session
1015
+ * nobody is using, which is the "open connection a malicious agent can farm for" in as many words.
1016
+ *
1017
+ * **TERMINAL IS TERMINAL.** A sealed or abandoned session had its seed zeroed in the same step
1018
+ * that wrote its status, so there is nothing to come back on. This refuses by name rather than
1019
+ * minting a fresh identity — a revival that quietly mints would hand one session a second peer id
1020
+ * and break the invariant while appearing to work.
1021
+ *
1022
+ * Idempotent: a session that already has a live node returns ok without building a second one.
1023
+ */
1024
+ async reviveSessionNode(agentName, sessionId) {
1025
+ const key = this.#ctx.sessionKey(agentName, sessionId);
1026
+ const live = this.#ctx.activeNodes.get(key);
1027
+ if (live)
1028
+ return { ok: true, peerId: live.node.getPeerId() };
1029
+ // PARITY with `acceptSession` — and the parity guard in msg-022 is what caught its absence.
1030
+ // A revived session's offer record has almost always been cleared already (it was cleared when
1031
+ // the session was first accepted), so this is usually a no-op. It is here because "usually a
1032
+ // no-op" is not a reason for establishment and revival to do different things: every divergence
1033
+ // between those two paths in this file has been a defect, and the guard exists because one of
1034
+ // them shipped past a green suite for two days.
1035
+ this.#ctx.receivers.clearOfferedDialer(agentName, sessionId);
1036
+ const record = this.#ctx.queries.getSessionRecord(agentName, sessionId);
1037
+ if (!record)
1038
+ return { ok: false, reason: "session_not_found" };
1039
+ /**
1040
+ * A REVIVED SESSION GETS ITS SEAL CHANCES BACK — `DOD-M15-SEAL-FAILED-TERMINAL-1` review
1041
+ * MEDIUM-6, and without this a receipt can be lost permanently and silently.
1042
+ *
1043
+ * `restart_seal_gave_up_at` is written when the restart resolver exhausts its attempts, and
1044
+ * NOTHING ever cleared it. Its stated purpose is narrow — *"a machine restarting ~6 times a day
1045
+ * must not re-run five ceremonies against a hopeless session on every boot"* — and a session
1046
+ * being revived is the opposite of hopeless: something is talking to it again.
1047
+ *
1048
+ * The path it closes: resolver gives up → the column is stamped → the session is REVIVED and
1049
+ * carries live traffic → it is closed → the background ceremony dies → the in-memory failure
1050
+ * marker is lost at the next restart → `listRestartOrphanedSessions` excludes the row forever on
1051
+ * this column → and `listExpiredUnrevivableSessions` explicitly INCLUDES
1052
+ * `restart_seal_gave_up_at IS NOT NULL`, so the revival sweep force-abandons it. Receipt gone,
1053
+ * with no surface having ever said so.
1054
+ *
1055
+ * Bounded, because revival is not a boot-loop: it takes a live counterparty or an operator read.
1056
+ */
1057
+ // One statement, gated in SQL rather than on a field: `SessionRecord` does not carry this column
1058
+ // and widening the type to read it once would spread it through every consumer. `changes` tells
1059
+ // us whether it actually cleared, so the log stays a signal instead of firing on every revival.
1060
+ const clearedGaveUp = this.#db
1061
+ ?.prepare("UPDATE sessions SET restart_seal_gave_up_at = NULL, restart_seal_gave_up_reason = NULL " +
1062
+ "WHERE agent_id = ? AND session_id = ? AND restart_seal_gave_up_at IS NOT NULL")
1063
+ .run(this.#ctx.resolveAgentId(agentName), sessionId);
1064
+ if ((clearedGaveUp?.changes ?? 0) > 0) {
1065
+ this.#ctx.logger.info("session.restart_seal.gave_up.cleared", {
1066
+ agentName, sessionId,
1067
+ impact: "this session is eligible for restart-seal recovery again — it is being revived, so it is not hopeless.",
1068
+ });
1069
+ }
1070
+ if (record.status === "sealed" || record.status === "abandoned" || record.status === "seal_interrupted_pending") {
1071
+ return {
1072
+ ok: false,
1073
+ reason: "session_terminal",
1074
+ guidance: `Session is '${record.status}'. A session that has ended cannot be revived; start a new one.`,
1075
+ };
1076
+ }
1077
+ /**
1078
+ * DOD-M15-FRAME-1 (review F1) — A DEFENSIVE FREEZE MUST NOT UNDO ITSELF ON THE NEXT READ.
1079
+ *
1080
+ * `#freezeOnIdentityFailure` tears the node down, and a teardown writes status `interrupted`.
1081
+ * `interrupted` is not terminal — it is the *revivable* status — so `reviveIfNeededForRead`
1082
+ * fired on the operator's very next `cello_receive`, rebuilt a node behind a gater allowing the
1083
+ * SAME counterparty peer, flipped the row back to `active`, and logged it as a success.
1084
+ *
1085
+ * The freeze therefore lasted until the next keystroke, while the log line said *"no further
1086
+ * content will be accepted on this session"*. A security decision that silently reverses itself,
1087
+ * with a message asserting the opposite, is a worse defect than the one the freeze was added to
1088
+ * fix — and it is the class this milestone exists to remove, reintroduced by its own fix.
1089
+ *
1090
+ * Checked BEFORE the cap and after the terminal statuses, so the answer names the freeze rather
1091
+ * than whatever else the session would have been refused for.
1092
+ *
1093
+ * In memory, and so lost on a daemon restart — the same bound as `DOD-M15-DIVERGE-DURABLE-1`
1094
+ * and for the same reason. The durable column is `DOD-M15-FREEZE-STATUS-1`; the reversibility
1095
+ * could not wait for it.
1096
+ */
1097
+ const frozen = this.#ctx.frozenSessions.get(key);
1098
+ if (frozen) {
1099
+ // The REASON and the GUIDANCE both come from the site that froze it. Hardcoding them here was
1100
+ // correct while an identity failure was the only way in, and became a false accusation the
1101
+ // moment a second one existed — see the note on `#frozenSessions` in `session-node-manager.ts`.
1102
+ return {
1103
+ ok: false,
1104
+ reason: frozen.reason,
1105
+ guidance: `${frozen.guidance} It is not revived automatically, and reading or sending will not clear it. ` +
1106
+ `Your transcript up to the freeze is intact: cello_transcript ${sessionId} reads it. ` +
1107
+ `To end the session and keep what it earned, close it — cello_close_session ${sessionId}. To talk to them again, start a fresh session rather than reviving this one.`,
1108
+ };
1109
+ }
1110
+ /**
1111
+ * THE CAP APPLIES TO A REVIVAL TOO (review: parity gap). Establishment refuses at
1112
+ * `MAX_SESSION_NODES` because each node is a real libp2p instance with listeners, connections
1113
+ * and a relay reservation. A revival builds exactly the same thing, so letting it past the cap
1114
+ * would let a daemon walk over the limit one reconnect at a time — and the limit exists to stop
1115
+ * a machine being taken down by its own session count.
1116
+ *
1117
+ * Refused by name, so the caller can say something true: this is a local resource limit, not a
1118
+ * problem with the session or the counterparty.
1119
+ */
1120
+ if (this.#ctx.activeNodes.size >= MAX_SESSION_NODES) {
1121
+ this.#ctx.logger.warn("session.revive.cap.reached", {
1122
+ agentName,
1123
+ sessionId,
1124
+ activeCount: this.#ctx.activeNodes.size,
1125
+ maxCount: MAX_SESSION_NODES,
1126
+ impact: "this session stays interrupted until another session ends and frees a node slot",
1127
+ });
1128
+ return {
1129
+ ok: false,
1130
+ reason: "session_node_cap_reached",
1131
+ guidance: `This daemon already holds ${MAX_SESSION_NODES} active session nodes, so this session ` +
1132
+ "cannot be brought back yet. Close a session you have finished with and try again.",
1133
+ };
1134
+ }
1135
+ const identity = this.#ctx.sessionSeeds.get(key);
1136
+ if (identity === undefined) {
1137
+ // The honest case: the daemon restarted, so the keypair is genuinely gone. That is
1138
+ // RESTART-SEAL-1's territory (resolve with a receipt), not a revival — and saying so is the
1139
+ // difference between an operator waiting for a reconnect that cannot happen and one closing
1140
+ // the session.
1141
+ return {
1142
+ ok: false,
1143
+ reason: "session_identity_lost",
1144
+ guidance: "This session's transport identity did not survive a daemon restart, so it cannot be " +
1145
+ "revived. It will be sealed automatically, or you can close it now to get its receipt.",
1146
+ };
1147
+ }
1148
+ const gater = new SessionConnectionGater({
1149
+ sessionId,
1150
+ allowedPeerId: identity.counterpartyPeerId,
1151
+ logger: this.#ctx.logger,
1152
+ });
1153
+ // The relay peers must be allowed OUTBOUND before the node starts, or the reservation the line
1154
+ // below depends on is refused by our own gater — the same ordering the receiver builder uses.
1155
+ const reservations = this.#ctx.relay.reservationCircuitAddrs(agentName);
1156
+ for (const relayPeerId of reservations.relayPeerIds)
1157
+ gater.setAllowedOutboundPeer(relayPeerId);
1158
+ let node;
1159
+ const t0 = Date.now();
1160
+ this.#ctx.logger.info("session.revive.node.building", {
1161
+ agentName,
1162
+ sessionId,
1163
+ circuitAddrs: reservations.addrs.length,
1164
+ relayPeerIds: reservations.relayPeerIds.length,
1165
+ });
1166
+ try {
1167
+ node = await this.#ctx.receivers.buildRevivedNode(sessionId, gater, identity.seed, reservations.addrs, agentName);
1168
+ this.#ctx.logger.info("session.revive.node.started", {
1169
+ agentName,
1170
+ sessionId,
1171
+ startMs: Date.now() - t0,
1172
+ listenAddrs: node.listenAddresses().length,
1173
+ circuitListen: node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length,
1174
+ });
1175
+ }
1176
+ catch (err) {
1177
+ this.#ctx.logger.error("session.revive.node.failed", {
1178
+ agentName,
1179
+ sessionId,
1180
+ error: err instanceof Error ? err.message : String(err),
1181
+ impact: "the session stays interrupted; the next send will attempt this again",
1182
+ });
1183
+ return { ok: false, reason: "session_node_creation_failed" };
1184
+ }
1185
+ const autoNat = new NodeAutoNatService({
1186
+ node,
1187
+ logger: this.#ctx.logger,
1188
+ nodeType: "session",
1189
+ probers: this.#ctx.autoNatProbers(),
1190
+ });
1191
+ autoNat.emitInitialResult();
1192
+ // DOD-M12B-SESSION-SEED-1: give the re-dial its addresses back BEFORE the session goes active,
1193
+ // so the first send after a revival has somewhere to go. Without this the send fails instantly
1194
+ // on a connection that was never made, and — measured live — is lost rather than parked.
1195
+ if (identity.counterpartyAddrs.length > 0) {
1196
+ this.#ctx.counterpartyAddrs.set(key, [...identity.counterpartyAddrs]);
1197
+ }
1198
+ const correlationId = randomUUID();
1199
+ /**
1200
+ * DOD-M12B-REVIVE-PARK-1 — RESTORE THE RELAY, or a revived session cannot park and every failed
1201
+ * send is declared lost.
1202
+ *
1203
+ * This is the defect behind five identical live failures on 2026-08-18. `#parkContent` opens
1204
+ * with `if (!hook || !entry || !entry.relayPeerId || !entry.relayAddrs) return "unconfigured"`,
1205
+ * and a revived entry carried none of it — so the park was skipped and the send fell through to
1206
+ * *"could NOT be queued for retry — it is lost. Send it again."* The relay was recorded on the
1207
+ * session row the whole time, and store-and-forward would have delivered the message: the
1208
+ * counterparty's own sends park through it successfully in the same minute.
1209
+ *
1210
+ * What it cost the operator: their reply was accepted, discarded, and they were told to retype
1211
+ * it — which is how a transcript gets duplicates of a message that was never lost in the first
1212
+ * place.
1213
+ *
1214
+ * Read from the row rather than carried in the revival record on purpose: the row is where the
1215
+ * relay assignment is durable, and it is the same source `getPersistedRelayEndpoint` already
1216
+ * serves the startup flush from — a path that exists precisely because in-memory entries are
1217
+ * gone by then, which is exactly the situation a revival is in.
1218
+ */
1219
+ // ONE lookup, and ONE event for the absent case (review LOW-8). This used to read the endpoint
1220
+ // here and again inside the relay reconnect, and both logged `session.revive.relay.absent` with
1221
+ // different `impact` text — one event name standing for two meanings, fired twice for a single
1222
+ // condition. `reconnectRevivedSessionRelay` (`session-relay.ts`) takes it as a parameter now.
1223
+ const persistedRelay = this.#ctx.queries.getPersistedRelayEndpoint(agentName, sessionId);
1224
+ // 006-CRYPTO: a REVIVED session mints a FRESH keypair and re-keys — Decisions Carried #5. That
1225
+ // holds because the interrupt path destroys the old secret when it drops the entry; until it
1226
+ // did, this call found the stale key still in the map and quietly kept it. The salt, which IS
1227
+ // persisted, is re-read from the row instead — opposite lifetimes, deliberately.
1228
+ this.#ctx.ephemerals.mintSessionEphemeral(agentName, sessionId);
1229
+ /**
1230
+ * AND ANNOUNCE IT — review F1, second half. Minting a fresh key achieves nothing on its own: the
1231
+ * COUNTERPARTY has to hear about it, and it is the side that did NOT restart, so it is not
1232
+ * tearing anything down or reconnecting. `#sendEphemeralFrame` otherwise rides `onPeerConnect`,
1233
+ * which does not fire again for a connection that never dropped — the ordinary shape when only
1234
+ * one end's witness stream closed, which is what a relay roll produces.
1235
+ *
1236
+ * Without this the two ends sit on different keys for the life of the session, every message
1237
+ * fails GCM, and the receiving operator is told the content may have been MODIFIED IN FLIGHT for
1238
+ * what is a local key skew. Deferred a tick so the revived node's handlers are registered before
1239
+ * the frame goes out.
1240
+ */
1241
+ setTimeout(() => { void this.#ctx.ephemerals.sendEphemeralFrame(agentName, sessionId, "revive-rekey"); }, 0);
1242
+ this.#ctx.activeNodes.set(key, {
1243
+ node,
1244
+ agentName,
1245
+ sessionId,
1246
+ counterpartyPubkey: identity.counterpartyPubkey,
1247
+ gater,
1248
+ correlationId,
1249
+ counterpartySessionPeerId: identity.counterpartyPeerId,
1250
+ autoNat,
1251
+ ...(persistedRelay
1252
+ ? { relayPeerId: persistedRelay.relayPeerId, relayAddrs: persistedRelay.relayAddrs }
1253
+ : {}),
1254
+ });
1255
+ await this.#ctx.contentIn.registerContentHandler(agentName, sessionId, node, identity.counterpartyPubkey);
1256
+ /**
1257
+ * review HIGH-2 — REWIRE LIVENESS, or this session can never be interrupted again.
1258
+ *
1259
+ * Both creation paths call this; the first build of the revival did not. Without it the revived
1260
+ * session is pinned `active`: a later disconnect fires no transition, no `session_state_changed`
1261
+ * reaches the MCP client, and the receive surface renders unknown liveness as healthy-and-quiet.
1262
+ * So the SECOND laptop close would leave the operator staring at a session that reports fine and
1263
+ * is dead — this milestone's founding defect, one revival later, and with no status change left
1264
+ * to trigger the next revival either.
1265
+ */
1266
+ this.#ctx.liveness.wireSessionLiveness(agentName, sessionId, node, identity.counterpartyPubkey, correlationId, identity.counterpartyPeerId);
1267
+ // DOD-M12B-REVIVE-RELAY-1: the step revival skipped. Establishment connects the relay witness
1268
+ // here; without it the session comes back with no live inbound path at all.
1269
+ await this.#ctx.relay.reconnectRevivedSessionRelay(agentName, sessionId, node, gater, correlationId, persistedRelay);
1270
+ // THE REVERSE EDGE. A transport event took this session out of `active` and nothing has ever
1271
+ // put one back. Written after the node is live and its handler registered, so the row never
1272
+ // claims `active` for a session that cannot yet receive.
1273
+ //
1274
+ // review MEDIUM-3: the result is CHECKED. `#updateSessionStatus` returns false when the write
1275
+ // matched no row or the DB errored — and reporting revival ok on a row that still says
1276
+ // `interrupted` leaves a live, talking session where REVIVAL-BOUND-1's sweep can seal or abandon
1277
+ // it. Failing here means tearing the node back down rather than running in that split state.
1278
+ if (!this.updateSessionStatus(agentName, sessionId, "active")) {
1279
+ /**
1280
+ * DOD-M15-RELAYLEAK-1 (review MEDIUM-4) — **THIS TEARDOWN LEAKED THE EXACT THING THE LINE IS
1281
+ * ABOUT, THROUGH A DIFFERENT DOOR.**
1282
+ *
1283
+ * `reconnectRevivedSessionRelay` (`session-relay.ts`, not "above") has already called `registerSession` on the cached
1284
+ * relay client and hung it on this entry. Deleting the map key and stopping the node released
1285
+ * the daemon's own objects and left that registration standing with **no owner** — and
1286
+ * `detachSessionRelay` (`session-relay.ts`) closes a client only when `!hasSessions()`, so the orphaned
1287
+ * registration held that predicate false for the life of the process. The client, its
1288
+ * authenticated stream and its relay-side reservation were unreachable and immortal.
1289
+ *
1290
+ * The shutdown loop this line added does sweep it at exit, which is precisely why it had to be
1291
+ * fixed here too: a leak that is only cleaned up by process death is still a leak for every
1292
+ * hour the daemon is up.
1293
+ */
1294
+ /**
1295
+ * ⚠️ Review MEDIUM-2 — **THE ENTRY IS MATCHED BY IDENTITY, NOT BY KEY.** `reviveSessionNode`
1296
+ * has no in-flight guard: its `if (live) return` is separated from `#activeNodes.set` by the
1297
+ * whole node build, so two revivals for one key can both reach the `set` and the second
1298
+ * overwrites the first. Looking up by key alone would then hand THIS failing revival the
1299
+ * OTHER one's live entry, and detaching it would unregister a running session's leaf handler
1300
+ * — closing the client that session is using if it was the last one on it. Comparing `node`
1301
+ * costs one token and makes "the entry I created" provable rather than assumed.
1302
+ */
1303
+ const revivedEntry = this.#ctx.activeNodes.get(key);
1304
+ if (revivedEntry?.node === node)
1305
+ this.#ctx.relay.detachSessionRelay(revivedEntry);
1306
+ this.#ctx.activeNodes.delete(key);
1307
+ // 006-CRYPTO: the revival FAILED, so the key it just minted belongs to a session that never
1308
+ // came back. Dropping the entry without this would strand it for the daemon's lifetime.
1309
+ this.#ctx.ephemerals.destroySessionEphemeralFor(agentName, sessionId);
1310
+ try {
1311
+ await node.stop();
1312
+ }
1313
+ catch { /* best-effort: the status write already failed and is logged with its cause */ }
1314
+ return {
1315
+ ok: false,
1316
+ reason: "session_status_write_failed",
1317
+ guidance: "The session node was rebuilt but its status could not be written, so it was torn back " +
1318
+ "down rather than left live under an interrupted row. The daemon logged the cause.",
1319
+ };
1320
+ }
1321
+ // The messages that failed while this session was down were queued on a promise of "retried on
1322
+ // reconnect". This is that reconnect — fire it before anyone is told the session is back.
1323
+ if (this.#ctx.retryDrainHook !== null) {
1324
+ try {
1325
+ this.#ctx.retryDrainHook(agentName, sessionId);
1326
+ }
1327
+ catch (err) {
1328
+ this.#ctx.logger.warn("session.revive.retry_drain.failed", {
1329
+ agentName,
1330
+ sessionId,
1331
+ error: err instanceof Error ? err.message : String(err),
1332
+ impact: "messages queued while this session was down are still queued",
1333
+ });
1334
+ }
1335
+ }
1336
+ const peerId = node.getPeerId();
1337
+ this.#ctx.logger.info("session.revived", {
1338
+ agentName,
1339
+ sessionId,
1340
+ peerId,
1341
+ // The whole claim of this line, in the log: the id did not change, so the counterparty's
1342
+ // stored dial target is still correct and they do not need to be told anything.
1343
+ identityPreserved: true,
1344
+ });
1345
+ return { ok: true, peerId };
1346
+ }
1347
+ /**
1348
+ * DOD-M12B-SESSION-SEED-1 — the DEMAND edge: a send on an interrupted session revives it.
1349
+ *
1350
+ * One of TWO production callers of `reviveSessionNode` — `reviveIfNeededForRead` is the other —
1351
+ * and both are deliberately demand paths rather than timers. The `REDIAL-1` discipline and Andre's tenet say the same thing from two
1352
+ * directions: nothing may re-open on its own, because a background rebuilder would hold a dialable
1353
+ * endpoint open for a session nobody is using — the *"open connection a malicious agent can farm
1354
+ * for"*. The operator sending is the demand; there is no other trigger.
1355
+ *
1356
+ * A no-op for the normal case. An `active` session with a live node returns immediately without
1357
+ * touching it — this sits on the hot path of every send, and replacing a healthy node would be
1358
+ * churn that changes the peer id for no reason.
1359
+ */
1360
+ async reviveIfNeededForSend(agentName, sessionId) {
1361
+ const record = this.#ctx.queries.getSessionRecord(agentName, sessionId);
1362
+ if (!record)
1363
+ return { ok: false, reason: "session_not_found" };
1364
+ // The overwhelmingly common case: nothing to do, and no node was disturbed to find that out.
1365
+ if (record.status === "active" && this.#ctx.activeNodes.has(this.#ctx.sessionKey(agentName, sessionId)))
1366
+ return { ok: true };
1367
+ const revived = await this.reviveSessionNode(agentName, sessionId);
1368
+ if (!revived.ok) {
1369
+ this.#ctx.logger.info("session.revive.declined", {
1370
+ agentName,
1371
+ sessionId,
1372
+ previousStatus: record.status,
1373
+ trigger: "send",
1374
+ reason: revived.reason,
1375
+ });
1376
+ return revived;
1377
+ }
1378
+ this.#ctx.logger.info("session.revived.on_demand", {
1379
+ agentName,
1380
+ sessionId,
1381
+ previousStatus: record.status,
1382
+ trigger: "send",
1383
+ });
1384
+ return { ok: true };
1385
+ }
1386
+ /**
1387
+ * DOD-M12B-SESSION-SEED-1 (case B) — the INBOUND half of the demand edge.
1388
+ *
1389
+ * `reviveIfNeededForSend` covers the operator waking first. Case B's triggers are symmetric — a
1390
+ * wifi hop, a relay restart, a directory node cycling — so half the time the COUNTERPARTY wakes
1391
+ * first. They send; we have no node yet, because revival is demand-driven and we have demanded
1392
+ * nothing. Their content parks at the relay, which is the backstop working as designed.
1393
+ *
1394
+ * Then the operator comes back and READS, and until now that told them nothing: the receive
1395
+ * handler reads the transcript and never gates on status, so it happily reports what is already
1396
+ * stored while messages sit parked, waiting for a node that will not exist until the operator
1397
+ * happens to SEND. An operator who only reads was stuck forever with a surface that looked fine.
1398
+ *
1399
+ * **WHY A READ MAY TRIGGER THIS AND AN INBOUND DIAL MAY NOT.** Andre's tenet is about what a
1400
+ * REMOTE party can cause: *"an open connection that a malicious agent can farm for."* Reviving
1401
+ * because a peer dialled us would hand that lever straight to the peer — a stranger could keep our
1402
+ * endpoints open indefinitely by poking dead sessions. A read is the OPERATOR asking, on their own
1403
+ * machine, for their own session: the same class of demand as a send, and the class the tenet
1404
+ * allows. That distinction is the whole reason this is a separate entry point rather than a
1405
+ * revival triggered from the inbound handler.
1406
+ */
1407
+ async reviveIfNeededForRead(agentName, sessionId) {
1408
+ const record = this.#ctx.queries.getSessionRecord(agentName, sessionId);
1409
+ if (!record)
1410
+ return { ok: false, reason: "session_not_found" };
1411
+ if (record.status === "active" && this.#ctx.activeNodes.has(this.#ctx.sessionKey(agentName, sessionId)))
1412
+ return { ok: true };
1413
+ // Reading the transcript of an ended session is normal and must keep working — the CALLER does
1414
+ // not treat this refusal as an error, it just reads what is stored. What must not happen is the
1415
+ // read bringing the session back: the receipt is issued and the identity is gone.
1416
+ const revived = await this.reviveSessionNode(agentName, sessionId);
1417
+ if (!revived.ok) {
1418
+ // review MEDIUM-4: the absence of a success line was the only signal that a session could not
1419
+ // come back. `session_identity_lost` is the one an operator most needs, and it was generated
1420
+ // and destroyed one stack frame later with nothing written down.
1421
+ this.#ctx.logger.info("session.revive.declined", {
1422
+ agentName,
1423
+ sessionId,
1424
+ previousStatus: record.status,
1425
+ trigger: "read",
1426
+ reason: revived.reason,
1427
+ });
1428
+ return revived;
1429
+ }
1430
+ this.#ctx.logger.info("session.revived.on_demand", {
1431
+ agentName,
1432
+ sessionId,
1433
+ previousStatus: record.status,
1434
+ trigger: "read",
1435
+ });
1436
+ // Fetch what is waiting NOW. Review MEDIUM-5 corrected the claim this used to make: the drain
1437
+ // runs off the AGENT's standing receiver, not the session node, and the 5-minute backstop would
1438
+ // have delivered this content anyway. So this is an accelerator, not a rescue — worth having,
1439
+ // and worth describing accurately. (The send path deliberately does not fire one: the same
1440
+ // backstop covers it, at a cost of at most one interval.)
1441
+ this.#ctx.park.fireParkedDrain(agentName, "session_revived");
1442
+ return { ok: true };
1443
+ }
1444
+ #insertSessionRow(sessionId, agentName, counterpartyPubkey, status) {
1445
+ if (!this.#db)
1446
+ return false;
1447
+ const now = Date.now();
1448
+ try {
1449
+ /**
1450
+ * ⚠️ THE SESSION'S STARTING POINT GOES IN AT INSERT — `DOD-M15-SELFCHAIN-1`.
1451
+ *
1452
+ * It is recorded before this row exists (the session open needs it before the node is built),
1453
+ * so an UPDATE at that moment has nothing to match. Writing it here is what puts it on disk,
1454
+ * and on disk is what lets the chain be resumed after a restart. `null` when nothing recorded
1455
+ * one, which is a session whose sends will be refused by name rather than silently unlinked.
1456
+ */
1457
+ const genesis = this.#ctx.leafRecords.genesisFor(agentName, sessionId);
1458
+ this.#db
1459
+ .prepare(`INSERT INTO sessions
1460
+ (session_id, agent_id, counterparty_pubkey, status, created_at, updated_at, genesis_prev_root)
1461
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
1462
+ .run(sessionId, this.#ctx.requireAgentId(agentName), counterpartyPubkey, status, now, now, genesis ? Buffer.from(genesis) : null);
1463
+ return true;
1464
+ }
1465
+ catch (err) {
1466
+ // D4 review F2: this helper serves the CREATE/ACCEPT paths (and interrupt-restore) — the old
1467
+ // event name `session.interrupt.db.write.failed` steered diagnosis to the interrupt path only.
1468
+ this.#ctx.logger.error("session.row.write.failed", {
1469
+ sessionId,
1470
+ agentName,
1471
+ status,
1472
+ error: err instanceof Error ? err.message : String(err),
1473
+ });
1474
+ return false;
1475
+ }
1476
+ }
1477
+ /** CC-5/F21: unilaterally mark a session locally-terminal ("abandoned") — retire its live node and
1478
+ * set the DB status, with NO bilateral seal (a dead half-open handshake has nothing to notarize).
1479
+ * Used by cello_close_session { force } and the dead-half-open reaper. Idempotent: a missing/already-
1480
+ * abandoned session is a no-op. Resolves true iff the status flip was actually written (CC-10
1481
+ * reviewer LOW: callers must not report a reap as successful when the write failed). */
1482
+ async abandonSession(agentName, sessionId) {
1483
+ // Status flip FIRST and synchronous (before the async node teardown yields), so a non-awaited
1484
+ // reaper call from a read path takes effect for the SAME read (the DB is updated before the await).
1485
+ const flipped = this.updateSessionStatus(agentName, sessionId, "abandoned");
1486
+ await this.retireSessionNode(agentName, sessionId);
1487
+ return flipped;
1488
+ }
1489
+ updateSessionStatus(agentName, sessionId, status,
1490
+ // DOD-CAP-SELF-HEAL-1: who caused an interruption, when this call is the one causing it.
1491
+ // Omitting it leaves the column NULL, which the acceptance bound reads as the counterparty's —
1492
+ // so a LOCAL teardown that forgets to say so is charged to the peer. That is exactly how the
1493
+ // operator's own kill switch (`cello_set_agent_offline` → destroySessionNode) was locking out
1494
+ // a counterparty who had done nothing.
1495
+ interruptedBy) {
1496
+ if (!this.#db)
1497
+ return false;
1498
+ /**
1499
+ * DOD-M12B-SESSION-SEED-1 (review F2) — the identity dies on terminal INTENT, not on a
1500
+ * successful UPDATE.
1501
+ *
1502
+ * The first build destroyed the seed only after the write landed. `#requireAgentId` THROWS for
1503
+ * a retired agent, so every terminal write for a revoked agent's sessions fell into the catch
1504
+ * and kept its transport identity for the life of the process — an identity whose agent has
1505
+ * just been revoked in the directory, held with nothing reporting it, and REVIVAL-BOUND-1's
1506
+ * sweep excludes retired agents so nothing else closed it either. The same held for a
1507
+ * `session.status.write.missed` and for any DB error.
1508
+ *
1509
+ * Coupling a security teardown to a database write is backwards: the write can fail, and the
1510
+ * failure is exactly when we least want a live key lying around. So it runs FIRST and
1511
+ * unconditionally, and if the write then fails the session is one we can no longer revive —
1512
+ * which is the safe direction, and is reported loudly below rather than inferred from the
1513
+ * absence of a debug line.
1514
+ */
1515
+ if (status === "sealed" || status === "abandoned") {
1516
+ this.destroySessionSeed(agentName, sessionId);
1517
+ // DOD-M15-DIVERGE-1: divergence stops being true HERE and only here. It used to be dropped by
1518
+ // `#evictSessionCaches` on every node teardown — including the one that writes `interrupted`,
1519
+ // which is a status the seal gate still acts on, so the fact was forgotten while it was still
1520
+ // load-bearing. A terminal status is the one point at which no future close can be refused,
1521
+ // so the flag has nothing left to protect.
1522
+ this.#ctx.records.clearDivergedMemo(agentName, sessionId);
1523
+ // DURABLE too (DOD-M15-DIVERGE-DURABLE-1) — otherwise a sealed session comes back after a
1524
+ // restart still carrying a refusal for a close that can no longer happen.
1525
+ // (agent_id, session_id) — see markSessionDiverged. Unkeyed, one side sealing cleared the
1526
+ // OTHER side's divergence on a loopback session.
1527
+ this.#db
1528
+ ?.prepare("UPDATE sessions SET diverged_at = NULL WHERE agent_id = ? AND session_id = ?")
1529
+ .run(this.#ctx.requireAgentId(agentName), sessionId);
1530
+ }
1531
+ // THE TERMINAL GUARD LIVES HERE, not in one wrapper, because there are three writers of
1532
+ // "sealed": markSealed, destroySessionNode, and retireSession on the witnessed-submit path.
1533
+ // Guarding only the wrapper asserts the invariant in a test while two other paths still break
1534
+ // it.
1535
+ //
1536
+ // abandoned → sealed REFUSED. A force-abandon is the documented way to give up a receipt.
1537
+ // A certificate arriving afterwards must not silently overturn the operator's decision. The
1538
+ // certificate is still stored by recordSealCertificate and stays retrievable.
1539
+ // sealed → sealed REFUSED. Nothing to write, and re-running the terminal disposition
1540
+ // hooks for a no-op should not be reported as a status that landed.
1541
+ if (status === "sealed") {
1542
+ const current = this.#ctx.queries.getSessionRecord(agentName, sessionId)?.status;
1543
+ if (current === "abandoned" || current === "sealed") {
1544
+ this.#ctx.logger.info("session.seal.status.not_written", {
1545
+ agentName, sessionId, currentStatus: current,
1546
+ impact: current === "abandoned"
1547
+ ? "a certificate arrived for a session the operator force-abandoned; it is stored and retrievable, but the row keeps saying abandoned"
1548
+ : "already sealed — nothing to write",
1549
+ });
1550
+ return false;
1551
+ }
1552
+ if (current === undefined) {
1553
+ // ORDINARY, not an error. recordSealCertificate documents this case: the seal can arrive
1554
+ // before the row is persisted. Falling through would emit `session.status.write.missed` at
1555
+ // ERROR level for a shape the system expects.
1556
+ this.#ctx.logger.info("session.seal.status.no_row", {
1557
+ agentName, sessionId,
1558
+ impact: "no session row yet — the certificate is still recorded and retrievable",
1559
+ });
1560
+ return false;
1561
+ }
1562
+ }
1563
+ const now = Date.now();
1564
+ try {
1565
+ const res = this.#db
1566
+ .prepare(
1567
+ // DOD-M12B-REVIVAL-BOUND-1: this is the FOURTH writer of `status = 'interrupted'`, and
1568
+ // until now the only one that wrote no `interrupted_at`. That is where Entry 41's two
1569
+ // timestamp-less rows came from, and a row with no timestamp has no revival bound that
1570
+ // can be evaluated. `COALESCE` matches the three sibling producers: the FIRST
1571
+ // interruption is the clock, so re-entering the status cannot push the deadline out.
1572
+ status === "interrupted"
1573
+ ? (interruptedBy === undefined
1574
+ ? "UPDATE sessions SET status = ?, updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE agent_id = ? AND session_id = ?"
1575
+ : "UPDATE sessions SET status = ?, updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?), interrupted_by = 'local' WHERE agent_id = ? AND session_id = ?")
1576
+ : (interruptedBy === undefined
1577
+ ? "UPDATE sessions SET status = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?"
1578
+ : "UPDATE sessions SET status = ?, updated_at = ?, interrupted_by = 'local' WHERE agent_id = ? AND session_id = ?"))
1579
+ .run(...(status === "interrupted"
1580
+ ? [status, now, new Date(now).toISOString(), this.#ctx.requireAgentId(agentName), sessionId]
1581
+ : [status, now, this.#ctx.requireAgentId(agentName), sessionId]));
1582
+ // "Did not throw" is NOT "landed". An UPDATE whose WHERE matches no row — a wrong agent_id, a
1583
+ // session_id with no row — succeeds silently and changes nothing. Reporting that as a written
1584
+ // status flip is what let a disposition hook delete a live session's content, so the row count
1585
+ // is the answer to both questions.
1586
+ const landed = Number(res?.changes ?? 0) > 0;
1587
+ if (!landed) {
1588
+ this.#ctx.logger.error("session.status.write.missed", {
1589
+ sessionId,
1590
+ status,
1591
+ agentName,
1592
+ impact: (status === "sealed" || status === "abandoned")
1593
+ ? "no session row matched — the status was NOT changed and no disposition was run, AND "
1594
+ + "this session's transport identity has already been destroyed, so it can no longer "
1595
+ + "be revived even though its row still says it is open"
1596
+ : "no session row matched — the status was NOT changed and no disposition was run",
1597
+ });
1598
+ return false;
1599
+ }
1600
+ // DOD-RETRYQ-STRAND-1: only AFTER the status write actually landed. Disposing of durable
1601
+ // state on the strength of a write that did not land would discard content while the session
1602
+ // is still, on disk, drainable. 'interrupted' and 'seal_interrupted_pending' are deliberately
1603
+ // NOT terminal — both can still complete, and reaping them would destroy live content.
1604
+ if (status === "sealed" || status === "abandoned") {
1605
+ // DOD-M12B-STRAND-1: held frames outlive the chain that could have carried them.
1606
+ //
1607
+ // Once a session is terminal, `ingestReceivedContent` (in `session-content-ingest.ts`)
1608
+ // refuses it — and #releaseHeld is only
1609
+ // reachable from ingest — so no code path that exists can ever release a held frame again.
1610
+ // Left alone the rows sit on disk, unreachable by any surface, while the teardown alarm
1611
+ // reports `lost: 0`: a success message for content that has just become permanently
1612
+ // unreadable. The annex is the store built for exactly this shape.
1613
+ this.#ctx.held.annexHeldContentOnTerminal(agentName, sessionId, status);
1614
+ try {
1615
+ this.#ctx.onSessionTerminal?.(sessionId, status);
1616
+ }
1617
+ catch (hookErr) {
1618
+ // The status flip is the caller's contract and has already succeeded; a failing
1619
+ // disposition must not turn it into a reported failure. Named so the strand it leaves
1620
+ // behind is attributable rather than mysterious.
1621
+ this.#ctx.logger.error("session.terminal.disposition.failed", {
1622
+ sessionId,
1623
+ status,
1624
+ error: hookErr instanceof Error ? hookErr.message : String(hookErr),
1625
+ impact: "durable state keyed to this session was not disposed of and may strand",
1626
+ });
1627
+ }
1628
+ }
1629
+ return true;
1630
+ }
1631
+ catch (err) {
1632
+ // CC-5 (reviewer F-2): status-agnostic event + the actual target status in context — this method
1633
+ // now writes "abandoned" too, so labeling every failure "interrupt" was misleading.
1634
+ this.#ctx.logger.error("session.status.write.failed", {
1635
+ sessionId,
1636
+ status,
1637
+ error: err instanceof Error ? err.message : String(err),
1638
+ });
1639
+ return false;
1640
+ }
1641
+ }
1642
+ }
1643
+ //# sourceMappingURL=session-lifecycle.js.map