@cello-protocol/daemon 0.0.169 → 0.0.171

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 (48) hide show
  1. package/dist/agent-id-migration.d.ts.map +1 -1
  2. package/dist/agent-id-migration.js +17 -0
  3. package/dist/agent-id-migration.js.map +1 -1
  4. package/dist/away-detection.d.ts +62 -15
  5. package/dist/away-detection.d.ts.map +1 -1
  6. package/dist/away-detection.js +77 -20
  7. package/dist/away-detection.js.map +1 -1
  8. package/dist/close-session-handler.d.ts.map +1 -1
  9. package/dist/close-session-handler.js +81 -3
  10. package/dist/close-session-handler.js.map +1 -1
  11. package/dist/daemon.d.ts +7 -0
  12. package/dist/daemon.d.ts.map +1 -1
  13. package/dist/daemon.js +310 -38
  14. package/dist/daemon.js.map +1 -1
  15. package/dist/delivery-open-registry.d.ts +92 -0
  16. package/dist/delivery-open-registry.d.ts.map +1 -0
  17. package/dist/delivery-open-registry.js +121 -0
  18. package/dist/delivery-open-registry.js.map +1 -0
  19. package/dist/document-delivery-transport.d.ts +10 -1
  20. package/dist/document-delivery-transport.d.ts.map +1 -1
  21. package/dist/document-delivery-transport.js +10 -1
  22. package/dist/document-delivery-transport.js.map +1 -1
  23. package/dist/document-layer.d.ts +21 -0
  24. package/dist/document-layer.d.ts.map +1 -1
  25. package/dist/document-layer.js +34 -1
  26. package/dist/document-layer.js.map +1 -1
  27. package/dist/document-reconcile-scheduler.d.ts +33 -0
  28. package/dist/document-reconcile-scheduler.d.ts.map +1 -1
  29. package/dist/document-reconcile-scheduler.js +73 -0
  30. package/dist/document-reconcile-scheduler.js.map +1 -1
  31. package/dist/inbound-sessions.d.ts +7 -0
  32. package/dist/inbound-sessions.d.ts.map +1 -1
  33. package/dist/inbound-sessions.js +77 -6
  34. package/dist/inbound-sessions.js.map +1 -1
  35. package/dist/notification-handlers.d.ts.map +1 -1
  36. package/dist/notification-handlers.js +39 -2
  37. package/dist/notification-handlers.js.map +1 -1
  38. package/dist/session-content-handlers.d.ts.map +1 -1
  39. package/dist/session-content-handlers.js +134 -8
  40. package/dist/session-content-handlers.js.map +1 -1
  41. package/dist/session-node-manager.d.ts +177 -8
  42. package/dist/session-node-manager.d.ts.map +1 -1
  43. package/dist/session-node-manager.js +1504 -43
  44. package/dist/session-node-manager.js.map +1 -1
  45. package/dist/types.d.ts +64 -2
  46. package/dist/types.d.ts.map +1 -1
  47. package/dist/types.js.map +1 -1
  48. package/package.json +5 -5
@@ -52,6 +52,74 @@ import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gatewa
52
52
  */
53
53
  const AUTOACK_BROKER_GRACE_MS = 30_000;
54
54
  const MAX_REFUSED_PARKED_ENTRIES = 512;
55
+ /**
56
+ * DOD-M12B-ACK-1 — inbound `/cello/content/1.0.0` streams allowed per connection.
57
+ *
58
+ * libp2p's registrar default is 32, and it enforces the cap AFTER protocol negotiation has already
59
+ * answered, so exceeding it resets a stream the sender believes it just opened and the sender's
60
+ * next write fails with a message that names nothing. Content delivery is bursty by design (a
61
+ * document sweep opened 99 events in one second on a live daemon), and a slot stays occupied for
62
+ * the whole of ingest — which awaits SQLCipher and the security gateway. 32 is simply too close to
63
+ * normal traffic to be a safety limit.
64
+ *
65
+ * This is headroom, NOT the fix. The fix is that #handleContentStream now closes what it opens; a
66
+ * raised cap without that would only move the cliff. Kept finite on purpose: an unbounded cap would
67
+ * let a peer pin memory by opening streams it never uses, and the ceiling is what makes a future
68
+ * leak of this shape show up as a bounded failure instead of a heap.
69
+ */
70
+ const CONTENT_MAX_INBOUND_STREAMS = 512;
71
+ /**
72
+ * DOD-M12B-ACK-1 — how long an inbound content stream may stay open after we have closed our end.
73
+ *
74
+ * Closing our write end retires the stream only once the PEER has closed its end too, so a peer
75
+ * that opens streams and never closes them still fills our inbound slots — a guard that runs only
76
+ * on the party it constrains is not a guard. After this window we reset it ourselves, which the
77
+ * muxer honours unilaterally.
78
+ *
79
+ * It cannot be zero: an immediate reset would land while a well-behaved sender is still inside its
80
+ * own `await stream.close()`, rejecting that close and turning every ordinary send into a park.
81
+ * The frame is already ingested long before this fires, so nothing waits on it.
82
+ */
83
+ const CONTENT_STREAM_LINGER_MS = 30_000;
84
+ /**
85
+ * DOD-M12B-SHUTDOWN-1 — how long one teardown step may block the daemon's exit.
86
+ *
87
+ * Chosen against the surface that complains: `cello logout` gives up and reports the daemon still
88
+ * running after 5 s, so a step that can burn longer than that guarantees the message the operator
89
+ * saw. Two steps at 2 s each stay inside it.
90
+ */
91
+ const SHUTDOWN_STEP_DEADLINE_MS = 2_000;
92
+ /**
93
+ * DOD-M12B-REDIAL-1 — the shortest gap between two re-dials of one session.
94
+ *
95
+ * Long enough that a burst of sends against a peer that is genuinely gone costs one dial rather
96
+ * than one per message; short enough that a peer coming back is picked up on the next thing the
97
+ * operator says. It is cleared on a successful dial, so it never delays a live counterparty.
98
+ */
99
+ const REDIAL_COOLDOWN_MS = 15_000;
100
+ /**
101
+ * DOD-CAP-SELF-HEAL-1 — what counts against a per-sender acceptance bound.
102
+ *
103
+ * `active` always. `interrupted` ONLY when the counterparty caused it.
104
+ *
105
+ * D18 is why `interrupted` has to count at all: a peer can flip a session to `interrupted` for free
106
+ * by dropping its stream, then open a fresh one, indefinitely. Those are theirs and still count.
107
+ *
108
+ * What broke was charging them for OURS. A daemon restart flips every live session to
109
+ * `interrupted`, nothing resolves them, and the reaper correctly refuses to take any with received
110
+ * content — so the bound became all-time instead of concurrent. Measured 2026-08-17: two of one
111
+ * operator's own agents could not open a session, because one held five finished conversations with
112
+ * the other against a stranger cap of three.
113
+ *
114
+ * NULL counts as the counterparty's. The column is new, so every pre-existing row is unlabelled,
115
+ * and the safe default for an anti-abuse bound is to count rather than to excuse.
116
+ */
117
+ const CAP_COUNTS = (alias = "") => {
118
+ const p = alias ? `${alias}.` : "";
119
+ return `(${p}status = 'active'
120
+ OR (${p}status = 'interrupted' AND COALESCE(${p}interrupted_by, 'counterparty') != 'local'))`;
121
+ };
122
+ const CAP_COUNT_SQL = (where) => `SELECT COUNT(*) AS n FROM sessions WHERE ${where} AND ${CAP_COUNTS()}`;
55
123
  // Persistence bounds are TIER-GRADUATED via DEFAULT_TIER_BOUNDS (contacts-tier-migration). The two
56
124
  // consts below DERIVE from the grid's UNKNOWN row rather than restating it — the grid is the single
57
125
  // source (DOD-TIER-2 AC4), so these can never drift from it.
@@ -237,9 +305,36 @@ export class SessionNodeManager {
237
305
  #autoNatProbers;
238
306
  // M7-SESSION-003: per-session direct-path counterparty liveness, observed on the
239
307
  // session node's onPeerConnect ('alive') / onPeerDisconnect ('gone'). This is
240
- // the liveness authority for direct sessions the unilateral-seal gate reads
241
- // it (relay sessions query the relay instead). NEVER the directory (SI-002).
308
+ // the liveness authority for direct sessions (relay sessions query the relay
309
+ // instead). NEVER the directory (SI-002). Read by exactly three consumers: the
310
+ // half-open reaper, both status surfaces, and cello_receive. No seal path reads
311
+ // it — the coupling to sealing runs through the receive guidance, which turns
312
+ // 'gone' into "call cello_close_session".
313
+ // DOD-M12B-ACK-1 adds 'impaired': connection up, our writes on it failing. It sits BELOW
314
+ // 'alive' and never above 'gone' — see #markSessionImpaired.
242
315
  #sessionLiveness = new Map();
316
+ // DOD-M12B-ACK-1: WHY a session is impaired and what became of the content. Separate from the
317
+ // state above because the state is what surfaces print and this is what they must explain.
318
+ #impairmentCause = new Map();
319
+ // DOD-M12B-STRAND-1: sessions whose durable holds have been read back. One read per session per
320
+ // process; the Map is the working copy from then on.
321
+ #heldRestored = new Set();
322
+ // DOD-M12B-SEAL-STUCK-1: sessions whose post-restore release has been attempted. Separate from
323
+ // #heldRestored so a READ-ONLY probe can hydrate without performing (or consuming) the release.
324
+ #heldReleased = new Set();
325
+ // DOD-M12B-SEAL-STUCK-1: sessions whose ordering state THIS PROCESS has observed — a relay
326
+ // witness recorded for it. `#witnessedSeq` is memory-only, so for a session that predates this
327
+ // daemon "no gap recorded" means "not recorded", not "no gap", and that difference decides
328
+ // whether we may tell an operator the session is safe to close.
329
+ #orderingObserved = new Set();
330
+ // DOD-M12B-INDEX-1: sessions whose tree and whose relay counter have provably parted. A diverged
331
+ // session can never produce a root the counterparty agrees with, so it must never be reported as
332
+ // safe to close — the close would be signed, refused as `leaf_count_mismatch`, and the receipt
333
+ // lost for good.
334
+ #diverged = new Set();
335
+ // DOD-M12B-ACK-1: pending linger-resets for inbound content streams the peer has not closed.
336
+ // Held so shutdown can drop them rather than leave timers pointing at a torn-down node.
337
+ #lingeringStreams = new Set();
243
338
  // M7-UPGRADE-002: sessions whose content integrity could NOT be verified (a content_hash
244
339
  // mismatch = tamper was observed). The auto-acknowledge gate (SI-002) refuses to auto-co-sign
245
340
  // for a desynced session — B must never blind-sign a tail it cannot verify. Keyed by sessionId hex.
@@ -341,6 +436,20 @@ export class SessionNodeManager {
341
436
  // with its agent away), and the park deposit that follows has to be refused. One without the
342
437
  // other reproduces nothing.
343
438
  #sendFaultRemaining = 0;
439
+ // DOD-M12B-ACK-1 — the same seam for the delivery-ACK write. See injectAckFault.
440
+ #ackFaultRemaining = 0;
441
+ // DOD-M12B-REDIAL-1 — makes the next N `newStream` calls report the connection as gone, BEFORE
442
+ // the node is touched. The sibling of injectSendFault for the one condition that used to end a
443
+ // conversation permanently; a real connection drop is not reproducible in-process.
444
+ #connectionLossRemaining = 0;
445
+ // DOD-M12B-REDIAL-1: the counterparty addresses this session dialled, kept so it can dial them
446
+ // again. They arrived in the FROST-signed assignment and were used once and dropped, which is
447
+ // why nothing could ever re-dial.
448
+ #counterpartyAddrs = new Map();
449
+ // DOD-M12B-REDIAL-1: when this session may next attempt a re-dial. Continuous re-dialling is what
450
+ // produced the 2026-08-17 notification storm, so a burst of sends against a peer that is gone
451
+ // costs one attempt, not one per message.
452
+ #redialNotBefore = new Map();
344
453
  /** Arm the park-deposit fault. Returns the count now armed. */
345
454
  injectParkFault(count, cause) {
346
455
  this.#parkFaultRemaining = Math.max(0, count);
@@ -353,6 +462,20 @@ export class SessionNodeManager {
353
462
  this.#sendFaultRemaining = Math.max(0, count);
354
463
  return this.#sendFaultRemaining;
355
464
  }
465
+ /** DOD-M12B-ACK-1: arm the delivery-ACK write fault — the sibling of injectSendFault for the
466
+ * path that fails on a LISTENING agent. Without it the ACK failure branch (which impairs the
467
+ * session and, until this milestone, could never clear it again) is unreachable from a test:
468
+ * a listener sends no content, so the direct-send fault never fires for it. */
469
+ injectAckFault(count) {
470
+ this.#ackFaultRemaining = Math.max(0, count);
471
+ return this.#ackFaultRemaining;
472
+ }
473
+ /** DOD-M12B-REDIAL-1: arm the connection-loss fault — the next N direct sends find no open
474
+ * connection, exactly as they do after any blip. See #connectionLossRemaining. */
475
+ injectConnectionLoss(count) {
476
+ this.#connectionLossRemaining = Math.max(0, count);
477
+ return this.#connectionLossRemaining;
478
+ }
356
479
  getSendFaultRemaining() {
357
480
  return this.#sendFaultRemaining;
358
481
  }
@@ -639,6 +762,17 @@ export class SessionNodeManager {
639
762
  // watermark: this records "operator acknowledged via dismiss", not "operator received via
640
763
  // cello_receive". NULL = not yet dismissed.
641
764
  "ALTER TABLE sessions ADD COLUMN read_at INTEGER",
765
+ // DOD-M12B-ABANDON-NOTIFY-1: epoch-ms when the counterparty told us they force-abandoned.
766
+ // Deliberately NOT a status — the session stays sealable, so the operator can still take a
767
+ // unilateral receipt. It stops this side calling them, nothing more.
768
+ "ALTER TABLE sessions ADD COLUMN counterparty_abandoned_at INTEGER",
769
+ // DOD-CAP-SELF-HEAL-1: WHO caused this session to be interrupted — 'counterparty' when their
770
+ // stream dropped, 'local' when OUR daemon stopped or started. Only theirs counts against the
771
+ // acceptance bound. Without this the bound is all-time rather than concurrent: every restart
772
+ // flips every live session to `interrupted`, nothing ever resolves them, and a pair of agents
773
+ // that has talked three times can never talk again. NULL means "not recorded" and is treated
774
+ // as the counterparty's, because the safe default for an anti-abuse bound is to count it.
775
+ "ALTER TABLE sessions ADD COLUMN interrupted_by TEXT",
642
776
  ]) {
643
777
  try {
644
778
  this.#db.exec(ddl);
@@ -733,6 +867,69 @@ export class SessionNodeManager {
733
867
  PRIMARY KEY (agent_id, session_id, leaf_index)
734
868
  )
735
869
  `);
870
+ // DOD-M12B-STRAND-1 — content we RECEIVED and VERIFIED but cannot append yet.
871
+ //
872
+ // Held content used to live only in `#heldContent`, a Map that died with the session node. The
873
+ // teardown path said so itself: "the content is unrecoverable by the time we are here."
874
+ // Measured on one daemon in one morning: 367 held, 8 released, **24 destroyed**. Each
875
+ // destruction is permanent and one-sided — the sender was never acknowledged, so it believes
876
+ // the message is merely pending, while the only copy the receiver will ever see is gone and
877
+ // every later message in that session is stuck behind a gap nothing can fill.
878
+ //
879
+ // `canonical_seq` is the RELAY's position, not a local counter, and it is part of the key: that
880
+ // is what lets a frame come back after a restart and land at its OWN index rather than the next
881
+ // free slot. Appending it anywhere else would change the root the seal signs over.
882
+ //
883
+ // Keyed on agent_id, never agent_name — agent_name is a mutable display label (see the repo
884
+ // guide). `content_blob` is the SCREENED copy that gets delivered; `original_blob` is the peer's
885
+ // raw bytes, which the release path needs because classification reads byte 0 and the screened
886
+ // copy is no longer a CBOR map header for a document frame.
887
+ this.#db.exec(`
888
+ CREATE TABLE IF NOT EXISTS held_content (
889
+ agent_id TEXT NOT NULL,
890
+ session_id TEXT NOT NULL,
891
+ canonical_seq INTEGER NOT NULL,
892
+ content_blob BLOB NOT NULL,
893
+ original_blob BLOB,
894
+ content_hash_hex TEXT NOT NULL,
895
+ screened_out INTEGER NOT NULL DEFAULT 0,
896
+ correlation_id TEXT,
897
+ held_at INTEGER NOT NULL,
898
+ -- DOD-M12B-INDEX-1: 'received' (default) or 'sent'. A held frame of OUR OWN must be
899
+ -- released down the sent path — appended and transcribed as sent — never down the received
900
+ -- path, which would put our words in the counterparty's mouth in the sealed record and hand
901
+ -- them back to our own agent through cello_receive as though they had just arrived.
902
+ origin TEXT NOT NULL DEFAULT 'received',
903
+ -- DOD-M12B-INDEX-1: 'msg' or 'doc'. A held document leaf must come back as a document leaf.
904
+ leaf_kind TEXT NOT NULL DEFAULT 'msg',
905
+ PRIMARY KEY (agent_id, session_id, canonical_seq)
906
+ )
907
+ `);
908
+ // DOD-M12B-INDEX-1: `CREATE TABLE IF NOT EXISTS` is a NO-OP against a table that already
909
+ // exists, so a database created between DOD-M12B-STRAND-1 and this change has `held_content`
910
+ // WITHOUT `origin`. On those every insert throws and every restore throws — holds go back to
911
+ // memory-only, silently at the surface, and that now includes our own sent messages, which
912
+ // nobody else holds a copy of. Loud in the log is not the same as visible.
913
+ try {
914
+ this.#db.exec("ALTER TABLE held_content ADD COLUMN origin TEXT NOT NULL DEFAULT 'received'");
915
+ }
916
+ catch (err) {
917
+ const msg = err instanceof Error ? err.message : String(err);
918
+ if (!/duplicate column name/i.test(msg))
919
+ throw err;
920
+ }
921
+ // DOD-M12B-INDEX-1: and the LEAF KIND. `#releaseHeld` used to append every held frame as "msg",
922
+ // so a document leaf that had to wait for its position came back as a conversation message —
923
+ // the distinction survived the immediate append and was destroyed by the hold, unrecoverably
924
+ // after a restart.
925
+ try {
926
+ this.#db.exec("ALTER TABLE held_content ADD COLUMN leaf_kind TEXT NOT NULL DEFAULT 'msg'");
927
+ }
928
+ catch (err) {
929
+ const msg = err instanceof Error ? err.message : String(err);
930
+ if (!/duplicate column name/i.test(msg))
931
+ throw err;
932
+ }
736
933
  // DOD-LOG-1 (PERSIST-LOG-001) / PERSIST-002 (AC-010): the durable, ENCRYPTED-at-rest readable
737
934
  // transcript. Each row is keyed by the canonical leaf `sequence`, so it JOINS to
738
935
  // session_tree_leaves(leaf_index) — a stored message is provably behind a committed hash-chain
@@ -890,7 +1087,10 @@ export class SessionNodeManager {
890
1087
  for (const row of activeRows) {
891
1088
  try {
892
1089
  this.#db
893
- .prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE agent_id = ? AND session_id = ?")
1090
+ .prepare(
1091
+ // DOD-CAP-SELF-HEAL-1: OURS. This is the boot sweep finding sessions a previous
1092
+ // process left `active`; the counterparty did nothing, so they are not charged for it.
1093
+ "UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?), interrupted_by = 'local' WHERE agent_id = ? AND session_id = ?")
894
1094
  .run(now, interruptedAt, row.agent_id, row.session_id);
895
1095
  if (row.agent_name === null) {
896
1096
  this.#logger.error("session.agent.orphaned", {
@@ -1548,6 +1748,10 @@ export class SessionNodeManager {
1548
1748
  * otherwise let multiple held chunks each individually pass the size gate while cumulatively
1549
1749
  * exceeding it once #releaseHeld drains them. */
1550
1750
  #getHeldBytesTotal(agentName, sessionId) {
1751
+ // DOD-M12B-STRAND-1: hydrate first. Reading the Map before the durable holds are back
1752
+ // under-counts, and this gate exists to stop several held chunks each passing the size cap
1753
+ // individually while cumulatively exceeding it — an under-count is the bypass.
1754
+ this.#ensureHeldRestored(agentName, sessionId);
1551
1755
  const held = this.#heldContent.get(this.#k(agentName, sessionId));
1552
1756
  if (!held)
1553
1757
  return 0;
@@ -1567,7 +1771,7 @@ export class SessionNodeManager {
1567
1771
  if (!this.#db)
1568
1772
  return 0;
1569
1773
  const row = this.#db
1570
- .prepare("SELECT COUNT(*) AS n FROM sessions WHERE agent_id = ? AND counterparty_pubkey = ? AND status IN ('active', 'interrupted')")
1774
+ .prepare(CAP_COUNT_SQL("agent_id = ? AND counterparty_pubkey = ?"))
1571
1775
  .get(this.#requireAgentId(agentName), counterpartyPubkey);
1572
1776
  return row.n;
1573
1777
  }
@@ -1583,7 +1787,8 @@ export class SessionNodeManager {
1583
1787
  return 0;
1584
1788
  const row = this.#db
1585
1789
  .prepare(`SELECT COUNT(*) AS n FROM sessions s
1586
- WHERE s.agent_id = ? AND s.status IN ('active', 'interrupted')
1790
+ WHERE s.agent_id = ?
1791
+ AND ${CAP_COUNTS("s")}
1587
1792
  AND NOT EXISTS (
1588
1793
  SELECT 1 FROM contacts c
1589
1794
  WHERE c.agent_id = s.agent_id AND c.pubkey = s.counterparty_pubkey
@@ -1605,6 +1810,10 @@ export class SessionNodeManager {
1605
1810
  const perSenderCap = this.resolveTierBound(agentName, tier, "max_sessions");
1606
1811
  const perSender = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
1607
1812
  if (perSender >= perSenderCap) {
1813
+ // BYTE-IDENTICAL to every other refusal, deliberately — DOD-TIER-3. A BLOCKED sender and an
1814
+ // over-cap UNKNOWN must be indistinguishable, or the refusal tells someone they are blocked.
1815
+ // The operator's alarm needs numbers, so it asks for them SEPARATELY via capDiagnostics;
1816
+ // hanging them off this object would put a distinguishing oracle in the return value.
1608
1817
  return { ok: false, reason: "abuse_bound_sessions_per_sender" };
1609
1818
  }
1610
1819
  // The global stranger cap is only for the UNKNOWN pool. A KNOWN+ sender is past it by trust;
@@ -1978,10 +2187,21 @@ export class SessionNodeManager {
1978
2187
  };
1979
2188
  }
1980
2189
  // Log observability event (session.node.created)
2190
+ //
2191
+ // `counterpartySessionPeerId` IS LOGGED because it is recorded here ONCE and never refreshed,
2192
+ // while a standing receiver is rebuilt with a fresh libp2p keypair on every signaling reconnect
2193
+ // and every lost reservation. If the peer rebuilds between advertising its endpoint and this
2194
+ // handoff, we record an identity that no longer exists — and since `newStream` never dials, it
2195
+ // only ever looks for an ALREADY-OPEN connection filed under exactly this string, so every send
2196
+ // in this direction parks forever while the reverse direction works fine.
2197
+ //
2198
+ // Both sides of a local session log this event, so recording the id we will dial makes that
2199
+ // mismatch a direct comparison in the log instead of an unfalsifiable hypothesis.
1981
2200
  this.#logger.info("session.node.created", {
1982
2201
  sessionId,
1983
2202
  agentName,
1984
2203
  sessionPeerId: peerId,
2204
+ counterpartySessionPeerId: counterpartyPeerId,
1985
2205
  correlationId,
1986
2206
  });
1987
2207
  // Add to active map (keyed by (agentName, sessionId) — DOD-LOOP-1)
@@ -2232,10 +2452,176 @@ export class SessionNodeManager {
2232
2452
  /**
2233
2453
  * M7-SESSION-003: read the direct-path counterparty liveness for a session.
2234
2454
  * 'unknown' when no session node observation has occurred yet.
2455
+ *
2456
+ * DOD-M12B-ACK-1: 'impaired' is DAEMON-LOCAL and deliberately not on the relay's
2457
+ * SessionLiveness wire type — the relay answers a different question (does it hold the
2458
+ * recipient's standing connection) and its three states are a deployed bilateral contract.
2235
2459
  */
2236
2460
  getSessionLiveness(agentName, sessionId) {
2237
2461
  return this.#sessionLiveness.get(this.#k(agentName, sessionId)) ?? "unknown";
2238
2462
  }
2463
+ /**
2464
+ * DOD-M12B-ACK-1 — live `/cello/content/1.0.0` stream counts on a session's direct path, or null
2465
+ * when the session has no active node.
2466
+ *
2467
+ * Answerable at runtime on purpose, in the same spirit as getConnectionMonitorPolicy: the count
2468
+ * is what decides whether the next send survives, and until this existed it could only be
2469
+ * recovered by measuring a log after the fact. It is also what lets the regression assert that a
2470
+ * slot was RELEASED, rather than that some particular number of messages happened to fit.
2471
+ */
2472
+ countSessionContentStreams(agentName, sessionId) {
2473
+ const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
2474
+ if (!entry || typeof entry.node.countProtocolStreams !== "function")
2475
+ return null;
2476
+ return entry.node.countProtocolStreams(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
2477
+ }
2478
+ /**
2479
+ * DOD-M12B-ACK-1 — the live content-stream counts for a peer, as log context.
2480
+ *
2481
+ * A diagnostic must not be able to break the failure path it describes, so a node that predates
2482
+ * `countProtocolStreams` (test fakes do) yields no fields rather than throwing.
2483
+ */
2484
+ #streamCensus(node, peerId) {
2485
+ if (typeof node.countProtocolStreams !== "function")
2486
+ return {};
2487
+ try {
2488
+ const { inbound, outbound } = node.countProtocolStreams(peerId, CELLO_CONTENT_PROTOCOL_ID);
2489
+ return { contentStreamsInbound: inbound, contentStreamsOutbound: outbound, contentStreamsInboundCap: CONTENT_MAX_INBOUND_STREAMS };
2490
+ }
2491
+ catch {
2492
+ return {};
2493
+ }
2494
+ }
2495
+ /**
2496
+ * DOD-M12B-ACK-1 — the connection is up and delivery on it is not working.
2497
+ *
2498
+ * Liveness is otherwise driven ONLY by libp2p peer-connect/peer-disconnect, so it answers "is
2499
+ * there a connection object?" while every surface that prints it is read as "can I talk to them?".
2500
+ * Measured 2026-08-17: one session reported `alive` for 70 minutes after every write had started
2501
+ * failing, another never stopped.
2502
+ *
2503
+ * ONLY 'gone' is protected, and 'gone' is NOT protected because a seal gate reads it — nothing in
2504
+ * the code does. It is protected because the receive surface turns 'gone' into "call
2505
+ * cello_close_session", and a failed write must never be able to produce that instruction.
2506
+ *
2507
+ * 'unknown' is DOWNGRADED just like 'alive', which is not obvious and is the point. A session
2508
+ * whose recorded `counterpartySessionPeerId` has gone stale never sees a matching peer-connect,
2509
+ * so it sits at 'unknown' while every send fails forever — the exact case documented at
2510
+ * #wireSessionLiveness — and the receive surface renders 'unknown' as healthy-and-quiet, which is
2511
+ * the 70-minute lie relocated one lane over. 'unknown' claims nothing; the surface built on it does.
2512
+ */
2513
+ #markSessionImpaired(agentName, sessionId, opts) {
2514
+ const key = this.#k(agentName, sessionId);
2515
+ const prior = this.#sessionLiveness.get(key);
2516
+ if (prior === "gone") {
2517
+ // Declining is a decision, so it is logged. A silent early return here is the shape that let
2518
+ // the original defect hide for a day: nothing recorded that writes were failing on a session
2519
+ // every surface was still calling healthy.
2520
+ this.#logger.debug("session.liveness.impairment.declined", {
2521
+ sessionId, liveness: prior, cause: opts.cause, error: opts.error, correlationId: opts.correlationId,
2522
+ });
2523
+ return;
2524
+ }
2525
+ // The CAUSE is refreshed even when the state does not move, because the receive surface builds
2526
+ // its guidance from it and a stale cause would describe the wrong failure.
2527
+ this.#impairmentCause.set(key, { cause: opts.cause, retained: "unknown" });
2528
+ if (prior === "impaired")
2529
+ return;
2530
+ this.#sessionLiveness.set(key, "impaired");
2531
+ this.#logger.warn("session.liveness.changed", {
2532
+ sessionId,
2533
+ counterpartyPubkey: this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey,
2534
+ transportPath: "direct",
2535
+ liveness: "impaired",
2536
+ observedBy: opts.cause,
2537
+ priorLiveness: prior ?? "unknown",
2538
+ // `reason` is a contract string and `error` is the message — the convention every other
2539
+ // failure log in this file follows. Collapsing them makes grouping by `reason` useless.
2540
+ reason: "write_failed",
2541
+ error: opts.error,
2542
+ correlationId: opts.correlationId,
2543
+ });
2544
+ }
2545
+ /**
2546
+ * DOD-M12B-ACK-1 — what became of the content whose send caused the impairment.
2547
+ *
2548
+ * The receive surface has no memory of the last send, so without this it can only guess — and the
2549
+ * guess it would make ("it was parked, do not resend") is FALSE in the two cases that matter
2550
+ * most: a refused park whose durable enqueue was dropped, and one that threw. In both the message
2551
+ * is gone and `cello_send` has already told the caller to send it again, so a receive that says
2552
+ * "do not resend" contradicts it later, while the agent is sitting there waiting.
2553
+ */
2554
+ #noteImpairmentRetention(agentName, sessionId, retained) {
2555
+ const key = this.#k(agentName, sessionId);
2556
+ const current = this.#impairmentCause.get(key);
2557
+ if (!current)
2558
+ return;
2559
+ this.#impairmentCause.set(key, { cause: current.cause, retained });
2560
+ }
2561
+ /** DOD-M12B-ACK-1: why this session is impaired, for the surface that has to explain it. Null
2562
+ * when it is not impaired — a caller must not narrate a failure that is not current. */
2563
+ getSessionImpairment(agentName, sessionId) {
2564
+ const key = this.#k(agentName, sessionId);
2565
+ if (this.#sessionLiveness.get(key) !== "impaired")
2566
+ return null;
2567
+ return this.#impairmentCause.get(key) ?? null;
2568
+ }
2569
+ /**
2570
+ * DOD-M12B-ACK-1 — a delivery landed, so the impairment is over.
2571
+ *
2572
+ * Without this an `impaired` flag is a one-way door: one bad write would make a session report a
2573
+ * broken conversation for the rest of its life, which is the same class of lie in the other
2574
+ * direction. Called from BOTH send paths — an agent that mostly listens sends content rarely and
2575
+ * ACKs constantly, so clearing only on content would leave exactly those sessions impaired
2576
+ * forever. Only clears 'impaired': a successful write says nothing about a connection libp2p has
2577
+ * already declared 'gone'.
2578
+ */
2579
+ #clearSessionImpairment(agentName, sessionId, observedBy, correlationId) {
2580
+ const key = this.#k(agentName, sessionId);
2581
+ if (this.#sessionLiveness.get(key) !== "impaired")
2582
+ return;
2583
+ this.#sessionLiveness.set(key, "alive");
2584
+ this.#impairmentCause.delete(key);
2585
+ this.#logger.info("session.liveness.changed", {
2586
+ sessionId,
2587
+ counterpartyPubkey: this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey,
2588
+ transportPath: "direct",
2589
+ liveness: "alive",
2590
+ observedBy,
2591
+ reason: "write_succeeded",
2592
+ correlationId,
2593
+ });
2594
+ }
2595
+ /**
2596
+ * DOD-M12B-ABANDON-NOTIFY-1 — drive the REAL inbound content handler with one framed message and
2597
+ * a claimed peer identity.
2598
+ *
2599
+ * The handler is registered on a live libp2p node, so without this the only way to reach its
2600
+ * branches is a full two-node transport fixture — which is why the session-abandoned branch and
2601
+ * its peer pinning had no coverage at all. This feeds the same function the protocol handler
2602
+ * calls, including the authentication check, rather than a copy of its logic.
2603
+ */
2604
+ async handleContentFrameForTest(agentName, sessionId, framedBytes, remotePeerId) {
2605
+ const source = {
2606
+ async *[Symbol.asyncIterator]() { yield framedBytes; },
2607
+ close: async () => { },
2608
+ abort: () => { },
2609
+ status: "closed",
2610
+ };
2611
+ await this.#handleContentStream(agentName, sessionId, source, remotePeerId);
2612
+ }
2613
+ /** DOD-CAP-SELF-HEAL-1 test seam: the shutdown sweep's effect, without a shutdown. Mirrors the
2614
+ * real UPDATE at gracefulShutdown so a test cannot pass against a label production never sets. */
2615
+ markSessionsInterruptedByLocalShutdownForTest() {
2616
+ // SCOPED TO UNLABELLED ROWS. Unscoped, this would relabel rows already marked
2617
+ // `counterparty` — one call excusing every interruption every attacker ever caused, on every
2618
+ // agent. Production never relabels; nor does this.
2619
+ this.#db?.prepare("UPDATE sessions SET interrupted_by = 'local' WHERE status = 'interrupted' AND interrupted_by IS NULL").run();
2620
+ }
2621
+ /** DOD-CAP-SELF-HEAL-1 test seam: the counterparty's stream closing, without a real peer. */
2622
+ markInterruptedByCounterpartyForTest(agentName, sessionId) {
2623
+ this.#db?.prepare("UPDATE sessions SET interrupted_by = 'counterparty' WHERE agent_id = ? AND session_id = ?").run(this.#requireAgentId(agentName), sessionId);
2624
+ }
2239
2625
  /** Test seam (same spirit as getDb()): seed per-session direct-path liveness, which is otherwise
2240
2626
  * only set by the live node's onPeerConnect/onPeerDisconnect (#wireSessionLiveness). Lets a
2241
2627
  * DB-seeded test exercise the CC-5 reaper's "alive counterparty must survive" gate without standing
@@ -2308,11 +2694,16 @@ export class SessionNodeManager {
2308
2694
  "Check the daemon's database (disk space, permissions).",
2309
2695
  };
2310
2696
  }
2311
- // Log observability event
2697
+ // Log observability event. `counterpartySessionPeerId` for the same reason as the initiator
2698
+ // side: this is the identity every later send will look for an open connection under, it is
2699
+ // never refreshed, and the peer's standing receiver may already have been rebuilt under a new
2700
+ // one. The RESPONDER is the side that can go stale — only the initiator dials, so this is the
2701
+ // half that inherits an id it never verified.
2312
2702
  this.#logger.info("session.node.created", {
2313
2703
  sessionId,
2314
2704
  agentName,
2315
2705
  sessionPeerId: peerId,
2706
+ counterpartySessionPeerId: initiatorPeerId,
2316
2707
  correlationId,
2317
2708
  });
2318
2709
  // Remove this agent's standing receiver from the slot and add to active map. The handed-off
@@ -2385,7 +2776,10 @@ export class SessionNodeManager {
2385
2776
  // surface as interrupted so AC-010 recovery handles them at next login.
2386
2777
  // The session.node.destroyed log preserves the original reason for observability.
2387
2778
  const dbStatus = reason === "sealed" ? "sealed" : "interrupted";
2388
- this.#updateSessionStatus(agentName, sessionId, dbStatus);
2779
+ // DOD-CAP-SELF-HEAL-1: OURS. Every caller of this with a non-sealed reason is a local teardown
2780
+ // — the operator's kill switch (`cello_set_agent_offline`), an internal error, a node replaced.
2781
+ // The counterparty did nothing, so they must not be charged a cap slot for it.
2782
+ this.#updateSessionStatus(agentName, sessionId, dbStatus, dbStatus === "interrupted" ? "local" : undefined);
2389
2783
  this.#activeNodes.delete(this.#k(agentName, sessionId));
2390
2784
  // Evict the in-memory per-session caches on teardown. The tree is durable in
2391
2785
  // SQLite (getSessionTree reloads it on demand), and the received-content buffer
@@ -2500,22 +2894,69 @@ export class SessionNodeManager {
2500
2894
  // destroyed with the session three seconds later. The sender then re-sent that envelope 90
2501
2895
  // times against a ceiling of 5, and every surface reported the delivery as merely pending.
2502
2896
  //
2503
- // This is a LOSS REPORT, not a fix the content is unrecoverable by the time we are here. The
2504
- // fix is upstream, in not tearing a session down while an answer is still owed on it.
2897
+ // DOD-M12B-STRAND-1 THIS IS NO LONGER AN EPITAPH. Dropping the Map now drops a CACHE: the
2898
+ // frames are rows in `held_content`, and #restoreHeldContent brings them back the next time
2899
+ // this session gets a node. `session.content.held.discarded` is kept, at WARN rather than
2900
+ // ERROR, and only for what it now means — a gap is still open on a session going away, which
2901
+ // is worth an alarm even though nothing is lost.
2902
+ //
2903
+ // It fires ONLY when the durable rows are confirmed present. A hold whose persist failed is a
2904
+ // genuine loss and must not be reported with the same event as a survivor, so it gets its own
2905
+ // error naming the count that is actually gone. The check is a COUNT against the store rather
2906
+ // than a belief about the write that ran earlier.
2505
2907
  const strandedHolds = this.#heldContent.get(key);
2506
2908
  if (strandedHolds && strandedHolds.size > 0) {
2507
- this.#logger.error("session.content.held.discarded", {
2909
+ const canonicalSeqs = [...strandedHolds.keys()].sort((a, b) => a - b);
2910
+ // NULL means "we could not find out", and it must never render as "destroyed". The bare
2911
+ // catch this replaces coerced a failed COUNT to 0, which then claimed every held frame had
2912
+ // been lost — asserting a cause it had not established. It is not hypothetical:
2913
+ // #requireAgentId THROWS for a retired agent, on this exact path, so retiring an agent with
2914
+ // an open hold fabricated a data-loss alarm pointing at the persistence layer while the real
2915
+ // fault was name resolution.
2916
+ let durable = null;
2917
+ try {
2918
+ const row = this.#db?.prepare("SELECT COUNT(*) AS n FROM held_content WHERE agent_id = ? AND session_id = ?").get(this.#requireAgentId(agentName), sessionId);
2919
+ durable = row?.n ?? 0;
2920
+ }
2921
+ catch (err) {
2922
+ this.#logger.warn("session.content.held.durable_count.failed", {
2923
+ agentName, sessionId,
2924
+ impact: "cannot say whether the held frames are durable — reported as unknown, NOT as lost",
2925
+ error: err instanceof Error ? err.message : String(err),
2926
+ });
2927
+ }
2928
+ const lost = durable === null ? null : strandedHolds.size - durable;
2929
+ this.#logger.warn("session.content.held.discarded", {
2508
2930
  agentName,
2509
2931
  sessionId,
2510
2932
  count: strandedHolds.size,
2511
- canonicalSeqs: [...strandedHolds.keys()].sort((a, b) => a - b),
2933
+ durable,
2934
+ canonicalSeqs,
2512
2935
  // NULL when the tree was not cached at teardown. Honest, and cheap — reloading the leaf
2513
2936
  // table to fill in a diagnostic field is not worth a disk read on every teardown, let
2514
2937
  // alone the cache resurrection it caused.
2515
2938
  treeSize: treeSizeBeforeEviction,
2516
2939
  });
2940
+ if (lost !== null && lost > 0) {
2941
+ this.#logger.error("session.content.held.lost", {
2942
+ agentName,
2943
+ sessionId,
2944
+ lost,
2945
+ held: strandedHolds.size,
2946
+ impact: "verified content was NOT written to held_content and is destroyed by this teardown — the sender was never acknowledged and believes it is still pending",
2947
+ });
2948
+ }
2517
2949
  }
2518
2950
  this.#heldContent.delete(key);
2951
+ // DOD-M12B-STRAND-1: the hydration guard goes with the cache it guards. Without this, a session
2952
+ // torn down and given a node again inside ONE process would never re-read its durable holds —
2953
+ // the frames would sit in the table, unreleasable, which is indistinguishable from the loss
2954
+ // this unit exists to stop.
2955
+ this.#heldRestored.delete(key);
2956
+ this.#heldReleased.delete(key);
2957
+ this.#diverged.delete(key);
2958
+ this.#counterpartyAddrs.delete(key);
2959
+ this.#redialNotBefore.delete(key);
2519
2960
  this.#highWaterSeq.delete(key);
2520
2961
  }
2521
2962
  /**
@@ -2523,6 +2964,37 @@ export class SessionNodeManager {
2523
2964
  * Called from the SIGTERM / cello logout path (AC-009).
2524
2965
  * SQLite writes complete before this method returns.
2525
2966
  */
2967
+ /**
2968
+ * DOD-M12B-SHUTDOWN-1 — wait for a teardown step, but never forever.
2969
+ *
2970
+ * Every step of shutdown used to be an unbounded `await` on libp2p. That is what makes "the
2971
+ * daemon acknowledged the request but is still running" possible: nothing on the daemon side
2972
+ * emits a word while it hangs, so the operator's own message ("it may be stuck closing sessions
2973
+ * or its database") was a guess. Past the deadline the step is ABANDONED and SAID — the resources
2974
+ * it was closing are reclaimed by the OS on exit, and an exit is worth more than a tidy one.
2975
+ */
2976
+ async #boundedTeardown(work, step, count) {
2977
+ if (count === 0)
2978
+ return;
2979
+ const started = Date.now();
2980
+ let timer;
2981
+ const deadline = new Promise((resolve) => {
2982
+ timer = setTimeout(() => resolve("timeout"), SHUTDOWN_STEP_DEADLINE_MS);
2983
+ timer.unref?.();
2984
+ });
2985
+ const outcome = await Promise.race([work.then(() => "done"), deadline]);
2986
+ if (timer)
2987
+ clearTimeout(timer);
2988
+ if (outcome === "timeout") {
2989
+ this.#logger.error("session.shutdown.step.timeout", {
2990
+ step, count, waitedMs: Date.now() - started,
2991
+ impact: "this teardown step did not finish and was abandoned so the daemon can exit; the OS reclaims what it held",
2992
+ });
2993
+ }
2994
+ else {
2995
+ this.#logger.debug("session.shutdown.step.done", { step, count, tookMs: Date.now() - started });
2996
+ }
2997
+ }
2526
2998
  async gracefulShutdown() {
2527
2999
  // DOD-NAT-REACHABILITY-1: stop the reservation watchdog before anything is torn
2528
3000
  // down — a tick landing mid-shutdown would try to rebuild a receiver we are in
@@ -2533,6 +3005,11 @@ export class SessionNodeManager {
2533
3005
  }
2534
3006
  // Signal any in-flight standing-receiver replacement to self-stop (review M2).
2535
3007
  this.#shuttingDown = true;
3008
+ // DOD-M12B-ACK-1: drop the inbound-content linger resets. The nodes they would reset are being
3009
+ // torn down here anyway, so firing after this point is pure noise on the way out.
3010
+ for (const timer of this.#lingeringStreams)
3011
+ clearTimeout(timer);
3012
+ this.#lingeringStreams.clear();
2536
3013
  // Cancel every armed awaiting-ACK timer so an un-acked send (e.g. a rejected /
2537
3014
  // tampered frame that never produced a `persisted` ACK) does not leave a 20s
2538
3015
  // timer pinning the content + this manager in memory past teardown (review M1).
@@ -2555,7 +3032,9 @@ export class SessionNodeManager {
2555
3032
  else {
2556
3033
  const interruptedAt = new Date(now).toISOString();
2557
3034
  try {
2558
- this.#db.prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE status = 'active'").run(now, interruptedAt);
3035
+ this.#db.prepare(
3036
+ // DOD-CAP-SELF-HEAL-1: OURS. Our own shutdown ended these, not the counterparty.
3037
+ "UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?), interrupted_by = 'local' WHERE status = 'active'").run(now, interruptedAt);
2559
3038
  }
2560
3039
  catch (err) {
2561
3040
  this.#logger.error("session.interrupt.db.write.failed", {
@@ -2587,14 +3066,22 @@ export class SessionNodeManager {
2587
3066
  });
2588
3067
  }));
2589
3068
  }
2590
- await Promise.all(stopPromises);
3069
+ // DOD-M12B-SHUTDOWN-1: BOUNDED. `node.stop()` awaits libp2p's own teardown, which has no
3070
+ // deadline of its own — one connection that will not close holds this, and this holds the whole
3071
+ // daemon. Measured 2026-08-17: `cello logout` acknowledged, then the process was still alive
3072
+ // 30+ seconds later and needed a signal. An abandoned stop costs a socket the OS reclaims when
3073
+ // we exit; an unbounded wait costs the exit itself.
3074
+ await this.#boundedTeardown(Promise.all(stopPromises), "session_nodes", stopPromises.length);
2591
3075
  this.#activeNodes.clear();
2592
3076
  // Evict in-memory per-session caches (trees reload from SQLite; received-content
2593
3077
  // plaintext must not survive shutdown in memory).
2594
3078
  this.#trees.clear();
2595
3079
  this.#receivedContent.clear();
2596
- // Stop ALL per-agent standing receivers (DOD-LOOP-1).
2597
- for (const [agentName, sr] of this.#standingReceivers) {
3080
+ // Stop ALL per-agent standing receivers (DOD-LOOP-1). In PARALLEL and BOUNDED: this was a
3081
+ // sequential await per agent with no deadline, so five agents meant five chances for one stuck
3082
+ // libp2p teardown to hold the exit — and it sits between the operator being told the daemon is
3083
+ // stopping and the process actually going.
3084
+ await this.#boundedTeardown(Promise.all([...this.#standingReceivers].map(async ([agentName, sr]) => {
2598
3085
  sr.autoNat.stop();
2599
3086
  try {
2600
3087
  await sr.node.stop();
@@ -2607,7 +3094,7 @@ export class SessionNodeManager {
2607
3094
  correlationId: "n/a",
2608
3095
  });
2609
3096
  }
2610
- }
3097
+ })), "standing_receivers", this.#standingReceivers.size);
2611
3098
  this.#standingReceivers.clear();
2612
3099
  // Release the SQLite handle so the DB file is no longer held open after shutdown
2613
3100
  // (review L5). Queries guard on `#db === null` and degrade to empty/null.
@@ -2795,7 +3282,21 @@ export class SessionNodeManager {
2795
3282
  // the pre-check above raced (it cannot — DatabaseSync is synchronous), the
2796
3283
  // UPDATE only mutates a row that is still active.
2797
3284
  this.#db
2798
- .prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ? WHERE agent_id = ? AND session_id = ? AND status = 'active'")
3285
+ .prepare(
3286
+ // DOD-CAP-SELF-HEAL-1: labelled by SOURCE, because the two are not the same event.
3287
+ //
3288
+ // relay_frame — the relay telling us the counterparty went. THEIRS. The D18
3289
+ // disconnect-evasion move, and it must keep counting.
3290
+ // stream_close — OUR witness stream to the relay ended. That fires on a relay restart,
3291
+ // a relay fleet roll, or a local network blip. Claiming the counterparty
3292
+ // did it means three relay deploys permanently refuse a peer who was
3293
+ // never involved — and relay deploys are routine, so it ratchets faster
3294
+ // than daemon restarts do.
3295
+ //
3296
+ // `relay_stream_close` is its own label and STILL COUNTS (the bound excuses only 'local'),
3297
+ // because an attacker who can disturb our relay link must not get a free cap reset. It is
3298
+ // recorded honestly rather than blamed on the wrong party.
3299
+ `UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ?, interrupted_by = '${source === "relay_frame" ? "counterparty" : "relay_stream_close"}' WHERE agent_id = ? AND session_id = ? AND status = 'active'`)
2799
3300
  .run(now, authoritativeCount, interruptedAt, this.#requireAgentId(agentName), sessionId);
2800
3301
  }
2801
3302
  catch (err) {
@@ -3174,6 +3675,9 @@ export class SessionNodeManager {
3174
3675
  for (const addr of addrs) {
3175
3676
  try {
3176
3677
  await entry.node.dial(addr);
3678
+ // DOD-M12B-REDIAL-1: keep them. They arrived in the signed assignment and were used once
3679
+ // and dropped, which is the reason nothing could ever dial this counterparty again.
3680
+ this.#counterpartyAddrs.set(this.#k(agentName, sessionId), [...addrs]);
3177
3681
  this.#logger.info("session.transport.connected", {
3178
3682
  sessionId,
3179
3683
  addr,
@@ -3246,6 +3750,8 @@ export class SessionNodeManager {
3246
3750
  // the leaf_deliver witness stream / arrival order.
3247
3751
  let orderingS1;
3248
3752
  let orderingS2;
3753
+ // DOD-M12B-INDEX-1: the relay's answer to "where does this message go", carried to the caller.
3754
+ let assignedSeq;
3249
3755
  // DOD-MP-SESSION-RETIRE-1 — the relay's answer SURVIVES to the caller even when the direct send
3250
3756
  // then succeeds. `relay_session_gone` is deliberately not terminal (it also fires for perfectly
3251
3757
  // live sessions whenever the relay restarts, because the relay stores sessions in memory), so
@@ -3263,9 +3769,20 @@ export class SessionNodeManager {
3263
3769
  if (witnessed.ok) {
3264
3770
  orderingS1 = witnessed.structure1_cbor;
3265
3771
  orderingS2 = witnessed.structure2_cbor;
3772
+ // 1-BASED → 0-BASED. The relay numbers the first leaf of a session 1
3773
+ // (`relay-node.ts`: `const seq = state.seq_counter + 1`), and this tree is 0-indexed.
3774
+ // Every RECEIVE path in this file normalises with -1 and says so; the send path took the
3775
+ // raw number, which puts every comparison against `tree.size()` one position out — so a
3776
+ // perfectly healthy first message reads as "ahead of the tail" and is held behind a gap
3777
+ // that does not exist. Do not remove this without changing both receive sites too.
3778
+ assignedSeq = witnessed.sequence_number - 1;
3266
3779
  this.#logger.info("session.relay.hash.submitted", {
3267
3780
  sessionId,
3268
- sequenceNumber: witnessed.sequence_number,
3781
+ // BOTH SPACES, NAMED. The relay's number is 1-based and the leaf index is 0-based, and
3782
+ // reading one as the other is the defect this milestone exists to stop — so a log that
3783
+ // carries only "sequenceNumber" invites exactly that mistake on the next investigation.
3784
+ relaySequence: witnessed.sequence_number,
3785
+ leafIndex: assignedSeq,
3269
3786
  correlationId,
3270
3787
  });
3271
3788
  }
@@ -3353,8 +3870,14 @@ export class SessionNodeManager {
3353
3870
  // resolves the awaiting timer; on failure (counterparty offline) the hash is already
3354
3871
  // witnessed above, so the caller / TTF path parks the SEALED content to the relay
3355
3872
  // store-and-forward backstop and the recipient recovers it at the witnessed sequence (2b).
3873
+ // Held outside the try so the catch can retire a stream that was opened and then failed to
3874
+ // write. Without it every failure leaks the OUTBOUND half of the stream the receiver-side
3875
+ // `finally` retires — same defect, other end, other cap (64 outbound per protocol per
3876
+ // connection). See the note on #handleContentStream's finally.
3877
+ let sendStream;
3356
3878
  try {
3357
- const stream = await entry.node.newStream(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
3879
+ const stream = await this.#openContentStream(agentName, sessionId, entry, correlationId);
3880
+ sendStream = stream;
3358
3881
  // AC-001/AC-003: arm the TTF tracking BEFORE the frame goes on the wire. The
3359
3882
  // receiver's `persisted` ACK can come back fast (in-process / low-latency
3360
3883
  // transports), so registering the awaiting entry after send would let the ACK
@@ -3399,9 +3922,17 @@ export class SessionNodeManager {
3399
3922
  // A close that failed for a benign reason costs a redundant park, which the receiver dedups
3400
3923
  // on the content hash. A false delivered costs the message.
3401
3924
  await stream.close();
3402
- return { ok: true, delivered: true, ...(relayRefusal === undefined ? {} : { relayRefusal }) };
3925
+ this.#clearSessionImpairment(agentName, sessionId, "direct_send", correlationId);
3926
+ return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
3403
3927
  }
3404
3928
  catch (err) {
3929
+ this.#markSessionImpaired(agentName, sessionId, { cause: "direct_send", error: err instanceof Error ? err.message : String(err), correlationId });
3930
+ if (sendStream !== undefined) {
3931
+ try {
3932
+ sendStream.abort(err instanceof Error ? err : new Error(String(err)));
3933
+ }
3934
+ catch { /* already gone */ }
3935
+ }
3405
3936
  // The send failed after (possibly) arming the awaiting tracking — drop it so a
3406
3937
  // never-delivered frame does not later fire a spurious TTF park.
3407
3938
  this.#untrackAwaitingAck(agentName, sessionId, contentHash);
@@ -3412,9 +3943,31 @@ export class SessionNodeManager {
3412
3943
  // can be reported as "dispatched to relay" instead of a raw stream failure — the operator/
3413
3944
  // agent sees the truth (the message IS in flight, just not direct), not a false negative.
3414
3945
  const hashHex = Buffer.from(contentHash).toString("hex");
3946
+ // NAME THE CAUSE. This catch used to discard `err` outright, so a park reported only its exit
3947
+ // point — "dispatched to relay" — and never what went wrong. Measured 2026-08-17: 212 parks on
3948
+ // one daemon, not one of them recording a reason, which is what made a one-way session look
3949
+ // like a protocol mystery for a night.
3950
+ //
3951
+ // `counterpartySessionPeerId` is the load-bearing field. It is recorded ONCE at session
3952
+ // establishment and never refreshed, while a standing receiver is rebuilt with a fresh keypair
3953
+ // on every signaling reconnect — so if the two ever cross, every send goes one-way forever and
3954
+ // nothing says so. With this line that becomes a single grep instead of a night.
3955
+ this.#logger.warn("session.content.direct.send.failed", {
3956
+ agentName,
3957
+ sessionId,
3958
+ contentHash: hashHex,
3959
+ counterpartySessionPeerId: entry.counterpartySessionPeerId,
3960
+ error: err instanceof Error ? err.message : String(err),
3961
+ // "Cannot write to a stream that is closed" names where the write died, never why. The
3962
+ // why is almost always the per-protocol stream cap, and these two numbers are what turn
3963
+ // that from a log-measurement session into a grep.
3964
+ ...this.#streamCensus(entry.node, entry.counterpartySessionPeerId),
3965
+ correlationId,
3966
+ });
3415
3967
  const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2);
3416
3968
  if (attempt.outcome === "parked") {
3417
- return { ok: true, delivered: false, parked: true, ...(relayRefusal === undefined ? {} : { relayRefusal }) };
3969
+ this.#noteImpairmentRetention(agentName, sessionId, "parked");
3970
+ return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
3418
3971
  }
3419
3972
  // M12-P12: the deposit was refused, and #untrackAwaitingAck above already dropped the
3420
3973
  // in-memory entry — so without this, NOTHING holds the content and the TTF timer that would
@@ -3491,10 +4044,18 @@ export class SessionNodeManager {
3491
4044
  // F3: the two failures are NOT interchangeable to the caller. `reason` is a contract string
3492
4045
  // and stays put; `guidance` carries the difference, because "we are retrying this" and "this
3493
4046
  // message is gone, send it again" demand opposite actions from the operator.
4047
+ //
4048
+ // The same distinction is recorded on the session, because `cello_receive` will be asked
4049
+ // about this later and would otherwise have to guess — and its guess ("it was parked, do not
4050
+ // resend") is the exact opposite of what the lost case needs.
4051
+ this.#noteImpairmentRetention(agentName, sessionId, durable ? "durable" : "lost");
3494
4052
  return {
3495
4053
  ok: false,
3496
4054
  reason: "session_stream_unavailable",
3497
4055
  error: errMsg,
4056
+ // Carried on the failure path too: a DURABLY QUEUED message still owns the position the
4057
+ // relay witnessed for it before delivery was attempted, and its leaf must go there.
4058
+ ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }),
3498
4059
  // M12-P13: the machine-readable half of the distinction below. M12-P12 shipped it in the
3499
4060
  // guidance SENTENCE only, so the callers that have to ACT on it — commit the leaf for a
3500
4061
  // queued message, never for a lost one — would have had to substring-match English. None
@@ -4129,6 +4690,7 @@ export class SessionNodeManager {
4129
4690
  : this.#witnessedSeq.get(key)?.get(contentHashHex);
4130
4691
  const nextExpected = this.getSessionTree(agentName, sessionId).size();
4131
4692
  if (canonicalSeq !== undefined && canonicalSeq > nextExpected) {
4693
+ this.#ensureHeldRestored(agentName, sessionId);
4132
4694
  let held = this.#heldContent.get(key);
4133
4695
  if (!held) {
4134
4696
  held = new Map();
@@ -4137,7 +4699,15 @@ export class SessionNodeManager {
4137
4699
  // A terminal block out of canonical order is held WITHOUT delivery (screenedOut): #releaseHeld
4138
4700
  // leafs it at its canonical index when the gap fills, but never buffers it for the agent. This
4139
4701
  // keeps leafIndex === canonicalSeq for screened-out content too (code-review HIGH-1).
4140
- held.set(canonicalSeq, { content: deliverContent, contentHashHex, correlationId, ...(terminalBlock ? { screenedOut: true } : {}) });
4702
+ // THE PEER'S RAW BYTES RIDE ALONG. Classification (document frame vs conversation) reads
4703
+ // byte 0, and `deliverContent` is the SCREENED copy — for a CBOR frame that is no longer a
4704
+ // map header, so a held document frame was released into the CONVERSATION path: transcript,
4705
+ // doorbell, and `cello_receive` handing an agent raw CBOR as though a person typed it.
4706
+ // The in-order path has always passed these bytes; only the held path dropped them.
4707
+ held.set(canonicalSeq, { content: deliverContent, originalContent: content, contentHashHex, correlationId, ...(terminalBlock ? { screenedOut: true } : {}) });
4708
+ // DOD-M12B-STRAND-1: and to disk, before we answer. The in-memory Map is the working copy;
4709
+ // this row is the one that survives the teardown that used to destroy it.
4710
+ this.#persistHeldContent(agentName, sessionId, canonicalSeq, deliverContent, content, contentHashHex, terminalBlock === true, correlationId);
4141
4711
  this.#logger.info("session.content.held", {
4142
4712
  sessionId,
4143
4713
  canonicalSeq,
@@ -4230,6 +4800,9 @@ export class SessionNodeManager {
4230
4800
  if (sequenceNumber < 0)
4231
4801
  return;
4232
4802
  const key = this.#k(agentName, sessionId);
4803
+ // DOD-M12B-SEAL-STUCK-1: this process has now seen this session's ordering state, so an empty
4804
+ // witness map for it means "no gap" rather than "never looked".
4805
+ this.#orderingObserved.add(key);
4233
4806
  let map = this.#witnessedSeq.get(key);
4234
4807
  if (!map) {
4235
4808
  map = new Map();
@@ -4286,6 +4859,16 @@ export class SessionNodeManager {
4286
4859
  */
4287
4860
  sealReadiness(agentName, sessionId) {
4288
4861
  const key = this.#k(agentName, sessionId);
4862
+ // DOD-M12B-STRAND-1: hydrate first. An under-counted `heldCount` reports a gapped session as
4863
+ // READY, and this gate's whole purpose is to stop a short chain being signed — the counterparty
4864
+ // answers `leaf_count_mismatch`, which is TERMINAL and costs the receipt permanently. Failing
4865
+ // open here is the one outcome worse than refusing a healthy close.
4866
+ //
4867
+ // Hydrate WITHOUT releasing: this is a read path now — the status surface asks it for every
4868
+ // active session — and a read that appends leaves, advances the root and rings the doorbell is
4869
+ // a diagnostic command that delivers messages. The release still runs on every path that was
4870
+ // going to mutate anyway.
4871
+ this.#ensureHeldRestored(agentName, sessionId, { release: false });
4289
4872
  const treeSize = this.getSessionTree(agentName, sessionId).size();
4290
4873
  const highWaterSeq = this.#highWaterSeq.get(key) ?? -1;
4291
4874
  const heldCount = this.#heldContent.get(key)?.size ?? 0;
@@ -4305,7 +4888,107 @@ export class SessionNodeManager {
4305
4888
  // ordering authority has committed and this tree has not — no arithmetic, no space mismatch,
4306
4889
  // and it cannot go negative.
4307
4890
  const missingLeaves = this.#witnessedSeq.get(key)?.size ?? 0;
4308
- return { ready: missingLeaves === 0 && heldCount === 0, treeSize, highWaterSeq, heldCount, missingLeaves };
4891
+ // DOD-M12B-INDEX-1: our OWN held sends are counted separately. They block a seal just as a
4892
+ // received hold does, but they are not "a message from the counterparty that has not arrived" —
4893
+ // and a refusal that calls them that tells the operator to wait for something already in hand,
4894
+ // which is how a close-retry loop ends at force-abandon and no receipt.
4895
+ let heldOwn = 0;
4896
+ for (const e of this.#heldContent.get(key)?.values() ?? [])
4897
+ if (e.origin === "sent")
4898
+ heldOwn++;
4899
+ return {
4900
+ ready: missingLeaves === 0 && heldCount === 0,
4901
+ treeSize, highWaterSeq, heldCount, missingLeaves,
4902
+ heldOwn, heldReceived: heldCount - heldOwn,
4903
+ };
4904
+ }
4905
+ /**
4906
+ * DOD-M12B-SEAL-STUCK-1 — the operator-facing answer to "can this session be closed?".
4907
+ *
4908
+ * THREE STATES, because there are three answers. `sealReadiness` above returns a boolean plus raw
4909
+ * counters, and both of its counters are easy to misread on a surface:
4910
+ *
4911
+ * - `missingLeaves` is `#witnessedSeq.size`, which is every position the relay witnessed that
4912
+ * this tree has not appended — and a HELD frame keeps its witness entry. So it INCLUDES the
4913
+ * held ones. Reporting it beside `heldCount` counts the same message twice and labels one copy
4914
+ * "never received" when it is sitting on our own disk. Split here into what each actually is.
4915
+ * - Neither counter survives a restart on its own: `#witnessedSeq` is memory-only. Held content
4916
+ * is durable since DOD-M12B-STRAND-1, but a position the relay witnessed for content that
4917
+ * never arrived leaves no trace. So for a session carrying leaves this process did not watch
4918
+ * arrive, "clean" is unknowable — and saying `ready` there invites a close that gets
4919
+ * `leaf_count_mismatch` back, which is terminal and costs the receipt for good.
4920
+ */
4921
+ sealReadinessView(agentName, sessionId) {
4922
+ const key = this.#k(agentName, sessionId);
4923
+ const r = this.sealReadiness(agentName, sessionId);
4924
+ if (!r.ready) {
4925
+ const oldestHeldMs = this.#oldestHeldMs(agentName, sessionId);
4926
+ return {
4927
+ state: "blocked",
4928
+ // The witness map counts a held frame until it is appended, so subtract the RECEIVED holds
4929
+ // to avoid reporting one message twice. NOT the own-sends: the witness map only ever
4930
+ // carries counterparty leaves, so subtracting ours would push this count below the truth.
4931
+ awaitingArrival: Math.max(0, r.missingLeaves - r.heldReceived),
4932
+ heldBehindGap: r.heldCount,
4933
+ oldestHeldMs,
4934
+ };
4935
+ }
4936
+ if (this.#diverged.has(key)) {
4937
+ // NOT `ready`. The tree is ahead of the relay's counter for good, so a close here signs a root
4938
+ // the counterparty answers `leaf_count_mismatch` to — terminal, and the receipt is gone. The
4939
+ // raw counters cannot see this: nothing is missing and nothing is held.
4940
+ return { state: "unknown", reason: "record_diverged_from_relay" };
4941
+ }
4942
+ if (r.treeSize > 0 && !this.#orderingObserved.has(key)) {
4943
+ return {
4944
+ state: "unknown",
4945
+ reason: "witness_state_predates_daemon_start",
4946
+ };
4947
+ }
4948
+ return { state: "ready" };
4949
+ }
4950
+ /** DOD-M12B-INDEX-1 — this agent's own K_local pubkey, for attributing its own held content.
4951
+ * Null when it cannot be resolved: an UNATTRIBUTED annex row is true, a falsely attributed one
4952
+ * is not, and this is the record that outlives the session. */
4953
+ #ownPubkeyHex(agentName) {
4954
+ if (!this.#db)
4955
+ return null;
4956
+ try {
4957
+ // BY agent_id, never by agent_name. The name is a mutable, reuse-freed display label, and
4958
+ // scoping on it hands one identity's rows to another keypair (DOD-AGENT-ID-JOINKEY-1).
4959
+ const row = this.#db
4960
+ .prepare("SELECT k_local_pubkey FROM agents WHERE agent_id = ?")
4961
+ .get(this.#requireAgentId(agentName));
4962
+ return row?.k_local_pubkey ?? null;
4963
+ }
4964
+ catch (err) {
4965
+ // An unattributed annex row is truthful; an unattributed row nobody knows about is not. This
4966
+ // throws for a retired agent, and without a line here EVERY own held message would land in
4967
+ // the record that outlives the session with no sender and no explanation.
4968
+ this.#logger.warn("session.own_pubkey.unresolved", {
4969
+ agentName,
4970
+ error: err instanceof Error ? err.message : String(err),
4971
+ impact: "this agent's own held content will be annexed without a sender",
4972
+ });
4973
+ return null;
4974
+ }
4975
+ }
4976
+ /** DOD-M12B-SEAL-STUCK-1 — how long the oldest held frame for this session has been waiting, or
4977
+ * null when nothing is held. This is what separates "stuck since this morning" from "in flight
4978
+ * 40 ms ago", and without it a healthy mid-conversation window reads as a stranded session. */
4979
+ #oldestHeldMs(agentName, sessionId) {
4980
+ if (!this.#db)
4981
+ return null;
4982
+ try {
4983
+ const row = this.#db.prepare("SELECT MIN(held_at) AS oldest FROM held_content WHERE agent_id = ? AND session_id = ?").get(this.#requireAgentId(agentName), sessionId);
4984
+ if (!row || row.oldest === null)
4985
+ return null;
4986
+ return Date.now() - row.oldest;
4987
+ }
4988
+ catch {
4989
+ // A diagnostic detail must not be able to break the surface it decorates.
4990
+ return null;
4991
+ }
4309
4992
  }
4310
4993
  /** DOD-MSG-4 / DAEMON-004: append a verified message leaf and buffer it for cello_receive. */
4311
4994
  #appendVerifiedContent(agentName, sessionId, content, contentHashHex, senderPubkey, correlationId,
@@ -4429,6 +5112,520 @@ export class SessionNodeManager {
4429
5112
  }
4430
5113
  return { leafIndex };
4431
5114
  }
5115
+ /**
5116
+ * DOD-M12B-ABANDON-NOTIFY-1 — tell the counterparty we have hung up. Best effort, never blocking.
5117
+ *
5118
+ * A force-abandon marks the session terminal HERE and did nothing else, so the other side kept
5119
+ * its half live, kept retrying delivery into it, and kept trying to re-establish — forever,
5120
+ * because nothing would ever answer. That is what produced the 2026-08-17 notification storm:
5121
+ * surviving halves calling continuously while the operator saw connection requests from agents
5122
+ * nobody was driving.
5123
+ *
5124
+ * BEST EFFORT, and every caller must treat it that way. A peer that is offline cannot be told, so
5125
+ * this is an improvement on silence rather than a guarantee — and it must never delay or fail the
5126
+ * abandon, which is the operator's escape hatch out of a session that can never seal.
5127
+ */
5128
+ async notifyCounterpartyAbandon(agentName, sessionId, correlationId) {
5129
+ const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
5130
+ if (!entry) {
5131
+ // NAMES ITS CAUSE, and it is not the network. An `interrupted` session has no node — the
5132
+ // restart sweep and markInterrupted both tear it down — and `interrupted` is exactly the
5133
+ // status force-abandon exists for. Reporting this as "could not be reached" sends the
5134
+ // operator to debug a connection when the answer is in our own process. At INFO, not debug,
5135
+ // because it is the common case and it changes what the operator is told.
5136
+ this.#logger.info("session.abandon.notice.skipped", {
5137
+ agentName, sessionId, reason: "no_local_node", correlationId,
5138
+ impact: "this side had already torn the session down, so there was nothing to send on — the counterparty was not told",
5139
+ });
5140
+ return { told: false, reason: "no_local_node" };
5141
+ }
5142
+ let stream;
5143
+ try {
5144
+ // Through the RE-DIAL path, not a bare newStream. A session worth force-abandoning is very
5145
+ // often one whose connection blipped — the peer is online and calling us, which is the whole
5146
+ // complaint — so one demand-driven dial is the difference between telling them and not.
5147
+ stream = await this.#openContentStream(agentName, sessionId, entry, correlationId);
5148
+ // Typed against protocol-types so the shape cannot drift from the declaration the receiving
5149
+ // side (and any second client implementation) reads.
5150
+ const notice = {
5151
+ type: "session_abandoned_notice",
5152
+ session_id: sessionId,
5153
+ ...(correlationId === undefined ? {} : { correlation_id: correlationId }),
5154
+ };
5155
+ const frame = encodeCbor(notice);
5156
+ stream.send(lp.encode.single(frame));
5157
+ await stream.close();
5158
+ this.#logger.info("session.abandon.notice.sent", { agentName, sessionId, correlationId });
5159
+ return { told: true, reason: "sent" };
5160
+ }
5161
+ catch (err) {
5162
+ if (stream !== undefined) {
5163
+ try {
5164
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
5165
+ }
5166
+ catch { /* already gone */ }
5167
+ }
5168
+ this.#logger.warn("session.abandon.notice.failed", {
5169
+ agentName, sessionId, correlationId,
5170
+ error: err instanceof Error ? err.message : String(err),
5171
+ impact: "the counterparty was not told and may keep calling until it gives up",
5172
+ });
5173
+ return { told: false, reason: "send_failed" };
5174
+ }
5175
+ }
5176
+ /**
5177
+ * DOD-M12B-ABANDON-NOTIFY-1 — the receiving half: our counterparty has abandoned, so retire.
5178
+ *
5179
+ * RETIRING IS NOT DELETING. The counterparty walking away forfeits the notarized receipt; it must
5180
+ * not also cost the operator the record of what was actually said. The transcript and the tree
5181
+ * stay exactly as they are.
5182
+ *
5183
+ * Only an `active` or `interrupted` session moves. A SEALED session has a notarized receipt and
5184
+ * must never be turned into an abandoned one by a late or duplicated notice — that would destroy
5185
+ * the artifact this protocol exists to produce. An unknown session is refused rather than
5186
+ * created: an authenticated stream proves who is speaking, not that a session exists.
5187
+ */
5188
+ async retireOnCounterpartyAbandon(agentName, sessionId, correlationId) {
5189
+ const record = this.getSessionRecord(agentName, sessionId);
5190
+ if (!record) {
5191
+ this.#logger.warn("session.abandon.notice.unknown_session", { agentName, sessionId, correlationId });
5192
+ return false;
5193
+ }
5194
+ if (record.status !== "active" && record.status !== "interrupted") {
5195
+ this.#logger.debug("session.abandon.notice.ignored", {
5196
+ agentName, sessionId, status: record.status, correlationId,
5197
+ reason: "session already terminal",
5198
+ });
5199
+ return false;
5200
+ }
5201
+ // THE TRANSPORT IS RETIRED. THE SESSION IS NOT.
5202
+ //
5203
+ // The first build flipped the status to `abandoned`, and that was wrong twice over. It handed
5204
+ // the abandoning party a button that DENIES US OUR RECEIPT: the unilateral seal exists for
5205
+ // exactly this case — "the counterparty never co-closes" — and produces a notarized certificate
5206
+ // after a grace period, but `cello_close_session` refuses an `abandoned` session outright. So
5207
+ // one frame from them destroyed a recovery path that already existed, remotely and for free.
5208
+ // Today the abandoner can only go silent, and going silent is what the unilateral seal was
5209
+ // built to survive.
5210
+ //
5211
+ // What the DoD actually asks for is that we stop calling them. That is a transport concern:
5212
+ // mark it, stop re-dialling, stop retrying delivery — and leave the session sealable.
5213
+ const marked = this.#markCounterpartyAbandoned(agentName, sessionId);
5214
+ if (!marked)
5215
+ return false;
5216
+ // The addresses go, so the demand-driven re-dial has nothing to dial. This is the storm.
5217
+ const key = this.#k(agentName, sessionId);
5218
+ this.#counterpartyAddrs.delete(key);
5219
+ this.#logger.warn("session.counterparty.abandoned", {
5220
+ agentName, sessionId, priorStatus: record.status, correlationId,
5221
+ 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",
5222
+ });
5223
+ // AWAITED, and `retireSessionNode` NOT `destroySessionNode`. The latter writes the status back
5224
+ // — `error` maps to `interrupted` — a few hundred milliseconds later, which silently undid the
5225
+ // whole unit; the former is the method that tears a node down without touching the status, and
5226
+ // it is what the local force-abandon path already uses.
5227
+ await this.retireSessionNode(agentName, sessionId);
5228
+ return true;
5229
+ }
5230
+ /** DOD-M12B-ABANDON-NOTIFY-1 — durable "they hung up" marker. Not a status: the session stays
5231
+ * sealable, which is the whole point of not making this terminal. */
5232
+ #markCounterpartyAbandoned(agentName, sessionId) {
5233
+ if (!this.#db)
5234
+ return false;
5235
+ try {
5236
+ const res = this.#db
5237
+ .prepare("UPDATE sessions SET counterparty_abandoned_at = ?, updated_at = ? WHERE agent_id = ? AND session_id = ? AND counterparty_abandoned_at IS NULL")
5238
+ .run(Date.now(), Date.now(), this.#requireAgentId(agentName), sessionId);
5239
+ // No rows changed means it was already marked — a duplicated notice, which must not
5240
+ // re-announce. "Did not throw" is not "landed"; the row count is the answer.
5241
+ return Number(res?.changes ?? 0) > 0;
5242
+ }
5243
+ catch (err) {
5244
+ this.#logger.error("session.counterparty.abandoned.write.failed", {
5245
+ agentName, sessionId,
5246
+ error: err instanceof Error ? err.message : String(err),
5247
+ impact: "this side will go on trying to reach a counterparty that has hung up",
5248
+ });
5249
+ return false;
5250
+ }
5251
+ }
5252
+ /**
5253
+ * DOD-CAP-SELF-HEAL-1 — the numbers behind a cap refusal, for the OPERATOR'S alarm only.
5254
+ *
5255
+ * Kept off `checkUnknownSenderAcceptanceBound`'s return on purpose. That refusal is byte-identical
5256
+ * across tiers by design (DOD-TIER-3) so a blocked party cannot tell blocking from throttling;
5257
+ * attaching the counts to it would put the oracle straight into the value the refusal path
5258
+ * carries. This is a separate, purely local read, and nothing it returns crosses the wire.
5259
+ */
5260
+ capDiagnostics(agentName, counterpartyPubkey) {
5261
+ const tier = this.getTier(agentName, counterpartyPubkey);
5262
+ const cap = this.resolveTierBound(agentName, tier, "max_sessions");
5263
+ const counted = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
5264
+ return {
5265
+ tier, cap, counted,
5266
+ // How many to close to get UNDER the cap — not how many exist. At 5 against a cap of 3 the
5267
+ // answer is 3, and "close 5" tells the operator to do more than the job needs.
5268
+ mustClear: Math.max(0, counted - cap + 1),
5269
+ blocked: tier === TIER.BLOCKED,
5270
+ };
5271
+ }
5272
+ /** DOD-CAP-SELF-HEAL-1: the sessions with this counterparty that are consuming cap slots, oldest
5273
+ * first. The operator is told to close some — this is WHICH, because "close three of them" with
5274
+ * no list is not an instruction they can follow. */
5275
+ sessionsConsumingCap(agentName, counterpartyPubkey, limit = 10) {
5276
+ if (!this.#db)
5277
+ return [];
5278
+ try {
5279
+ const rows = this.#db.prepare(`SELECT session_id FROM sessions WHERE agent_id = ? AND counterparty_pubkey = ? AND ${CAP_COUNTS()}
5280
+ ORDER BY updated_at ASC LIMIT ?`).all(this.#requireAgentId(agentName), counterpartyPubkey, limit);
5281
+ return rows.map((r) => r.session_id);
5282
+ }
5283
+ catch {
5284
+ return [];
5285
+ }
5286
+ }
5287
+ /** DOD-M12B-ABANDON-NOTIFY-1: has the counterparty told us they hung up? */
5288
+ counterpartyAbandonedAt(agentName, sessionId) {
5289
+ if (!this.#db)
5290
+ return null;
5291
+ try {
5292
+ const row = this.#db
5293
+ .prepare("SELECT counterparty_abandoned_at FROM sessions WHERE agent_id = ? AND session_id = ?")
5294
+ .get(this.#requireAgentId(agentName), sessionId);
5295
+ return row?.counterparty_abandoned_at ?? null;
5296
+ }
5297
+ catch {
5298
+ return null;
5299
+ }
5300
+ }
5301
+ /**
5302
+ * DOD-M12B-REDIAL-1 — open a content stream, re-dialling once if the connection has gone.
5303
+ *
5304
+ * `newStream` never dials. It looks for an already-open connection filed under the recorded peer
5305
+ * id and throws `connection_lost` when there is none — and NOTHING re-dialled: not on
5306
+ * `session.liveness.changed → gone`, not on signaling reconnect, not on agent offline→online, not
5307
+ * in the drain hook. So one blip and that session parked EVERY message for the rest of its life,
5308
+ * on both sides, permanently. The relay backstop kept the messages moving, which is exactly why
5309
+ * it hid: nothing was lost, the conversation just stopped being a conversation.
5310
+ *
5311
+ * DEMAND-DRIVEN, never a timer. A background re-dial loop is what produced the 2026-08-17
5312
+ * notification storm — surviving halves of abandoned sessions dialling continuously while the
5313
+ * operator saw connection requests from agents nobody was driving. This fires only when a send
5314
+ * actually needs the connection, and a cooldown bounds a burst against a peer that is genuinely
5315
+ * gone: five sends cost one dial, not five.
5316
+ */
5317
+ async #openContentStream(agentName, sessionId, entry, correlationId) {
5318
+ const attempt = async () => {
5319
+ if (this.#connectionLossRemaining > 0) {
5320
+ this.#connectionLossRemaining -= 1;
5321
+ throw { reason: "no_connection", message: "injected connection loss" };
5322
+ }
5323
+ return entry.node.newStream(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
5324
+ };
5325
+ try {
5326
+ return await attempt();
5327
+ }
5328
+ catch (err) {
5329
+ const reason = err?.reason;
5330
+ // ONLY for a missing connection, and `no_connection` is the reason that means exactly that.
5331
+ // NOT `connection_lost`, which is the transport's catch-all default and therefore also covers
5332
+ // a stream that failed on a healthy connection — the per-protocol stream cap of
5333
+ // DOD-M12B-ACK-1. Dialling there fixes nothing and shows the counterparty a connection
5334
+ // request caused by a defect on this side, which is the storm this unit exists to avoid.
5335
+ if (reason !== "no_connection")
5336
+ throw err;
5337
+ const key = this.#k(agentName, sessionId);
5338
+ const addrs = this.#counterpartyAddrs.get(key);
5339
+ if (!addrs || addrs.length === 0) {
5340
+ // ABSENT IS NOT FINE, and it is not silent. A session we never dialled — the responder's
5341
+ // half — has no addresses to dial back with, and that is a real limitation the operator
5342
+ // should be able to see rather than infer from a park.
5343
+ this.#logger.warn("session.transport.redial.unavailable", {
5344
+ sessionId, agentName, correlationId,
5345
+ impact: "the direct path is down and this side holds no address for the counterparty, so every send parks until they re-establish",
5346
+ });
5347
+ throw err;
5348
+ }
5349
+ const now = Date.now();
5350
+ const notBefore = this.#redialNotBefore.get(key) ?? 0;
5351
+ if (now < notBefore) {
5352
+ this.#logger.debug("session.transport.redial.cooldown", {
5353
+ sessionId, agentName, retryInMs: notBefore - now, correlationId,
5354
+ });
5355
+ throw err;
5356
+ }
5357
+ this.#redialNotBefore.set(key, now + REDIAL_COOLDOWN_MS);
5358
+ this.#logger.info("session.transport.redial.attempted", { sessionId, agentName, addrs: addrs.length, correlationId });
5359
+ const reconnected = await this.connectToCounterparty(agentName, sessionId, addrs);
5360
+ if (!reconnected.ok) {
5361
+ this.#logger.warn("session.transport.redial.failed", {
5362
+ sessionId, agentName, reason: reconnected.reason, error: reconnected.error, correlationId,
5363
+ });
5364
+ throw err;
5365
+ }
5366
+ this.#logger.info("session.transport.redial.succeeded", { sessionId, agentName, correlationId });
5367
+ // Cleared so the NEXT blip is repaired immediately: the cooldown exists to bound a dead peer,
5368
+ // not to make a live one wait.
5369
+ this.#redialNotBefore.delete(key);
5370
+ return attempt();
5371
+ }
5372
+ }
5373
+ /**
5374
+ * DOD-M12B-INDEX-1 — commit THIS agent's own leaf at the position the relay assigned it.
5375
+ *
5376
+ * The receiver has always enforced "leaf index === canonical position": content witnessed ahead
5377
+ * of the next expected leaf is held, not appended out of order. The sender never did. It had the
5378
+ * position in hand — the relay answers about 4 ms before the append — and called a push-only
5379
+ * append that puts the leaf at the tail whatever the tail happens to be. While its own tree has
5380
+ * no gap the two agree and nothing shows; the first gap puts its leaf at someone else's index,
5381
+ * parts its root from the counterparty's, and the next seal gets `leaf_count_mismatch`, which is
5382
+ * terminal.
5383
+ *
5384
+ * DELIVERY IS NOT DEFERRED BY THIS. The caller has already put the bytes on the wire; only the
5385
+ * leaf waits for its slot, exactly as a received message does. Holding our own send is only
5386
+ * affordable because holds are durable (DOD-M12B-STRAND-1) — before that it would have risked
5387
+ * losing the message outright.
5388
+ *
5389
+ * `assignedSeq` absent means no ordering authority answered. That is the documented degradation
5390
+ * and it appends in arrival order as before: with no position there is no discipline to enforce,
5391
+ * and refusing would take messaging down whenever the relay is unreachable.
5392
+ */
5393
+ placeOwnLeaf(agentName, sessionId, contentHashHex, sentBytes, assignedSeq, correlationId, kind = "msg") {
5394
+ // Hydrate before reading the frontier: a durable hold this process has not read back yet would
5395
+ // make the tree look further along than it is.
5396
+ this.#ensureHeldRestored(agentName, sessionId);
5397
+ const nextExpected = this.getSessionTree(agentName, sessionId).size();
5398
+ if (assignedSeq === undefined) {
5399
+ const { leafIndex } = this.appendSessionLeaf(agentName, sessionId, kind, contentHashHex, correlationId);
5400
+ return { placed: true, leafIndex };
5401
+ }
5402
+ if (assignedSeq === nextExpected) {
5403
+ const { leafIndex } = this.appendSessionLeaf(agentName, sessionId, kind, contentHashHex, correlationId);
5404
+ return { placed: true, leafIndex };
5405
+ }
5406
+ if (assignedSeq < nextExpected) {
5407
+ // THE TREE AND THE RELAY HAVE ALREADY DIVERGED, and refusing here does not undo that.
5408
+ //
5409
+ // This side is AHEAD of the relay's counter, which happens by design: a message whose relay
5410
+ // submit failed still appends unwitnessed (the documented degradation). From then on every
5411
+ // ack comes back behind our frontier and the two can never agree again — the seal was already
5412
+ // lost at the unwitnessed append, not here.
5413
+ //
5414
+ // So the choice is between a record that is short by every subsequent message and one that is
5415
+ // complete but skewed. Appending at the tail keeps the operator's own words in their own
5416
+ // transcript, which is worth more than a tidiness the roots cannot recover anyway; writing
5417
+ // over the assigned slot is the one thing never done, because that rewrites a leaf a root has
5418
+ // already been computed over. The divergence is reported at ERROR and carried to the caller
5419
+ // rather than dressed up as an ordinary success.
5420
+ this.#logger.error("session.tree.position_behind_frontier", {
5421
+ agentName, sessionId, assignedSeq, nextExpected, contentHash: contentHashHex, correlationId,
5422
+ impact: "this side's tree is ahead of the relay's counter, so the two can no longer agree on a root — the message is kept in the local record and this session can no longer be sealed bilaterally",
5423
+ });
5424
+ this.#diverged.add(this.#k(agentName, sessionId));
5425
+ const { leafIndex } = this.appendSessionLeaf(agentName, sessionId, kind, contentHashHex, correlationId);
5426
+ return { placed: true, leafIndex, diverged: true };
5427
+ }
5428
+ // Ahead of the tail: hold it, exactly as the receiver holds theirs, and let #releaseHeld put it
5429
+ // in at its own index when the gap fills.
5430
+ const key = this.#k(agentName, sessionId);
5431
+ let held = this.#heldContent.get(key);
5432
+ if (!held) {
5433
+ held = new Map();
5434
+ this.#heldContent.set(key, held);
5435
+ }
5436
+ held.set(assignedSeq, { content: sentBytes, contentHashHex, correlationId, origin: "sent", kind });
5437
+ this.#persistHeldContent(agentName, sessionId, assignedSeq, sentBytes, sentBytes, contentHashHex, false, correlationId, "sent", kind);
5438
+ this.#logger.info("session.content.held", {
5439
+ sessionId, canonicalSeq: assignedSeq, nextExpected, gap: assignedSeq - nextExpected,
5440
+ origin: "sent", correlationId,
5441
+ });
5442
+ return { placed: false, heldAt: assignedSeq };
5443
+ }
5444
+ /**
5445
+ * DOD-M12B-STRAND-1 — write one held frame to the durable store.
5446
+ *
5447
+ * LOGS LOUD, does not refuse. The caller answers `held: true` either way, and that is correct:
5448
+ * held content is never `persisted`-acked, so the sender keeps its copy and retries whether or
5449
+ * not this row lands. What a failure costs is the restart case — the frame is memory-only again,
5450
+ * exactly as it was before this unit — so it is reported at ERROR here and counted again by the
5451
+ * teardown alarm, and never allowed to look like a success.
5452
+ */
5453
+ #persistHeldContent(agentName, sessionId, canonicalSeq, deliverContent, originalContent, contentHashHex, screenedOut, correlationId, origin = "received", leafKind = "msg") {
5454
+ if (!this.#db)
5455
+ return;
5456
+ try {
5457
+ // A position may legitimately be re-written by a redelivery of the SAME frame. Different
5458
+ // content at the same relay position means the relay contradicted itself, and destroying the
5459
+ // first copy silently is not an option for verified content.
5460
+ const existing = this.#db.prepare("SELECT content_hash_hex FROM held_content WHERE agent_id = ? AND session_id = ? AND canonical_seq = ?").get(this.#requireAgentId(agentName), sessionId, canonicalSeq);
5461
+ if (existing && existing.content_hash_hex !== contentHashHex) {
5462
+ this.#logger.error("session.content.held.position_conflict", {
5463
+ agentName, sessionId, canonicalSeq, correlationId,
5464
+ existingContentHash: existing.content_hash_hex, incomingContentHash: contentHashHex,
5465
+ impact: "two different frames claim one canonical position — the earlier held copy is being replaced",
5466
+ });
5467
+ }
5468
+ this.#db.prepare(`INSERT OR REPLACE INTO held_content
5469
+ (agent_id, session_id, canonical_seq, content_blob, original_blob, content_hash_hex, screened_out, correlation_id, held_at, origin, leaf_kind)
5470
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(this.#requireAgentId(agentName), sessionId, canonicalSeq, Buffer.from(deliverContent), Buffer.from(originalContent), contentHashHex, screenedOut ? 1 : 0, correlationId ?? null, Date.now(), origin, leafKind);
5471
+ }
5472
+ catch (err) {
5473
+ this.#logger.error("session.content.held.persist.failed", {
5474
+ agentName, sessionId, canonicalSeq, contentHash: contentHashHex, correlationId,
5475
+ impact: "this frame is held IN MEMORY ONLY and will be destroyed if the daemon restarts",
5476
+ error: err instanceof Error ? err.message : String(err),
5477
+ });
5478
+ }
5479
+ }
5480
+ /** DOD-M12B-STRAND-1 — drop one held frame from the durable store, on release or on a refusal
5481
+ * that supersedes it. A row that outlives its release re-appends on the next boot. */
5482
+ #deleteHeldContent(agentName, sessionId, canonicalSeq) {
5483
+ if (!this.#db)
5484
+ return;
5485
+ try {
5486
+ this.#db.prepare("DELETE FROM held_content WHERE agent_id = ? AND session_id = ? AND canonical_seq = ?").run(this.#requireAgentId(agentName), sessionId, canonicalSeq);
5487
+ }
5488
+ catch (err) {
5489
+ this.#logger.error("session.content.held.delete.failed", {
5490
+ agentName, sessionId, canonicalSeq,
5491
+ impact: "the released frame's durable row survives and will be re-appended on the next boot",
5492
+ error: err instanceof Error ? err.message : String(err),
5493
+ });
5494
+ }
5495
+ }
5496
+ /**
5497
+ * DOD-M12B-STRAND-1 — restore this session's held frames into memory.
5498
+ *
5499
+ * Called when a session node is (re)created, which is the moment the session becomes able to
5500
+ * append again. Loading at daemon boot instead would be wrong for the same reason the old code
5501
+ * was wrong: a frame is only releasable against a tree, and the tree is loaded per session.
5502
+ *
5503
+ * A frame whose content the tree ALREADY HOLDS at that position is dropped — re-appending it
5504
+ * would change the root a seal signs over. That test is `hashAt`, not `canonical_seq < frontier`:
5505
+ * the two counters are different spaces and this file documents them drifting, so the index
5506
+ * comparison alone would destroy a frame the tree never held. See the drift branch below.
5507
+ */
5508
+ #ensureHeldRestored(agentName, sessionId, opts) {
5509
+ const key = this.#k(agentName, sessionId);
5510
+ if (!this.#heldRestored.has(key)) {
5511
+ // Set BEFORE restoring, so the #ensureHeldRestored inside #releaseHeld below returns straight
5512
+ // away instead of recursing.
5513
+ this.#heldRestored.add(key);
5514
+ this.#restoreHeldContent(agentName, sessionId);
5515
+ }
5516
+ // A RESTORED FRAME MAY ALREADY BE IN ORDER, and nothing else would ever notice.
5517
+ //
5518
+ // #releaseHeld has one caller: the tail of a successful inbound ingest. Every other way the tree
5519
+ // grows — an outbound send leaf, a queued or rejected leaf — advances the frontier without
5520
+ // draining. While holds died with the session node that cost seconds; now the hold is durable,
5521
+ // so the stall is durable too: the counterparty's message sits on disk at exactly the next slot,
5522
+ // is never delivered, and `sealReadiness` counts it, so the session cannot close either.
5523
+ // Undeliverable AND unsealable, forever, from one restart.
5524
+ //
5525
+ // TRACKED SEPARATELY FROM THE HYDRATION. A read-only caller (the status surface) hydrates and
5526
+ // must NOT release — otherwise `cello status` appends leaves, advances the session root, writes
5527
+ // transcript rows and rings the doorbell, which makes a diagnostic command the thing that
5528
+ // delivers messages. One shared flag would also let that read CONSUME the release the next real
5529
+ // ingest was going to perform, which is the stall above, reintroduced.
5530
+ if (opts?.release === false)
5531
+ return;
5532
+ if (this.#heldReleased.has(key))
5533
+ return;
5534
+ this.#heldReleased.add(key);
5535
+ if (this.#heldContent.get(key)?.size) {
5536
+ const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
5537
+ if (counterparty)
5538
+ this.#releaseHeld(agentName, sessionId, counterparty);
5539
+ }
5540
+ }
5541
+ #restoreHeldContent(agentName, sessionId) {
5542
+ if (!this.#db)
5543
+ return;
5544
+ let rows;
5545
+ try {
5546
+ rows = this.#db.prepare(`SELECT canonical_seq, content_blob, original_blob, content_hash_hex, screened_out, correlation_id, origin, leaf_kind
5547
+ FROM held_content WHERE agent_id = ? AND session_id = ? ORDER BY canonical_seq ASC`).all(this.#requireAgentId(agentName), sessionId);
5548
+ }
5549
+ catch (err) {
5550
+ this.#logger.error("session.content.held.restore.failed", {
5551
+ agentName, sessionId,
5552
+ impact: "verified content held before the restart is not in memory and cannot be released",
5553
+ error: err instanceof Error ? err.message : String(err),
5554
+ });
5555
+ return;
5556
+ }
5557
+ if (rows.length === 0)
5558
+ return;
5559
+ const key = this.#k(agentName, sessionId);
5560
+ let held = this.#heldContent.get(key);
5561
+ if (!held) {
5562
+ held = new Map();
5563
+ this.#heldContent.set(key, held);
5564
+ }
5565
+ const tree = this.getSessionTree(agentName, sessionId);
5566
+ const frontier = tree.size();
5567
+ let restored = 0;
5568
+ let superseded = 0;
5569
+ let drifted = 0;
5570
+ for (const row of rows) {
5571
+ // ASK THE TREE WHAT IS AT THAT POSITION — do not infer it from the index.
5572
+ //
5573
+ // `canonical_seq` is the RELAY's sequence space; `frontier` is this tree's msg-leaf count.
5574
+ // The two drift, on purpose and by documented cases: the relay counts CTRL leaves the tree
5575
+ // never appends, and a first message whose relay submit failed leaves the tree one ahead.
5576
+ // Under drift `canonical_seq < frontier` is TRUE for a frame the tree has never held, and
5577
+ // deleting on that comparison destroys verified content while reporting it as tidy-up —
5578
+ // the exact failure this unit exists to end, reintroduced on the recovery path.
5579
+ const occupant = tree.hashAt(row.canonical_seq);
5580
+ if (occupant === row.content_hash_hex) {
5581
+ this.#deleteHeldContent(agentName, sessionId, row.canonical_seq);
5582
+ superseded++;
5583
+ continue;
5584
+ }
5585
+ if (row.canonical_seq < frontier) {
5586
+ // The position is taken by DIFFERENT content. The frame cannot be appended (that would
5587
+ // rewrite a committed leaf) and must not be deleted (it is verified content nobody else
5588
+ // holds), so it goes to the annex that exists for exactly this — content that arrived for
5589
+ // a chain that can no longer carry it — and only then does the row go.
5590
+ const annexed = this.recordSealedAnnex(agentName, sessionId, row.content_hash_hex, new Uint8Array(row.content_blob),
5591
+ // DOD-M12B-INDEX-1: our own held send is attributed to US, never to the counterparty.
5592
+ row.origin === "sent"
5593
+ ? this.#ownPubkeyHex(agentName)
5594
+ : this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey ?? null);
5595
+ this.#logger.error("session.content.held.position_drifted", {
5596
+ agentName, sessionId, canonicalSeq: row.canonical_seq, frontier,
5597
+ contentHash: row.content_hash_hex, occupant, annexed,
5598
+ correlationId: row.correlation_id ?? undefined,
5599
+ impact: annexed
5600
+ ? "the relay's position for this frame is occupied by different content — it cannot join the chain and is readable only from the annex"
5601
+ : "the relay's position for this frame is occupied by different content AND the annex write failed — the durable row is kept rather than destroyed",
5602
+ });
5603
+ if (annexed)
5604
+ this.#deleteHeldContent(agentName, sessionId, row.canonical_seq);
5605
+ drifted++;
5606
+ continue;
5607
+ }
5608
+ held.set(row.canonical_seq, {
5609
+ content: new Uint8Array(row.content_blob),
5610
+ ...(row.original_blob ? { originalContent: new Uint8Array(row.original_blob) } : {}),
5611
+ contentHashHex: row.content_hash_hex,
5612
+ ...(row.correlation_id ? { correlationId: row.correlation_id } : {}),
5613
+ ...(row.screened_out ? { screenedOut: true } : {}),
5614
+ ...(row.origin === "sent" ? { origin: "sent" } : {}),
5615
+ ...(row.leaf_kind === "doc" ? { kind: "doc" } : {}),
5616
+ });
5617
+ restored++;
5618
+ }
5619
+ if (held.size === 0)
5620
+ this.#heldContent.delete(key);
5621
+ this.#logger.info("session.content.held.restored", {
5622
+ agentName, sessionId, restored, superseded, drifted, frontier,
5623
+ canonicalSeqs: [...held.keys()].sort((a, b) => a - b),
5624
+ // The flow ids of the frames that came back, so a restored message ties to the
5625
+ // `session.content.held` that opened its flow before the restart.
5626
+ correlationIds: rows.map((r) => r.correlation_id).filter((c) => c !== null),
5627
+ });
5628
+ }
4432
5629
  /**
4433
5630
  * DOD-MSG-4: drain held out-of-order content in canonical order. After a leaf is appended, any
4434
5631
  * held entry whose canonical sequence equals the new next-expected index is now in order — append
@@ -4436,6 +5633,10 @@ export class SessionNodeManager {
4436
5633
  */
4437
5634
  #releaseHeld(agentName, sessionId, senderPubkey) {
4438
5635
  const key = this.#k(agentName, sessionId);
5636
+ // DOD-M12B-STRAND-1: hydrate before scanning. Restoring eagerly at session-node creation put
5637
+ // it behind writes that can fail — one failed `sessions` row upsert and the frames stayed on
5638
+ // disk, invisible, which is the same outcome as losing them.
5639
+ this.#ensureHeldRestored(agentName, sessionId);
4439
5640
  const held = this.#heldContent.get(key);
4440
5641
  if (!held)
4441
5642
  return 0;
@@ -4446,13 +5647,47 @@ export class SessionNodeManager {
4446
5647
  if (!entry)
4447
5648
  break;
4448
5649
  held.delete(nextExpected);
4449
- // A screened-out (terminal-blocked) held entry leafs at its canonical index but is NEVER
4450
- // buffered for the agent; a normal held entry buffers + leafs (code-review HIGH-1).
4451
- if (entry.screenedOut) {
5650
+ // DOD-M12B-STRAND-1: released content leaves the durable store in the same breath. A row
5651
+ // that outlives its release would re-append the same content on the next boot growing the
5652
+ // tree and changing a root that has already been signed.
5653
+ this.#deleteHeldContent(agentName, sessionId, nextExpected);
5654
+ // DOD-M12B-INDEX-1: OUR OWN held message. It leafs at its canonical index and is transcribed
5655
+ // as SENT — never routed down the received path, which would attribute our words to the
5656
+ // counterparty in the sealed record and hand them back to our own agent as inbound.
5657
+ if (entry.origin === "sent") {
5658
+ // The KIND the leaf was placed with, not a hardcoded "msg" — a document leaf that had to
5659
+ // wait for its position must come back as a document leaf, or the two sides disagree about
5660
+ // what the chain contains.
5661
+ this.appendSessionLeaf(agentName, sessionId, entry.kind ?? "msg", entry.contentHashHex, entry.correlationId);
5662
+ // A DOCUMENT frame takes a leaf and NO transcript row — matching what the immediate-append
5663
+ // path does for one. Writing one would put raw CBOR into the operator's transcript as
5664
+ // something they said, which is the same attribution failure as releasing it inbound.
5665
+ if (entry.kind === "doc") {
5666
+ released++;
5667
+ this.#logger.info("session.content.released", {
5668
+ sessionId, sequenceNumber: nextExpected, leafKind: "doc", correlationId: entry.correlationId,
5669
+ });
5670
+ if (held.size === 0) {
5671
+ this.#heldContent.delete(key);
5672
+ break;
5673
+ }
5674
+ continue;
5675
+ }
5676
+ // OBSERVED, not assumed — the received path already does this. The leaf commits either way,
5677
+ // so a dropped transcript write means the operator's OWN message is missing from their own
5678
+ // transcript with the chain saying it is there, and nothing anywhere said so.
5679
+ if (!this.recordTranscriptMessage(agentName, sessionId, nextExpected, "sent", entry.content, entry.correlationId)) {
5680
+ this.#logger.error("session.content.released.transcript.failed", {
5681
+ agentName, sessionId, sequenceNumber: nextExpected, correlationId: entry.correlationId,
5682
+ impact: "this side's own message is committed to the chain but missing from its transcript",
5683
+ });
5684
+ }
5685
+ }
5686
+ else if (entry.screenedOut) {
4452
5687
  this.appendSessionLeaf(agentName, sessionId, "msg", entry.contentHashHex, entry.correlationId);
4453
5688
  }
4454
5689
  else {
4455
- this.#appendVerifiedContent(agentName, sessionId, entry.content, entry.contentHashHex, senderPubkey, entry.correlationId);
5690
+ this.#appendVerifiedContent(agentName, sessionId, entry.content, entry.contentHashHex, senderPubkey, entry.correlationId, entry.originalContent);
4456
5691
  }
4457
5692
  released++;
4458
5693
  this.#logger.info("session.content.released", {
@@ -4652,10 +5887,35 @@ export class SessionNodeManager {
4652
5887
  */
4653
5888
  async #sendDeliveryAck(agentName, sessionId, contentHash, correlationId) {
4654
5889
  const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
4655
- if (!entry)
5890
+ if (!entry) {
5891
+ // NOT a silent return. No ACK is exactly this milestone's symptom — the sender's TTF expires
5892
+ // and the message parks — so the one case where we knowingly decline to send one has to say
5893
+ // so, or it is indistinguishable from the defect.
5894
+ this.#logger.debug("content.delivery.ack.skipped", {
5895
+ agentName,
5896
+ sessionId,
5897
+ contentHash: Buffer.from(contentHash).toString("hex"),
5898
+ reason: "session_node_gone",
5899
+ correlationId,
5900
+ });
4656
5901
  return;
5902
+ }
5903
+ // Held outside the try so the catch can retire a stream that was opened and then failed to
5904
+ // write. Without it every failure leaks the OUTBOUND half of the stream the receiver-side
5905
+ // `finally` retires — same defect, other end, other cap. See the note on #handleContentStream.
5906
+ // Assigned IMMEDIATELY after newStream: anything between the two is a window where a throw
5907
+ // leaks the stream because the catch cannot see it.
5908
+ let ackStream;
4657
5909
  try {
4658
5910
  const stream = await entry.node.newStream(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
5911
+ ackStream = stream;
5912
+ // Injected ACK-write failure — thrown from inside the try so it lands in exactly the catch a
5913
+ // real reset lands in, and the whole downstream path (impair → abort → log) runs unmodified.
5914
+ if (this.#ackFaultRemaining > 0) {
5915
+ this.#ackFaultRemaining -= 1;
5916
+ this.#logger.warn("content.delivery.ack.fault.injected", { sessionId });
5917
+ throw new Error("connection_lost: injected delivery-ack fault");
5918
+ }
4659
5919
  const frame = encodeCbor({
4660
5920
  type: "content_delivery_ack",
4661
5921
  session_id: sessionId,
@@ -4664,27 +5924,47 @@ export class SessionNodeManager {
4664
5924
  correlation_id: correlationId,
4665
5925
  });
4666
5926
  stream.send(lp.encode.single(frame));
4667
- // The receiver-side counterpart to the sender's content.delivery.acked: B has acknowledged
4668
- // this content `persisted`, so the sender stops retrying/parking. Emitted for BOTH a normally
4669
- // delivered message AND a terminal-screen block (the block is a definitive receipt the leaf
4670
- // is recorded, so the sender must stop) and deliberately NOT for a transient hold.
5927
+ // NOT SWALLOWED, for the same reason the direct-send path stopped swallowing it: `close()`
5928
+ // waits for the write buffer to drain, so a reset mid-flush throws HERE and that is exactly
5929
+ // the case where the bytes never left. A swallowed close made two things happen at once —
5930
+ // this log claimed the ACK went out while the sender's TTF fired and parked, and the abort in
5931
+ // the catch below (the thing that frees the stream slot) became unreachable.
5932
+ await stream.close();
5933
+ // AFTER the close, because that is when it is true. The receiver-side counterpart to the
5934
+ // sender's content.delivery.acked: B has acknowledged this content `persisted`, so the sender
5935
+ // stops retrying/parking. Emitted for BOTH a normally delivered message AND a terminal-screen
5936
+ // block (the block is a definitive receipt — the leaf is recorded, so the sender must stop) —
5937
+ // and deliberately NOT for a transient hold.
4671
5938
  this.#logger.info("content.delivery.ack.sent", {
4672
5939
  sessionId,
4673
5940
  contentHash: Buffer.from(contentHash).toString("hex"),
4674
5941
  correlationId,
4675
5942
  });
4676
- try {
4677
- await stream.close();
4678
- }
4679
- catch { /* best-effort close */ }
5943
+ // An agent that mostly LISTENS sends content rarely and ACKs constantly. Clearing only on the
5944
+ // content path would leave exactly those sessions reporting a broken conversation forever
5945
+ // after one bad ACK — the one-way door, on the other send path.
5946
+ this.#clearSessionImpairment(agentName, sessionId, "delivery_ack", correlationId);
4680
5947
  }
4681
5948
  catch (err) {
4682
5949
  this.#logger.warn("content.delivery.ack.send.failed", {
4683
5950
  sessionId,
4684
5951
  contentHash: Buffer.from(contentHash).toString("hex"),
4685
5952
  error: err instanceof Error ? err.message : String(err),
5953
+ // "Cannot write to a stream that is closed" names where the write died, never why. The
5954
+ // why is almost always the per-protocol stream cap, and these two numbers are what turn
5955
+ // that from a log-measurement session into a grep.
5956
+ ...this.#streamCensus(entry.node, entry.counterpartySessionPeerId),
4686
5957
  correlationId,
4687
5958
  });
5959
+ // The ACK travels the same direct path as our own content, so a failure here is the same
5960
+ // evidence: writes to this counterparty are not landing.
5961
+ this.#markSessionImpaired(agentName, sessionId, { cause: "delivery_ack", error: err instanceof Error ? err.message : String(err), correlationId });
5962
+ if (ackStream !== undefined) {
5963
+ try {
5964
+ ackStream.abort(err instanceof Error ? err : new Error(String(err)));
5965
+ }
5966
+ catch { /* already gone */ }
5967
+ }
4688
5968
  }
4689
5969
  }
4690
5970
  /** Cancel and drop a single awaiting-ACK entry (e.g. the send failed after arming). */
@@ -4742,9 +6022,17 @@ export class SessionNodeManager {
4742
6022
  // the fragile dependency on that internal timing (review L4).
4743
6023
  async #registerContentHandler(agentName, sessionId, node, _counterpartyPubkey) {
4744
6024
  try {
4745
- await node.handle(CELLO_CONTENT_PROTOCOL_ID, (stream) => {
4746
- void this.#handleContentStream(agentName, sessionId, stream);
4747
- });
6025
+ await node.handle(CELLO_CONTENT_PROTOCOL_ID, (stream, remotePeerId) => {
6026
+ // `.catch` is not decoration: the handler builds its length-prefixed decoder before its own
6027
+ // try, and a throw there would otherwise become an unhandled rejection that takes the
6028
+ // daemon down for one malformed inbound stream.
6029
+ void this.#handleContentStream(agentName, sessionId, stream, remotePeerId).catch((err) => {
6030
+ this.#logger.warn("session.content.stream.handler.failed", {
6031
+ sessionId,
6032
+ error: err instanceof Error ? err.message : String(err),
6033
+ });
6034
+ });
6035
+ }, { maxInboundStreams: CONTENT_MAX_INBOUND_STREAMS });
4748
6036
  }
4749
6037
  catch (err) {
4750
6038
  this.#logger.error("session.content.handler.register.failed", {
@@ -4930,9 +6218,26 @@ export class SessionNodeManager {
4930
6218
  // No verified position — the caller falls back to the announced hash-dedup path.
4931
6219
  return null;
4932
6220
  }
4933
- async #handleContentStream(agentName, sessionId, stream) {
4934
- const iter = lp.decode(stream)[Symbol.asyncIterator]();
6221
+ async #handleContentStream(agentName, sessionId, stream, remotePeerId) {
6222
+ // CLOSING THIS STREAM IS WHAT KEEPS THE SESSION ALIVE PAST ITS 33RD MESSAGE.
6223
+ //
6224
+ // Every content frame and every delivery ACK opens a fresh /cello/content/1.0.0 stream on the
6225
+ // one muxed connection the session holds, and libp2p caps INBOUND streams per protocol per
6226
+ // connection. It enforces that cap AFTER multistream-select has answered, so an over-cap stream
6227
+ // negotiates fine and is reset an instant later, and the SENDER's next `stream.send(...)`
6228
+ // throws "Cannot write to a stream that is closed" — an error that names the exit point and not
6229
+ // one thing about the cause.
6230
+ //
6231
+ // A stream leaves the muxer's set only on its `close` event, and closing our write end triggers
6232
+ // that only once the peer has closed its end too. So a handler that reads its frame and returns
6233
+ // leaves the stream half-open for the life of the connection and the count only ever rises.
6234
+ // Measured on a live daemon: 115 failures over 3.5 hours, with EXACTLY 32 successful streams
6235
+ // before the first one on both affected sessions (M12B Entry 10).
6236
+ //
6237
+ // The decoder is built INSIDE the try so a malformed stream cannot throw past the close below.
6238
+ let iter;
4935
6239
  try {
6240
+ iter = lp.decode(stream)[Symbol.asyncIterator]();
4936
6241
  const result = await iter.next();
4937
6242
  if (result.done || result.value === undefined)
4938
6243
  return;
@@ -4952,6 +6257,40 @@ export class SessionNodeManager {
4952
6257
  }
4953
6258
  return;
4954
6259
  }
6260
+ // DOD-M12B-ABANDON-NOTIFY-1: the counterparty force-abandoned. Handled here, on the same
6261
+ // authenticated stream the delivery acknowledgement rides, and AFTER the session-id check
6262
+ // below cannot be skipped — the frame names its session and the handler is bound to one.
6263
+ if (frame["type"] === "session_abandoned_notice") {
6264
+ // PINNED TO THE COUNTERPARTY. This frame ENDS a conversation, so the stream being
6265
+ // authenticated is not enough — a session node is a promoted standing receiver, and a
6266
+ // standing receiver accepts everyone. libp2p's gater runs at connection establishment and
6267
+ // does not close connections that already exist, so a peer that dialled this node earlier
6268
+ // still holds a live connection after `setAllowedPeer` narrows it. Without this check that
6269
+ // peer could hang up a session it is not party to.
6270
+ //
6271
+ // `remotePeerId` is the Noise-authenticated transport identity, which the handler was
6272
+ // throwing away. Absent means we cannot prove who is speaking, and an unprovable claim to
6273
+ // end a session is refused.
6274
+ const expected = this.#activeNodes.get(this.#k(agentName, sessionId))?.counterpartySessionPeerId;
6275
+ if (!remotePeerId || !expected || remotePeerId !== expected) {
6276
+ this.#logger.warn("session.content.peer_mismatch", {
6277
+ sessionId, frameType: "session_abandoned_notice",
6278
+ remotePeerId: remotePeerId ?? "(absent)", expected: expected ?? "(unknown)",
6279
+ });
6280
+ return;
6281
+ }
6282
+ // REQUIRED and equal — absence is not a pass. The frame names its session and the handler
6283
+ // is bound to one; treating a missing field as agreement is how a guard stops guarding.
6284
+ const claimed = frame["session_id"];
6285
+ if (typeof claimed !== "string" || claimed !== sessionId) {
6286
+ this.#logger.warn("session.content.session_mismatch", {
6287
+ sessionId, claimedSessionId: typeof claimed === "string" ? claimed : "(absent)",
6288
+ });
6289
+ return;
6290
+ }
6291
+ void this.retireOnCounterpartyAbandon(agentName, sessionId, correlationId);
6292
+ return;
6293
+ }
4955
6294
  if (frame["type"] !== "content_frame") {
4956
6295
  // LOGGED, not silently dropped. This handler is bound to one session, and a frame it does
4957
6296
  // not understand arriving on that stream is either a peer speaking a newer protocol or a
@@ -5026,6 +6365,48 @@ export class SessionNodeManager {
5026
6365
  error: err instanceof Error ? err.message : String(err),
5027
6366
  });
5028
6367
  }
6368
+ finally {
6369
+ // `close()` waits only for OUR write buffer, which is empty here, so this cannot stall the
6370
+ // handler; it runs on every exit above, and there are several early returns.
6371
+ try {
6372
+ await stream.close();
6373
+ }
6374
+ catch (err) {
6375
+ // NOT SILENT. A close that fails here is the signature of the cap biting from the other
6376
+ // side, and it was the absence of exactly this line that turned the original diagnosis
6377
+ // into a 6,451-record log measurement.
6378
+ this.#logger.warn("session.content.stream.close.failed", {
6379
+ sessionId,
6380
+ error: err instanceof Error ? err.message : String(err),
6381
+ });
6382
+ try {
6383
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
6384
+ }
6385
+ catch { /* already gone */ }
6386
+ return;
6387
+ }
6388
+ // OUR CLOSE ALONE DOES NOT FREE THE SLOT — the peer has to close its end too, and a peer
6389
+ // owns its own daemon. Without this, someone who opens content streams and never closes them
6390
+ // pins every inbound slot we have and puts us straight back into the defect above, with the
6391
+ // same unreadable error. `abort` resets unilaterally, so it works regardless of the peer;
6392
+ // the delay is what keeps it from landing while a well-behaved sender is still inside its
6393
+ // own `close()`. Unref'd so it can never hold the process open at shutdown, and tracked so
6394
+ // teardown can drop it.
6395
+ if (stream.status === "open" || stream.status === "closing") {
6396
+ const linger = setTimeout(() => {
6397
+ this.#lingeringStreams.delete(linger);
6398
+ if (stream.status !== "open" && stream.status !== "closing")
6399
+ return;
6400
+ this.#logger.debug("session.content.stream.linger.reset", { sessionId });
6401
+ try {
6402
+ stream.abort(new Error("inbound content stream not closed by peer"));
6403
+ }
6404
+ catch { /* already gone */ }
6405
+ }, CONTENT_STREAM_LINGER_MS);
6406
+ linger.unref?.();
6407
+ this.#lingeringStreams.add(linger);
6408
+ }
6409
+ }
5029
6410
  }
5030
6411
  /**
5031
6412
  * M7-SESSION-001 AC-004/AC-005: Register a relay stream for an active session.
@@ -5681,13 +7062,85 @@ export class SessionNodeManager {
5681
7062
  return flipped;
5682
7063
  }
5683
7064
  /** @returns true iff the UPDATE was executed without error (a failed write is logged, never thrown). */
5684
- #updateSessionStatus(agentName, sessionId, status) {
7065
+ /**
7066
+ * DOD-M12B-STRAND-1 — move a terminal session's held frames to the annex.
7067
+ *
7068
+ * A held frame is content this agent RECEIVED and VERIFIED. When its session ends it can never
7069
+ * join that chain (appending behind a committed root is not an option, and ingest refuses a
7070
+ * terminal session outright), but it is still the operator's mail and no other copy exists —
7071
+ * the sender was never acknowledged for it. `sealed_session_annex` is where M12-P17 already puts
7072
+ * content that arrives for an ended session; this is the same content arriving slightly earlier.
7073
+ *
7074
+ * ANNEX FIRST, DELETE SECOND, per row. A crash between them costs a duplicate the annex's
7075
+ * INSERT OR IGNORE absorbs; the other order costs the message. A row whose annex write fails is
7076
+ * KEPT — the retention sweep will find it again, and a leftover row is cheaper than a lost one.
7077
+ */
7078
+ #annexHeldContentOnTerminal(agentName, sessionId, status) {
7079
+ if (!this.#db)
7080
+ return;
7081
+ let rows;
7082
+ try {
7083
+ rows = this.#db.prepare(`SELECT canonical_seq, content_blob, content_hash_hex, held_at, origin
7084
+ FROM held_content WHERE agent_id = ? AND session_id = ? ORDER BY canonical_seq ASC`).all(this.#requireAgentId(agentName), sessionId);
7085
+ }
7086
+ catch (err) {
7087
+ this.#logger.error("session.content.held.annex.scan.failed", {
7088
+ agentName, sessionId, status,
7089
+ impact: "held frames for a terminal session were not moved to the annex and remain unreadable",
7090
+ error: err instanceof Error ? err.message : String(err),
7091
+ });
7092
+ return;
7093
+ }
7094
+ if (rows.length === 0)
7095
+ return;
7096
+ const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey ?? null;
7097
+ let annexed = 0;
7098
+ let kept = 0;
7099
+ for (const row of rows) {
7100
+ // DOD-M12B-INDEX-1: ATTRIBUTION. A `sent` row is our own message. Stamping the counterparty's
7101
+ // pubkey on it would put our words in their mouth in the one record that survives the
7102
+ // session — the same failure the release path was changed to avoid, on the drain it did not
7103
+ // touch. `#ownPubkeyHex` is null only when the identity cannot be resolved, and a null sender
7104
+ // reads as "unattributed", which is true, rather than as a false attribution.
7105
+ const sender = row.origin === "sent" ? this.#ownPubkeyHex(agentName) : counterparty;
7106
+ if (this.recordSealedAnnex(agentName, sessionId, row.content_hash_hex, new Uint8Array(row.content_blob), sender)) {
7107
+ this.#deleteHeldContent(agentName, sessionId, row.canonical_seq);
7108
+ // AND OUT OF THE IN-MEMORY MAP. Measured live 2026-08-17 on daemon 0.0.170: this frame is
7109
+ // now safe in the annex and its durable row is gone — but teardown still found it in the
7110
+ // map, counted `held_content` for the session, got 0, and fired
7111
+ // `session.content.held.lost`: "verified content was destroyed". Ten frames were annexed
7112
+ // and the same ten were reported destroyed, in the same second. A false alarm on the most
7113
+ // serious event in the system is worse than no alarm, because the next investigation goes
7114
+ // looking for content that was never lost.
7115
+ this.#heldContent.get(this.#k(agentName, sessionId))?.delete(row.canonical_seq);
7116
+ annexed++;
7117
+ }
7118
+ else {
7119
+ kept++;
7120
+ }
7121
+ }
7122
+ this.#logger.warn("session.content.held.annexed", {
7123
+ agentName, sessionId, status, annexed, kept,
7124
+ // The consumer of `held_at`: how long the oldest frame waited before its session ended.
7125
+ oldestHeldMs: Date.now() - Math.min(...rows.map((r) => r.held_at)),
7126
+ impact: "these messages arrived and verified but never joined the chain — they are readable from the annex, not the transcript",
7127
+ });
7128
+ }
7129
+ #updateSessionStatus(agentName, sessionId, status,
7130
+ // DOD-CAP-SELF-HEAL-1: who caused an interruption, when this call is the one causing it.
7131
+ // Omitting it leaves the column NULL, which the acceptance bound reads as the counterparty's —
7132
+ // so a LOCAL teardown that forgets to say so is charged to the peer. That is exactly how the
7133
+ // operator's own kill switch (`cello_set_agent_offline` → destroySessionNode) was locking out
7134
+ // a counterparty who had done nothing.
7135
+ interruptedBy) {
5685
7136
  if (!this.#db)
5686
7137
  return false;
5687
7138
  const now = Date.now();
5688
7139
  try {
5689
7140
  const res = this.#db
5690
- .prepare("UPDATE sessions SET status = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
7141
+ .prepare(interruptedBy === undefined
7142
+ ? "UPDATE sessions SET status = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?"
7143
+ : "UPDATE sessions SET status = ?, updated_at = ?, interrupted_by = 'local' WHERE agent_id = ? AND session_id = ?")
5691
7144
  .run(status, now, this.#requireAgentId(agentName), sessionId);
5692
7145
  // "Did not throw" is NOT "landed". An UPDATE whose WHERE matches no row — a wrong agent_id, a
5693
7146
  // session_id with no row — succeeds silently and changes nothing. Reporting that as a written
@@ -5708,6 +7161,14 @@ export class SessionNodeManager {
5708
7161
  // is still, on disk, drainable. 'interrupted' and 'seal_interrupted_pending' are deliberately
5709
7162
  // NOT terminal — both can still complete, and reaping them would destroy live content.
5710
7163
  if (status === "sealed" || status === "abandoned") {
7164
+ // DOD-M12B-STRAND-1: held frames outlive the chain that could have carried them.
7165
+ //
7166
+ // Once a session is terminal, `ingestReceivedContent` refuses it — and #releaseHeld is only
7167
+ // reachable from ingest — so no code path that exists can ever release a held frame again.
7168
+ // Left alone the rows sit on disk, unreachable by any surface, while the teardown alarm
7169
+ // reports `lost: 0`: a success message for content that has just become permanently
7170
+ // unreadable. The annex is the store built for exactly this shape.
7171
+ this.#annexHeldContentOnTerminal(agentName, sessionId, status);
5711
7172
  try {
5712
7173
  this.#onSessionTerminal?.(sessionId, status);
5713
7174
  }