@cello-protocol/daemon 0.0.180 → 0.0.182

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/dist/agent-id-migration.d.ts +9 -0
  2. package/dist/agent-id-migration.d.ts.map +1 -1
  3. package/dist/agent-id-migration.js +73 -1
  4. package/dist/agent-id-migration.js.map +1 -1
  5. package/dist/agent-selection.d.ts +73 -0
  6. package/dist/agent-selection.d.ts.map +1 -0
  7. package/dist/agent-selection.js +80 -0
  8. package/dist/agent-selection.js.map +1 -0
  9. package/dist/assignment-verify.d.ts +62 -0
  10. package/dist/assignment-verify.d.ts.map +1 -0
  11. package/dist/assignment-verify.js +185 -0
  12. package/dist/assignment-verify.js.map +1 -0
  13. package/dist/backup-restore.d.ts +102 -0
  14. package/dist/backup-restore.d.ts.map +1 -0
  15. package/dist/backup-restore.js +340 -0
  16. package/dist/backup-restore.js.map +1 -0
  17. package/dist/bin/cello-daemon.js +91 -49
  18. package/dist/bin/cello-daemon.js.map +1 -1
  19. package/dist/close-commitment.d.ts +67 -0
  20. package/dist/close-commitment.d.ts.map +1 -0
  21. package/dist/close-commitment.js +78 -0
  22. package/dist/close-commitment.js.map +1 -0
  23. package/dist/close-session-handler.d.ts +20 -0
  24. package/dist/close-session-handler.d.ts.map +1 -1
  25. package/dist/close-session-handler.js +351 -58
  26. package/dist/close-session-handler.js.map +1 -1
  27. package/dist/consortium-bootstrap.d.ts +2 -0
  28. package/dist/consortium-bootstrap.d.ts.map +1 -1
  29. package/dist/consortium-bootstrap.js +65 -7
  30. package/dist/consortium-bootstrap.js.map +1 -1
  31. package/dist/content-park.d.ts.map +1 -1
  32. package/dist/content-park.js +141 -10
  33. package/dist/content-park.js.map +1 -1
  34. package/dist/daemon.d.ts +13 -1
  35. package/dist/daemon.d.ts.map +1 -1
  36. package/dist/daemon.js +928 -123
  37. package/dist/daemon.js.map +1 -1
  38. package/dist/delivery-session-suspects.d.ts.map +1 -1
  39. package/dist/delivery-session-suspects.js +5 -0
  40. package/dist/delivery-session-suspects.js.map +1 -1
  41. package/dist/directory-auth-posture.d.ts +87 -0
  42. package/dist/directory-auth-posture.d.ts.map +1 -0
  43. package/dist/directory-auth-posture.js +134 -0
  44. package/dist/directory-auth-posture.js.map +1 -0
  45. package/dist/directory-bootstrap.d.ts +66 -3
  46. package/dist/directory-bootstrap.d.ts.map +1 -1
  47. package/dist/directory-bootstrap.js +133 -7
  48. package/dist/directory-bootstrap.js.map +1 -1
  49. package/dist/document-delivery-transport.d.ts +22 -2
  50. package/dist/document-delivery-transport.d.ts.map +1 -1
  51. package/dist/document-delivery-transport.js +11 -3
  52. package/dist/document-delivery-transport.js.map +1 -1
  53. package/dist/document-frame-router.d.ts +14 -0
  54. package/dist/document-frame-router.d.ts.map +1 -1
  55. package/dist/document-frame-router.js +17 -0
  56. package/dist/document-frame-router.js.map +1 -1
  57. package/dist/document-gate.js +51 -8
  58. package/dist/document-gate.js.map +1 -1
  59. package/dist/document-handlers.d.ts.map +1 -1
  60. package/dist/document-handlers.js +50 -6
  61. package/dist/document-handlers.js.map +1 -1
  62. package/dist/document-inbound.d.ts +22 -0
  63. package/dist/document-inbound.d.ts.map +1 -1
  64. package/dist/document-inbound.js +54 -0
  65. package/dist/document-inbound.js.map +1 -1
  66. package/dist/document-layer.d.ts +13 -0
  67. package/dist/document-layer.d.ts.map +1 -1
  68. package/dist/document-layer.js +1 -0
  69. package/dist/document-layer.js.map +1 -1
  70. package/dist/document-screen.d.ts +39 -0
  71. package/dist/document-screen.d.ts.map +1 -1
  72. package/dist/document-screen.js +64 -9
  73. package/dist/document-screen.js.map +1 -1
  74. package/dist/inbound-sessions.d.ts +31 -1
  75. package/dist/inbound-sessions.d.ts.map +1 -1
  76. package/dist/inbound-sessions.js +276 -16
  77. package/dist/inbound-sessions.js.map +1 -1
  78. package/dist/index.d.ts +2 -0
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +3 -0
  81. package/dist/index.js.map +1 -1
  82. package/dist/ipc-server.d.ts +5 -1
  83. package/dist/ipc-server.d.ts.map +1 -1
  84. package/dist/ipc-server.js +19 -3
  85. package/dist/ipc-server.js.map +1 -1
  86. package/dist/manifest-validity.d.ts +151 -0
  87. package/dist/manifest-validity.d.ts.map +1 -0
  88. package/dist/manifest-validity.js +268 -0
  89. package/dist/manifest-validity.js.map +1 -0
  90. package/dist/notification-handlers.d.ts.map +1 -1
  91. package/dist/notification-handlers.js +15 -0
  92. package/dist/notification-handlers.js.map +1 -1
  93. package/dist/outbound-sessions.d.ts +31 -0
  94. package/dist/outbound-sessions.d.ts.map +1 -1
  95. package/dist/outbound-sessions.js +198 -17
  96. package/dist/outbound-sessions.js.map +1 -1
  97. package/dist/park-envelope.d.ts +72 -1
  98. package/dist/park-envelope.d.ts.map +1 -1
  99. package/dist/park-envelope.js +119 -4
  100. package/dist/park-envelope.js.map +1 -1
  101. package/dist/refusal-reasons.d.ts +72 -0
  102. package/dist/refusal-reasons.d.ts.map +1 -0
  103. package/dist/refusal-reasons.js +69 -0
  104. package/dist/refusal-reasons.js.map +1 -0
  105. package/dist/registration-manager.d.ts.map +1 -1
  106. package/dist/registration-manager.js +20 -1
  107. package/dist/registration-manager.js.map +1 -1
  108. package/dist/restart-seal-resolver.d.ts.map +1 -1
  109. package/dist/restart-seal-resolver.js +6 -0
  110. package/dist/restart-seal-resolver.js.map +1 -1
  111. package/dist/retry-queue.d.ts +23 -1
  112. package/dist/retry-queue.d.ts.map +1 -1
  113. package/dist/retry-queue.js +37 -9
  114. package/dist/retry-queue.js.map +1 -1
  115. package/dist/roster-freshness.d.ts +169 -0
  116. package/dist/roster-freshness.d.ts.map +1 -0
  117. package/dist/roster-freshness.js +249 -0
  118. package/dist/roster-freshness.js.map +1 -0
  119. package/dist/seal-certificate-pull.d.ts +17 -1
  120. package/dist/seal-certificate-pull.d.ts.map +1 -1
  121. package/dist/seal-certificate-pull.js +37 -3
  122. package/dist/seal-certificate-pull.js.map +1 -1
  123. package/dist/seal-coordinator.d.ts +17 -1
  124. package/dist/seal-coordinator.d.ts.map +1 -1
  125. package/dist/seal-coordinator.js +71 -0
  126. package/dist/seal-coordinator.js.map +1 -1
  127. package/dist/seal-failure-store.d.ts +104 -0
  128. package/dist/seal-failure-store.d.ts.map +1 -0
  129. package/dist/seal-failure-store.js +126 -0
  130. package/dist/seal-failure-store.js.map +1 -0
  131. package/dist/seal-flows.d.ts.map +1 -1
  132. package/dist/seal-flows.js +35 -6
  133. package/dist/seal-flows.js.map +1 -1
  134. package/dist/seal-upgrade.d.ts +20 -5
  135. package/dist/seal-upgrade.d.ts.map +1 -1
  136. package/dist/seal-upgrade.js +28 -5
  137. package/dist/seal-upgrade.js.map +1 -1
  138. package/dist/session-assignment-parser.d.ts +41 -5
  139. package/dist/session-assignment-parser.d.ts.map +1 -1
  140. package/dist/session-assignment-parser.js +77 -14
  141. package/dist/session-assignment-parser.js.map +1 -1
  142. package/dist/session-ceremony.d.ts +29 -0
  143. package/dist/session-ceremony.d.ts.map +1 -1
  144. package/dist/session-ceremony.js +71 -0
  145. package/dist/session-ceremony.js.map +1 -1
  146. package/dist/session-connection-gater.d.ts +46 -7
  147. package/dist/session-connection-gater.d.ts.map +1 -1
  148. package/dist/session-connection-gater.js +163 -12
  149. package/dist/session-connection-gater.js.map +1 -1
  150. package/dist/session-content-handlers.d.ts.map +1 -1
  151. package/dist/session-content-handlers.js +110 -4
  152. package/dist/session-content-handlers.js.map +1 -1
  153. package/dist/session-node-manager.d.ts +407 -37
  154. package/dist/session-node-manager.d.ts.map +1 -1
  155. package/dist/session-node-manager.js +3051 -239
  156. package/dist/session-node-manager.js.map +1 -1
  157. package/dist/session-read-handlers.d.ts +27 -0
  158. package/dist/session-read-handlers.d.ts.map +1 -1
  159. package/dist/session-read-handlers.js +112 -6
  160. package/dist/session-read-handlers.js.map +1 -1
  161. package/dist/session-relay-client.d.ts +61 -2
  162. package/dist/session-relay-client.d.ts.map +1 -1
  163. package/dist/session-relay-client.js +170 -11
  164. package/dist/session-relay-client.js.map +1 -1
  165. package/dist/session-salt-agreement.d.ts +284 -0
  166. package/dist/session-salt-agreement.d.ts.map +1 -0
  167. package/dist/session-salt-agreement.js +371 -0
  168. package/dist/session-salt-agreement.js.map +1 -0
  169. package/dist/signal-submission.d.ts.map +1 -1
  170. package/dist/signal-submission.js +10 -1
  171. package/dist/signal-submission.js.map +1 -1
  172. package/dist/signaling-connect.d.ts.map +1 -1
  173. package/dist/signaling-connect.js +42 -5
  174. package/dist/signaling-connect.js.map +1 -1
  175. package/dist/types.d.ts +54 -0
  176. package/dist/types.d.ts.map +1 -1
  177. package/dist/types.js.map +1 -1
  178. package/dist/vocabulary.d.ts.map +1 -1
  179. package/dist/vocabulary.js +10 -0
  180. package/dist/vocabulary.js.map +1 -1
  181. package/dist/wire-content-hash.d.ts +56 -0
  182. package/dist/wire-content-hash.d.ts.map +1 -1
  183. package/dist/wire-content-hash.js +71 -0
  184. package/dist/wire-content-hash.js.map +1 -1
  185. package/package.json +5 -5
@@ -17,7 +17,12 @@
17
17
  // The daemon DB is SQLCipher (whole-file AES-256 at rest), never `node:sqlite`. `DaemonDatabase` is
18
18
  // the thin varargs surface; `openEncryptedDatabase` opens with a PRAGMA key and `resolveDbKey`
19
19
  // manages the single plaintext key file.
20
- import { wireContentHash } from "./wire-content-hash.js";
20
+ // `wireContentHash` is gone from this file on purpose: its ONE use was the receive-path cross-check,
21
+ // which now goes through `contentHashFor` so the comparison runs under the algorithm the sender
22
+ // named. A direct call here would be a hash computed without asking what the frame said (part B1).
23
+ import { contentHashFor, resolveContentHashAlg, CONTENT_HASH_ALGS } from "./wire-content-hash.js";
24
+ import { CAPACITY_REASONS } from "./refusal-reasons.js";
25
+ import { onPeerSaltFrame, ownSaltFrame, SALT_ADOPTION_LABELS, SALT_FREEZE_GUIDANCE, } from "./session-salt-agreement.js";
21
26
  import { openEncryptedDatabase, resolveDbKey, dbKeyPathFor, } from "./sqlcipher-db.js";
22
27
  import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
23
28
  import { ensureIdentitySchema } from "./db-identity-store.js";
@@ -34,14 +39,17 @@ import { MAX_SESSION_NODES, STANDING_RECEIVER_AGENT_NAME } from "./types.js";
34
39
  import { SessionConnectionGater } from "./session-connection-gater.js";
35
40
  import { SessionTree, sessionTreeLeafKindFromDb } from "./session-tree.js";
36
41
  import { CELLO_CONTENT_PROTOCOL_ID, NodeAutoNatService } from "@cello-protocol/transport";
37
- import { verify } from "@cello-protocol/crypto";
42
+ import { verify, buildMerkleTree, merkleRoot, generateSaltContribution, SESSION_SALT_BYTES } from "@cello-protocol/crypto";
38
43
  import { encodeSealPayload, MONIKER_RE, validateMoniker } from "@cello-protocol/protocol-types";
39
- import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex } from "./park-envelope.js";
44
+ import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex, ParkEnvelopeError, PARK_ENVELOPE_REASONS } from "./park-envelope.js";
40
45
  import { isValidMultiaddr } from "@cello-protocol/transport";
41
- import { AgentRelayClient, LEAF_KIND_CTRL, LEAF_KIND_MSG, isTerminalRelayRefusal, extractErrorMessage } from "./session-relay-client.js";
46
+ // `LEAF_KIND_MSG` is no longer imported here: `sendContent`'s `leafKind` stopped defaulting to it
47
+ // (B2b-1 review F4), so this file no longer names a default — every caller states its own kind.
48
+ import { AgentRelayClient, LEAF_KIND_CTRL, isTerminalRelayRefusal, extractErrorMessage } from "./session-relay-client.js";
42
49
  import { terminalRelayRefusal } from "./session-terminal-refusal.js";
43
50
  import { RelayReceiptStore } from "./relay-receipt-store.js";
44
51
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
52
+ import { addColumnIfMissing } from "./column-birth.js";
45
53
  import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
46
54
  /** SEC-1 / review M4: cap on the refused-parked-entry memo (remote-fed → must be bounded). */
47
55
  /**
@@ -52,6 +60,89 @@ import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gatewa
52
60
  */
53
61
  const AUTOACK_BROKER_GRACE_MS = 30_000;
54
62
  const MAX_REFUSED_PARKED_ENTRIES = 512;
63
+ /**
64
+ * Per-session cap on remembered unreadable-algorithm frames (`DOD-M15-SEALWIRE-1` part B1).
65
+ *
66
+ * Fed by a REMOTE party — a peer on a newer build refuses every frame it sends — so it needs a
67
+ * bound for the same reason `MAX_REFUSED_PARKED_ENTRIES` does. Small on purpose: the entries exist
68
+ * only to reconcile a refusal with its park-route redelivery, which happens within seconds, and
69
+ * losing an old one costs a log line rather than correctness.
70
+ */
71
+ const MAX_UNREADABLE_ALG_FRAMES = 64;
72
+ /**
73
+ * How long the first send waits for an in-flight salt agreement before giving up on it —
74
+ * `DOD-M15-SEALWIRE-1` B2b-2 constraint 2.
75
+ *
76
+ * The agreement is ONE round trip on a stream that is already open, so a healthy exchange finishes
77
+ * in milliseconds; this bound is not sized for the normal case, it is sized for how long an operator
78
+ * should wait before their message goes out unsalted instead of not going out.
79
+ *
80
+ * Five seconds because both errors cost real things. Too short and a merely slow counterparty makes
81
+ * the session permanently unsalted for no reason — the decision is irreversible, so the bound should
82
+ * be generous relative to the round trip. Too long and the first message of every conversation with
83
+ * a peer on an older build visibly hangs, which is the failure a user actually notices and blames
84
+ * the product for. This is only ever paid by a session that HAS an agreement outstanding: a
85
+ * park-only session never starts one and never waits (constraint 5).
86
+ */
87
+ const SALT_AGREEMENT_WAIT_MS = 5_000;
88
+ /**
89
+ * WHY a session is hashing unsalted — review Finding 1, and this exists because one sentence was
90
+ * carrying five different situations.
91
+ *
92
+ * `#saltForHashing` returns null for five distinct upstream conditions, and the single guidance
93
+ * string asserted one of them: *"expected when your counterparty runs a build that predates the salt
94
+ * agreement… start a new session once they upgrade."* An operator whose counterparty was merely
95
+ * OFFLINE when they sent their first message — the most common case by far, since a parked first
96
+ * message unsalts the session by design — read that and went and told a fully up-to-date
97
+ * counterparty to upgrade.
98
+ *
99
+ * That is the `directory_unreachable` shape this project keeps re-learning: the message names the
100
+ * exit point and points at the wrong machine. A closed set of reasons with its own guidance per
101
+ * reason is the fix, and a closed set is what stops a sixth condition quietly inheriting a fifth's
102
+ * explanation.
103
+ */
104
+ const UNSALTED_REASONS = {
105
+ /** No agreement was ever started — the counterparty has not connected. The park-only case. */
106
+ NO_AGREEMENT_STARTED: "no_agreement_started",
107
+ /** We announced and they did not answer inside the bound. */
108
+ AGREEMENT_TIMED_OUT: "agreement_timed_out",
109
+ /** They answered, terminally: they have already hashed content and can never adopt. */
110
+ PEER_CLOSED_ADOPTION: "peer_closed_adoption",
111
+ /** The session was torn down while the first send was still waiting. */
112
+ SESSION_TORN_DOWN: "session_torn_down",
113
+ /** This side already hashed, leafed, held or has in flight — adoption closed here. */
114
+ ADOPTION_CLOSED_LOCALLY: "adoption_closed_locally",
115
+ /** They answered in time and OUR OWN write failed. Nothing about their build is involved. */
116
+ OUR_PERSIST_FAILED: "our_persist_failed",
117
+ /**
118
+ * OUR announce never left this machine — review pass 2, F2. Reusing `AGREEMENT_TIMED_OUT` here
119
+ * told the operator *"your counterparty did not answer"* about a frame we never sent, which is the
120
+ * exact substitution this closed set was created to end, re-entering through the settle site the
121
+ * previous pass asked for.
122
+ */
123
+ ANNOUNCE_FAILED: "our_announce_failed",
124
+ /**
125
+ * They answered, we stored it, and reading it back FAILED — review pass 2, F4. Distinct from
126
+ * `OUR_PERSIST_FAILED` because the two point at different log lines, and the guidance names one.
127
+ */
128
+ OUR_READ_FAILED: "our_read_failed",
129
+ };
130
+ /**
131
+ * What the operator should DO about each. TOTAL by construction — a `Record` over the union, so a
132
+ * new reason cannot be added without something for the reader to act on. Same shape, and the same
133
+ * reason, as `refusal-reasons.ts`: that file exists because a free-form `reason: string` let a new
134
+ * code slip past every test in its own guard file.
135
+ */
136
+ const UNSALTED_GUIDANCE = {
137
+ [UNSALTED_REASONS.NO_AGREEMENT_STARTED]: "Your counterparty was not connected when you sent this, so there was nobody to agree a salt with — most often they are simply offline and this message is going to their relay mailbox. Nothing is wrong with either build. The agreement only runs at session open, so this session stays unsalted even after they come online; a session started while you are both connected will be salted.",
138
+ [UNSALTED_REASONS.AGREEMENT_TIMED_OUT]: "Your counterparty was connected but did not answer the salt agreement in time. Almost always they are on a build that predates it, in which case this is expected and permanent for this session — start a new session once they upgrade. If you know they are on the same version, look for session.salt.persist.failed on this side and session.salt.announce.failed on either.",
139
+ [UNSALTED_REASONS.PEER_CLOSED_ADOPTION]: "Your counterparty declined the salt because their side of this session had already hashed messages — their conversation started before yours could agree one. Both builds are fine and both sides know. Start a new session if you want the protection.",
140
+ [UNSALTED_REASONS.SESSION_TORN_DOWN]: "This session was closed or reset while the message was still being prepared. This line is about the salt only; look for the close or freeze event just before it for what actually happened to the session.",
141
+ [UNSALTED_REASONS.ADOPTION_CLOSED_LOCALLY]: "This session had already hashed or sent messages before a salt could be agreed, so adopting one now would leave half the conversation verifiable by one rule and half by another. That is permanent for this session and both builds are fine. Start a new session if you want the protection.",
142
+ [UNSALTED_REASONS.OUR_PERSIST_FAILED]: "Your counterparty answered in time and THIS side failed to store the agreed salt — the fault is local, not theirs. Do not ask them to upgrade. Look for session.salt.persist.failed immediately above this line; it names the write that failed.",
143
+ [UNSALTED_REASONS.ANNOUNCE_FAILED]: "THIS side could not send the salt agreement to your counterparty — the frame never left this machine, so they were never asked and their build is not involved. Do not ask them to upgrade. Look for session.salt.announce.failed immediately above this line for the connection error; most often the direct link to them dropped between connecting and sending.",
144
+ [UNSALTED_REASONS.OUR_READ_FAILED]: "A salt was agreed for this session and THIS side could not read it back — the fault is local storage, not your counterparty. Do not ask them to upgrade. Look for session.salt.read.failed immediately above this line; a wrong-width or unreadable row is named there.",
145
+ };
55
146
  /**
56
147
  * DOD-M12B-ACK-1 — inbound `/cello/content/1.0.0` streams allowed per connection.
57
148
  *
@@ -361,6 +452,24 @@ export class SessionNodeManager {
361
452
  #leafFetchGraceMs = LEAF_FETCH_GRACE_MS;
362
453
  #standingReceivers = new Map();
363
454
  #standingReceiverCreating = new Set();
455
+ /**
456
+ * (agentName, sessionId) → the peer id that session's `session_offer` named as the dialer.
457
+ *
458
+ * KEYED BY SESSION, not by agent (review F1). Keyed by agent alone, two overlapping inbound
459
+ * sessions destroyed each other: offer P narrows to peer P, offer Q overwrites with peer Q, then
460
+ * assignment P arrives and MISMATCHES — refusing a legitimate session while accusing the directory
461
+ * of naming two dialers, which it had not. It then cleared Q's record too, so assignment Q passed
462
+ * unchecked. An attacker could therefore disarm the check by provoking one mismatch.
463
+ *
464
+ * The gap between offer and assignment spans a cross-region threshold ceremony, so overlapping
465
+ * sessions are ordinary, not exotic. `DOD-M15-OFFER-EXPIRY-1` already prescribed this fix shape —
466
+ * "bind the narrowing to the session id it came from" — and the session id was in hand at both
467
+ * ends the whole time.
468
+ *
469
+ * DOD-M15-OFFER-SIGNED-1: read back when the SIGNED assignment arrives, so an unsigned offer
470
+ * cannot name a peer the signed document does not.
471
+ */
472
+ #offeredDialer = new Map();
364
473
  // M8B F14: agents that SHOULD have a standing receiver — marked by
365
474
  // ensureStandingReceiverForAgent (cello_start_agent / the inbound accept path) and
366
475
  // unmarked by removeStandingReceiverForAgent (cello_set_agent_offline). Consulted by the
@@ -446,13 +555,165 @@ export class SessionNodeManager {
446
555
  // safe to close — the close would be signed, refused as `leaf_count_mismatch`, and the receipt
447
556
  // lost for good.
448
557
  #diverged = new Set();
558
+ /**
559
+ * Rehydrate `#diverged` from `sessions.diverged_at` — `DOD-M15-DIVERGE-DURABLE-1`.
560
+ *
561
+ * The Set stays as the hot read (the seal gate consults it per close), and the column is the
562
+ * truth. Loaded once at boot rather than queried per read so the gate's cost does not change.
563
+ */
564
+ #loadDivergedFromDb() {
565
+ if (!this.#db)
566
+ return;
567
+ const rows = this.#db
568
+ .prepare(`SELECT s.session_id AS sid, a.agent_name AS agent
569
+ FROM sessions s JOIN agents a ON a.agent_id = s.agent_id
570
+ WHERE s.diverged_at IS NOT NULL`)
571
+ .all();
572
+ for (const r of rows)
573
+ this.#diverged.add(this.#k(r.agent, r.sid));
574
+ if (rows.length > 0) {
575
+ this.#logger.info("session.diverged.restored", {
576
+ count: rows.length,
577
+ impact: "these sessions provably cannot seal bilaterally and are refused at the seal gate — before " +
578
+ "this was durable, a restart made them read as healthy",
579
+ });
580
+ }
581
+ }
582
+ /**
583
+ * Record that this session's tree and the relay's counter have provably parted.
584
+ *
585
+ * Idempotent, and deliberately does NOT touch `updated_at`: that column drives the inbox's
586
+ * last-spoke ordering, and divergence is not activity.
587
+ */
588
+ markSessionDiverged(agentName, sessionId) {
589
+ this.#diverged.add(this.#k(agentName, sessionId));
590
+ if (!this.#db)
591
+ return;
592
+ /**
593
+ * KEYED ON (agent_id, session_id) — review F3, and the loopback case makes it concrete.
594
+ *
595
+ * This was `WHERE session_id = ?` alone. The table's PK is composite for a documented reason
596
+ * (`DOD-LOOP-1`, on the CREATE TABLE above): **two of one operator's agents can hold both ends
597
+ * of the SAME session_id on ONE daemon**, so `sessions` holds two rows. Unkeyed, marking one
598
+ * side diverged marked BOTH, and the clear below wiped BOTH — so side B sealing its half
599
+ * erased side A's divergence, and after a restart A's seal gate read healthy and signed a close
600
+ * that could only be refused. The line's own defect, produced by the line's own clear.
601
+ *
602
+ * Every other per-session UPDATE in this file keys on both columns; these two were the
603
+ * exceptions.
604
+ */
605
+ this.#db
606
+ .prepare("UPDATE sessions SET diverged_at = ? WHERE agent_id = ? AND session_id = ? AND diverged_at IS NULL")
607
+ .run(Date.now(), this.#requireAgentId(agentName), sessionId);
608
+ }
609
+ /** Whether this session has provably parted from the relay's ordering. */
610
+ isSessionDiverged(agentName, sessionId) {
611
+ return this.#diverged.has(this.#k(agentName, sessionId));
612
+ }
613
+ // DOD-M15-FRAME-1 (review F1): sessions frozen because a frame failed to verify against the
614
+ // expected counterparty. Consulted by `reviveSessionNode` — a teardown writes `interrupted`,
615
+ // which is the REVIVABLE status, so without this the next `cello_receive` silently rebuilt the
616
+ // session and re-admitted the same peer. NOT cleared by `#evictSessionCaches`: the freeze is a
617
+ // fact about the session, not about the node that was torn down to enforce it.
618
+ //
619
+ // DOD-M15-SEALWIRE-1 (part A): a MAP, not a Set, and the value is load-bearing. It was a Set when
620
+ // the identity freeze was the only freeze, so `reviveSessionNode` could hardcode its refusal —
621
+ // *"a message failed to verify against the expected counterparty's key"*. A salt disagreement now
622
+ // freezes through the same path, and that sentence would accuse a counterparty who did nothing:
623
+ // the ordinary cause is two builds that do not match. The refusal carries the freezing site's own
624
+ // words instead.
625
+ #frozenSessions = new Map();
626
+ /**
627
+ * DOD-M15-SEALWIRE-1 bullet 6 (part A) — the salt agreement's two pieces of per-session state.
628
+ *
629
+ * `#saltContributions` — OUR random half, MINTED ONCE PER SESSION. This being a map rather than a
630
+ * fresh call at each send is the whole correctness of the exchange: we re-announce on every
631
+ * counterparty connect, and a contribution regenerated per reconnect would have both sides
632
+ * deriving against a moving value with the fingerprints never settling — a session that
633
+ * reconnects and still disagrees, which reads as a network fault rather than a bug here.
634
+ *
635
+ * `#sessionSalts` — a CACHE over `sessions.content_salt`, which is the durable copy. Both are
636
+ * cleared by `#evictSessionCaches`: the contribution is worthless once a salt exists, and the
637
+ * salt is re-read from the row on revival, which is exactly what Decision #8 persists it for.
638
+ */
639
+ #saltContributions = new Map();
640
+ #sessionSalts = new Map();
641
+ /**
642
+ * The peer half we last answered with a repair, hex — review F14, and it is what makes the repair
643
+ * TERMINATE. Without it, two daemons that already hold the same salt trade contributions forever
644
+ * once a reconnect leaves a stale copy queued on each side. See `onPeerSaltFrame`'s
645
+ * `alreadyRepairedAgainstPeerHalf`.
646
+ */
647
+ #saltRepairedAgainst = new Map();
648
+ /**
649
+ * ─── B2b-2 state: what the SEND path needs that the row cannot answer ─────────────────────────
650
+ *
651
+ * `#saltPending` — an agreement that has actually gone out and not yet been answered. The first
652
+ * send waits on it (constraint 2). Absent means *nothing is in flight*, which is not the same as
653
+ * "no salt": a park-only session never starts one at all, and must not wait (constraint 5).
654
+ *
655
+ * `#hashedWithoutSalt` — this session has already computed an unsalted content hash. Decision #8
656
+ * closes adoption at the moment content is HASHED, and for a session's first message that is a
657
+ * full network round trip before any leaf, held row or in-flight entry exists. Without this flag
658
+ * the frontier count reads empty for exactly the window in which adopting would split the
659
+ * transcript.
660
+ *
661
+ * `#unsaltedAnnounced` — the fallback has been stated for this session. Decision #15 says once per
662
+ * session; a per-message warning is a filter waiting to be written.
663
+ *
664
+ * All three are per-session and in-memory by design, and are dropped with the rest of a session's
665
+ * caches on eviction — a revived session re-reads its salt from the row, re-derives its frontier
666
+ * from durable state, and starts a fresh agreement if it reconnects.
667
+ */
668
+ #saltPending = new Map();
669
+ /**
670
+ * HOW THE LAST AGREEMENT ENDED, kept after `#saltPending` is cleared.
671
+ *
672
+ * ⚠️ FOUND BY FALSIFYING MY OWN FIX. `#settleSaltPending` deletes the pending entry, so a send that
673
+ * arrives AFTER an agreement has already failed finds nothing pending and is told
674
+ * `no_agreement_started` — *"your counterparty was not connected"* — when in fact they were
675
+ * connected and our own dial to them failed. The outcome was observable only to a send that
676
+ * happened to already be waiting, which is the minority case.
677
+ *
678
+ * So the verdict outlives the wait. An ABSENT entry still means what it always meant — no
679
+ * agreement was ever started, the park-only case — and that distinction is the whole reason this
680
+ * is a separate map rather than a default.
681
+ */
682
+ #saltLastOutcome = new Map();
683
+ #hashedWithoutSalt = new Map();
684
+ #unsaltedAnnounced = new Set();
449
685
  // DOD-M12B-ACK-1: pending linger-resets for inbound content streams the peer has not closed.
450
686
  // Held so shutdown can drop them rather than leave timers pointing at a torn-down node.
451
687
  #lingeringStreams = new Set();
452
688
  // M7-UPGRADE-002: sessions whose content integrity could NOT be verified (a content_hash
453
689
  // mismatch = tamper was observed). The auto-acknowledge gate (SI-002) refuses to auto-co-sign
454
690
  // for a desynced session — B must never blind-sign a tail it cannot verify. Keyed by sessionId hex.
455
- #contentDesynced = new Set();
691
+ //
692
+ // DOD-M15-SEALWIRE-1 part B1 (review F1): a MAP, not a Set, and the value is the whole fix.
693
+ //
694
+ // It was a Set because a content_hash mismatch was the only way in, so "present" could mean
695
+ // "tampered". B1 added two more ways for a frame to fail verification — an algorithm we cannot
696
+ // read, and a salted frame we hold no salt for — and BOTH ARE ORDINARY. An honest peer on a newer
697
+ // build produces the first. So the gate must still fire (never sign content you could not verify)
698
+ // while the LABEL must not accuse anyone: `unverifiable` is not `tampered`.
699
+ //
700
+ // One structure with two labels rather than two sets, deliberately: a second set is a second thing
701
+ // for every gate to remember to consult, and the one that gets forgotten is the one that matters.
702
+ #contentDesynced = new Map();
703
+ /**
704
+ * Frames refused because they named a content-hash algorithm this build cannot read, keyed by
705
+ * session → the refused frame's content hash → the name it used. Review F2, corrected by F-D.
706
+ *
707
+ * ⚠️ KEYED BY THE FRAME, NOT THE SESSION, and the first version was keyed by the session. That
708
+ * made it fire on the NORMAL case: after one junk-alg frame, every subsequent park recovery on
709
+ * that session logged a WARN forever, for entirely unrelated messages — and the text asserted the
710
+ * two events were "the same message arriving twice by different routes", which nothing had
711
+ * established. A warning that fires on the benign steady state is not a signal.
712
+ *
713
+ * Hash-keyed, the claim becomes a fact and the event fires exactly once per affected message: the
714
+ * entry is removed the moment it is reconciled.
715
+ */
716
+ #unreadableAlgSeen = new Map();
456
717
  // DOD-MSG-4 (strict in-order): the RELAY is the ordering authority (Structure 2). For each
457
718
  // message the relay witnesses, it delivers B a (content_hash -> canonical sequence) binding via
458
719
  // the leaf_deliver stream. B records it here — keyed #k(agent,session) -> (contentHashHex -> seq)
@@ -517,6 +778,13 @@ export class SessionNodeManager {
517
778
  * The router it calls never logs them.
518
779
  */
519
780
  #onDocumentFrame = null;
781
+ /**
782
+ * DOD-DOC-SCREEN-CLASSIFY-1: the classify-only half of the hook above — is this a document
783
+ * frame, deciding nothing else. Injected together with it so the two cannot disagree about what
784
+ * a document frame is. Null means every frame takes the full inbound screen, exactly as before
785
+ * the document layer existed.
786
+ */
787
+ #isDocumentFrame = null;
520
788
  // A send is NOT fire-and-forget. After a content_frame is delivered over the direct session
521
789
  // channel, the sender arms a TTF timer and waits for an unsigned, transport-authenticated
522
790
  // `persisted` delivery ACK on the same /cello/content/1.0.0 protocol. A persisted ACK cancels the
@@ -749,7 +1017,17 @@ export class SessionNodeManager {
749
1017
  * fire this from an async backstop with no live caller (the TTF-expiry path) may ignore the
750
1018
  * result — the deposit itself and its logging are unchanged either way.
751
1019
  */
752
- async #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) {
1020
+ /**
1021
+ * `contentHashAlg` is `string | undefined`, NOT optional — B2b-1 review F4's shape, applied to the
1022
+ * last place it was missing.
1023
+ *
1024
+ * Optional, dropping it at a call site was neither a typecheck error nor a test failure, because
1025
+ * absent silently means `sha256` and that is the only value in play today. Measured: the
1026
+ * direct-dial-fail route's mutant SURVIVED the whole daemon suite. Requiring the argument — even
1027
+ * when its value is `undefined` — forces each of the three callers to state what this message was
1028
+ * hashed under, so a new fourth caller cannot omit it by accident.
1029
+ */
1030
+ async #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) {
753
1031
  // Fault injection FIRST, so it reproduces the real shape: the refusal happens at the same point
754
1032
  // the live hook refuses (before any deposit), with the same event and the same `cause`.
755
1033
  if (this.#parkFaultRemaining > 0) {
@@ -785,6 +1063,9 @@ export class SessionNodeManager {
785
1063
  // on recover too (sealed INTO the ciphertext envelope — INV-3: the relay still sees only ciphertext).
786
1064
  structure1Cbor,
787
1065
  structure2Cbor,
1066
+ // B2b: the park route must name the same algorithm the direct frame did, or the recipient
1067
+ // verifies the same message two different ways depending on which route it took.
1068
+ contentHashAlg,
788
1069
  });
789
1070
  // DOD-LEAVEMSG-1 (reviewer HIGH fix): check the TYPED result, not just "didn't throw" — the
790
1071
  // production hook's own failure branches (standing receiver unavailable, relay explicitly
@@ -802,12 +1083,30 @@ export class SessionNodeManager {
802
1083
  return { outcome: "parked" };
803
1084
  }
804
1085
  catch (err) {
1086
+ /**
1087
+ * THE CODE GOES IN `cause`, THE PARAGRAPH GOES IN THE LOG — B2b-2 constraint 6.
1088
+ *
1089
+ * `cause` is documented as the machine-readable half and is handed to callers that branch on
1090
+ * it. Putting `err.message` there meant a producer-side refusal — this build cannot seal that
1091
+ * algorithm — was indistinguishable from a relay outage, so it inherited the relay's guidance:
1092
+ * *"queued, and will be re-sent when the relay link is back."* The relay was never asked, and
1093
+ * every re-park throws in the same place, so that sends the operator to the wrong subsystem
1094
+ * and then tells them to wait for a recovery that cannot happen.
1095
+ *
1096
+ * `instanceof`, not a string test: an untyped failure keeps the old behaviour exactly, so this
1097
+ * narrows what the caller can distinguish without changing anything it could not.
1098
+ */
1099
+ const coded = err instanceof ParkEnvelopeError ? err : null;
805
1100
  this.#logger.warn("content.park.deposit.failed", {
806
1101
  sessionId,
807
1102
  contentHash: contentHashHex,
1103
+ ...(coded === null ? {} : { reason: coded.reason, detail: coded.detail }),
808
1104
  error: err instanceof Error ? err.message : String(err),
809
1105
  });
810
- return { outcome: "refused", cause: err instanceof Error ? err.message : String(err) };
1106
+ return {
1107
+ outcome: "refused",
1108
+ cause: coded?.reason ?? (err instanceof Error ? err.message : String(err)),
1109
+ };
811
1110
  }
812
1111
  }
813
1112
  // ─── Initialization ──────────────────────────────────────────────────────
@@ -847,6 +1146,47 @@ export class SessionNodeManager {
847
1146
  for (const ddl of [
848
1147
  "ALTER TABLE sessions ADD COLUMN message_count INTEGER NOT NULL DEFAULT 0",
849
1148
  "ALTER TABLE sessions ADD COLUMN interrupted_at TEXT",
1149
+ /**
1150
+ * Decisions Carried #8 — THE SESSION SALT, persisted.
1151
+ *
1152
+ * Agreed once at session open from BOTH sides' random contributions, and unchanged for the
1153
+ * life of the session. It is NOT a key: it decrypts nothing, and it is what lets this
1154
+ * operator's own transcript stay verifiable — the content hash is recomputed from stored
1155
+ * plaintext on the receive path and again for any later check, and salted it is underivable
1156
+ * without this value.
1157
+ *
1158
+ * PERSISTED because the alternative is silent corruption. ⚠️ FUTURE TENSE, deliberately
1159
+ * (review F10): NOTHING WRITES OR READS THIS COLUMN YET. `DOD-M15-SEALWIRE-1` will add the
1160
+ * contribution exchange and the lookup — "does this session already have a salt? yes → use it,
1161
+ * no → agree one" — and without the column that lookup would fail after a restart, mint a
1162
+ * fresh salt, and split the transcript at the crash: every leaf before it unverifiable, with
1163
+ * nothing saying so. The column lands now because it must exist before the code that needs it.
1164
+ *
1165
+ * NULL for every session opened before this column existed; those keep the unsalted hash.
1166
+ */
1167
+ "ALTER TABLE sessions ADD COLUMN content_salt BLOB",
1168
+ /**
1169
+ * DOD-M15-FREEZE-STATUS-1 — carried here for the OTHER LANE (`CELLO_Support`), agreed in
1170
+ * session `e3adcaa7…`. Two lanes must not both edit this file (§2e, one file two branches), so
1171
+ * the columns land in one migration and every line of behaviour stays on their side. Nothing
1172
+ * in this lane reads or writes them.
1173
+ *
1174
+ * `frozen_at` epoch-ms when `#freezeOnIdentityFailure` fired. NULL = never frozen.
1175
+ * `frozen_reason` the `reason` already passed to that method. NULL iff `frozen_at` is NULL.
1176
+ *
1177
+ * ⚠️ THE WRITE MUST LAND BEFORE `destroySessionNode`, NOT AFTER — FRAME-1 review F1's
1178
+ * ordering, and the reason the in-memory `#frozenSessions.add` already sits before the
1179
+ * teardown. `destroySessionNode` writes `interrupted`, which is the REVIVABLE status, so a
1180
+ * durable mark landing after it lets a read race the teardown and revive the session out from
1181
+ * under the freeze — the disk reproducing the bug the memory mark was moved early to fix.
1182
+ *
1183
+ * Why it earns a slot rather than waiting: `#frozenSessions` is memory-only today, so a
1184
+ * restart UN-FREEZES a session that was frozen because a party signed with a key that was not
1185
+ * the counterparty's. The next read revives it and re-admits that peer, while the log still
1186
+ * says the session will not be revived.
1187
+ */
1188
+ "ALTER TABLE sessions ADD COLUMN frozen_at INTEGER",
1189
+ "ALTER TABLE sessions ADD COLUMN frozen_reason TEXT",
850
1190
  // MSG-001-3b (MSG-2 startup-flush): persist the session's relay endpoint so the
851
1191
  // crash-backstop flush can deposit un-acked content after a restart, when the
852
1192
  // in-memory entry is gone. relay_addrs is a JSON array of multiaddr strings.
@@ -893,6 +1233,18 @@ export class SessionNodeManager {
893
1233
  // several times a day re-runs the whole budget against a hopeless session on every boot.
894
1234
  "ALTER TABLE sessions ADD COLUMN restart_seal_gave_up_at INTEGER",
895
1235
  "ALTER TABLE sessions ADD COLUMN restart_seal_gave_up_reason TEXT",
1236
+ // DOD-M15-DIVERGE-DURABLE-1: epoch-ms when this session's tree and the relay's counter
1237
+ // provably parted, so it can never seal bilaterally. NULL = not diverged.
1238
+ //
1239
+ // DURABLE, and the reason is that the read site cannot tell "not diverged" from "forgotten":
1240
+ // both are false and both read READY. `#diverged` was in memory, so a restart turned a
1241
+ // session that provably cannot seal into one the gate was happy to close.
1242
+ //
1243
+ // NOT the trade `frontier-mismatch.ts` makes on purpose. A frontier mismatch is re-detected
1244
+ // by the very next close, so losing it costs a recomputation. Divergence is re-detected only
1245
+ // by the next send that gets an ack behind the frontier — which on a finished conversation
1246
+ // never comes. Losing it costs a WRONG ANSWER.
1247
+ "ALTER TABLE sessions ADD COLUMN diverged_at INTEGER",
896
1248
  ]) {
897
1249
  try {
898
1250
  this.#db.exec(ddl);
@@ -1063,6 +1415,27 @@ export class SessionNodeManager {
1063
1415
  direction TEXT NOT NULL, -- 'sent' | 'received'
1064
1416
  blob BLOB NOT NULL, -- readable plaintext bytes (whole-DB SQLCipher-encrypted at rest)
1065
1417
  created_at INTEGER NOT NULL,
1418
+ -- ─── DOD-M15-SEALWIRE-1 bullet 5: the row proves AUTHORSHIP, or says it cannot ──────────
1419
+ --
1420
+ -- Before this, a row was (message, direction) and attribution came entirely from local
1421
+ -- session state: "this arrived on the socket I believed was Bob's". That is fine while the
1422
+ -- transcript is only ever read by its owner, and worthless the moment it is shown to anyone
1423
+ -- else — which is the whole point of a notarized record.
1424
+ --
1425
+ -- sender_sig is the Structure-2 signature, and it is stored ONLY after the receiver has
1426
+ -- already verified it against the pubkey inside the sender's own signed bytes
1427
+ -- (#recordFrameOrdering). A stored signature here is a VERIFIED one, never a claimed one.
1428
+ --
1429
+ -- attribution is NOT NULL ON PURPOSE, and it is the load-bearing column. There is a soft
1430
+ -- path — session.content.ordering.decode_failed falls back to hash-dedup — that ingests a
1431
+ -- message with no verified signature, so rows legitimately without one WILL exist. A
1432
+ -- nullable signature column and nothing else would rebuild the defect this bullet exists to
1433
+ -- fix: a table that IMPLIES every row carries authorship proof, where some carry none and
1434
+ -- nothing distinguishes them. Forcing every writer to name which it is makes silent NULL
1435
+ -- impossible rather than merely discouraged.
1436
+ sender_pubkey TEXT, -- from INSIDE the sender's signed bytes; NULL unless verified
1437
+ sender_sig BLOB, -- the VERIFIED Structure-2 signature; NULL unless verified
1438
+ attribution TEXT NOT NULL DEFAULT 'local_session_state', -- verified_signature | self_authored | local_session_state
1066
1439
  PRIMARY KEY (agent_id, session_id, sequence, direction)
1067
1440
  )
1068
1441
  `);
@@ -1114,6 +1487,32 @@ export class SessionNodeManager {
1114
1487
  PRIMARY KEY (agent_id, contact_pubkey, signal_hash)
1115
1488
  )
1116
1489
  `);
1490
+ /**
1491
+ * DOD-M15-SEALWIRE-1 bullet 5: authorship columns on an EXISTING transcript.
1492
+ *
1493
+ * BEFORE `migrateSessionTablesToAgentId` — the rebuild copies the intersection of old and new
1494
+ * columns, so a column added after it would be dropped on the upgrade boot and re-added empty.
1495
+ * These have their second entry in that migration's pinned DDL; `DOD-M15-MIGRATION-GUARD-1`
1496
+ * fails the build if the two ever disagree.
1497
+ *
1498
+ * `addColumnIfMissing` rather than a bare try/catch: it swallows ONLY `duplicate column name`
1499
+ * and rethrows anything else, so broken DDL cannot be mistaken for "already applied".
1500
+ */
1501
+ // Written as three LITERAL statements rather than a loop over a column array. A loop needs its
1502
+ // own parser in the guard (as retry_queue does); literals are read by the guard's generic one,
1503
+ // so these are replayed automatically and cannot fall outside it.
1504
+ addColumnIfMissing(this.#db, this.#logger, {
1505
+ table: "transcript", column: "sender_pubkey",
1506
+ sql: "ALTER TABLE transcript ADD COLUMN sender_pubkey TEXT",
1507
+ });
1508
+ addColumnIfMissing(this.#db, this.#logger, {
1509
+ table: "transcript", column: "sender_sig",
1510
+ sql: "ALTER TABLE transcript ADD COLUMN sender_sig BLOB",
1511
+ });
1512
+ addColumnIfMissing(this.#db, this.#logger, {
1513
+ table: "transcript", column: "attribution",
1514
+ sql: "ALTER TABLE transcript ADD COLUMN attribution TEXT NOT NULL DEFAULT 'local_session_state'",
1515
+ });
1117
1516
  const contactCols = this.#db.prepare("PRAGMA table_info(contacts)").all();
1118
1517
  if (!contactCols.some((c) => c.name === "moniker")) {
1119
1518
  this.#db.exec("ALTER TABLE contacts ADD COLUMN moniker TEXT");
@@ -1127,6 +1526,16 @@ export class SessionNodeManager {
1127
1526
  // database it already exists here and is re-keyed in the same transaction; on a fresh one it is
1128
1527
  // absent, is skipped, and RetryQueue then creates it directly in the re-keyed shape.
1129
1528
  migrateSessionTablesToAgentId(this.#db, this.#logger);
1529
+ /**
1530
+ * DOD-M15-DIVERGE-DURABLE-1: rehydrate the divergence set from `sessions.diverged_at`.
1531
+ *
1532
+ * AFTER `migrateSessionTablesToAgentId`, not with the column migrations that create the field.
1533
+ * The query joins `sessions.agent_id` to `agents`, and on a database written before REMOVE-001
1534
+ * that column does not exist until this migration adds it — placing the load earlier failed
1535
+ * with `no such column: s.agent_id` on exactly those legacy databases, which are the ones a
1536
+ * restart matters most for.
1537
+ */
1538
+ this.#loadDivergedFromDb();
1130
1539
  // DOD-TIER-1 (address-book Step 1): give `contacts` its tier metadata (tier / provenance /
1131
1540
  // last_offered_moniker / away_message). Pure ADD COLUMN, no rebuild — so it runs AFTER the
1132
1541
  // agent-id re-key above (it never needs to appear in that migration's pinned DDL) and BEFORE any
@@ -1269,6 +1678,101 @@ export class SessionNodeManager {
1269
1678
  * directory for the OFFLINE tree rebuild. Empty when no leaves were logged (e.g. a direct-only session
1270
1679
  * with no relay witness) — the caller then has nothing to carry and the seal stays bilateral/pending.
1271
1680
  */
1681
+ /**
1682
+ * REBUILD THE CERTIFIED ROOT FROM THIS DAEMON'S OWN LEAVES — `DOD-M15-SEALWIRE-1` bullet 2.
1683
+ *
1684
+ * The receipt used to prove only that the directory signed SOMETHING: the client took the sealed
1685
+ * root off the wire, confirmed the directory's signature over those bytes, stored it, and threw
1686
+ * away the root it had computed a step earlier. At co-signing time that means **your key signs a
1687
+ * root you never checked.**
1688
+ *
1689
+ * Bullet 1 moved the certified root into the content-hash domain, which is the domain this daemon
1690
+ * can actually rebuild — each carry leaf's `content_hash` is the leaf hash (RFC 6962 §2.1 "hash"
1691
+ * leaves are used as-is), and the carry is ordered by the relay's canonical `sequence_number`,
1692
+ * which is the order the directory rebuilds in.
1693
+ *
1694
+ * ─── Why this returns "cannot judge" instead of always answering ───────────────────────────
1695
+ *
1696
+ * A root comparison that is WRONG makes every session unsealable, and force-abandon — no receipt —
1697
+ * becomes the only exit. That failure is worse than the one being guarded, and this file already
1698
+ * carries two comments saying so about other gates.
1699
+ *
1700
+ * The carry is this daemon's view, and it is not guaranteed complete at the instant a certificate
1701
+ * arrives: the counterparty's SEAL ctrl leaf is what TRIGGERS the seal, so it may not have been
1702
+ * witnessed here yet. So completeness is checked FIRST, against the certificate's own leaf count.
1703
+ * A short carry means this daemon cannot judge — which is a different answer from "the roots
1704
+ * disagree", and conflating them would turn a local timing gap into an accusation.
1705
+ */
1706
+ verifyCertifiedRoot(agentPubkeyHex, sessionIdHex, certifiedRoot, certifiedLeafCount) {
1707
+ const carry = this.getSealCarry(agentPubkeyHex, sessionIdHex);
1708
+ if (carry.length === 0)
1709
+ return { verdict: "cannot_judge", reason: "no_carry" };
1710
+ /**
1711
+ * COMPLETENESS IS ESTABLISHED FROM THE CARRY'S OWN EVIDENCE, NEVER FROM THE CERTIFICATE.
1712
+ *
1713
+ * Review F3, and the first cut had this exactly backwards. It gated on
1714
+ * `carry.length !== certifiedLeafCount`, where `certifiedLeafCount` is a field the DIRECTORY
1715
+ * chooses and signs — so the party being checked controlled whether it was checked. A directory
1716
+ * certifying a root over a different conversation had only to state a `leaf_count` that did not
1717
+ * match, and the client answered "cannot judge" and accepted. The signature still verified,
1718
+ * because the count is signed inside the same TBS.
1719
+ *
1720
+ * That is the hole §2b names in as many words: *"an attacker who wants to evade a mismatch check
1721
+ * simply never supplies a checkable proof. Treating 'we could not tell' as harmless is the
1722
+ * hole."* I defended against a false POSITIVE and left the false NEGATIVE one field away.
1723
+ *
1724
+ * The carry can answer the question by itself. A complete bilateral leaf set is:
1725
+ * - sequences contiguous from 1 — no gap where a leaf this daemon never saw would sit; and
1726
+ * - exactly two SEAL ctrl leaves, from two DISTINCT senders — which is what a bilateral seal
1727
+ * is, and is the condition that says the counterparty's closing leaf has landed here.
1728
+ * Both predicates already exist in `seal-escalation.ts`; this reuses their shape rather than
1729
+ * inventing a second opinion about the same question.
1730
+ *
1731
+ * When the carry IS self-evidently complete, a `leaf_count` that disagrees is no longer "I
1732
+ * cannot tell" — it is the certificate describing a different leaf set, which is a MISMATCH.
1733
+ */
1734
+ const sequences = carry.map((l) => l.sequenceNumber).sort((a, b) => a - b);
1735
+ const contiguousFromOne = sequences.every((n, i) => n === i + 1);
1736
+ const ctrlSenders = new Set(carry.filter((l) => l.leafKind === LEAF_KIND_CTRL).map((l) => l.senderPubkeyHex));
1737
+ const selfEvidentlyComplete = contiguousFromOne && ctrlSenders.size === 2;
1738
+ if (!selfEvidentlyComplete) {
1739
+ return {
1740
+ verdict: "cannot_judge",
1741
+ reason: contiguousFromOne
1742
+ ? `carry_incomplete: ${ctrlSenders.size} of 2 SEAL ctrl leaves witnessed here`
1743
+ : `carry_noncontiguous: hold ${carry.length} leaves with a gap in the relay sequence`,
1744
+ };
1745
+ }
1746
+ if (carry.length !== certifiedLeafCount) {
1747
+ // The carry proves itself complete and the certificate claims a different size, so the
1748
+ // certificate is over a different leaf set. Accusing is correct here.
1749
+ return {
1750
+ verdict: "mismatch",
1751
+ ownRootHex: null, // no root computed — the sets differ in SIZE, which is decisive on its own
1752
+ detail: `leaf_count_disagrees: this daemon holds a provably complete ${carry.length}-leaf set, the certificate claims ${certifiedLeafCount}`,
1753
+ };
1754
+ }
1755
+ const inputs = [];
1756
+ for (const leaf of carry) {
1757
+ let contentHash;
1758
+ try {
1759
+ // Canonical Structure 1 is [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp].
1760
+ contentHash = decode(leaf.structure1Cbor)[1];
1761
+ }
1762
+ catch {
1763
+ return { verdict: "cannot_judge", reason: "structure1_decode_failed" };
1764
+ }
1765
+ if (!(contentHash instanceof Uint8Array) || contentHash.length !== 32) {
1766
+ return { verdict: "cannot_judge", reason: "structure1_content_hash_missing" };
1767
+ }
1768
+ inputs.push({ kind: "hash", data: contentHash });
1769
+ }
1770
+ const ownRoot = merkleRoot(buildMerkleTree(inputs));
1771
+ const ownRootHex = Buffer.from(ownRoot).toString("hex");
1772
+ return Buffer.compare(Buffer.from(ownRoot), Buffer.from(certifiedRoot)) === 0
1773
+ ? { verdict: "match" }
1774
+ : { verdict: "mismatch", ownRootHex, detail: "root_disagrees: same leaf count, different leaves or different order" };
1775
+ }
1272
1776
  getSealCarry(agentPubkeyHex, sessionIdHex) {
1273
1777
  if (!this.#sealLeafStore && this.#db) {
1274
1778
  this.#sealLeafStore = new SessionSealLeafStore(this.#db, this.#logger);
@@ -1286,16 +1790,40 @@ export class SessionNodeManager {
1286
1790
  * lost row only cost the unread count. Delivery reads the transcript now, so a swallowed received
1287
1791
  * row is TOTAL content loss and the caller has to know.
1288
1792
  */
1289
- recordTranscriptMessage(agentName, sessionId, sequence, direction, plaintext, correlationId) {
1793
+ recordTranscriptMessage(agentName, sessionId, sequence, direction, plaintext, correlationId,
1794
+ /**
1795
+ * DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof, when there is one.
1796
+ *
1797
+ * Optional because there legitimately is not always one — the ordering decode can fail SOFT and
1798
+ * the message is still ingested via hash-dedup. Optional is NOT the same as unremarked: absence
1799
+ * is written into the row as `attribution = 'local_session_state'`, so a reader can tell a row
1800
+ * whose author was proven from one whose author was assumed. That distinction is the bullet.
1801
+ */
1802
+ authorship) {
1290
1803
  if (!this.#db)
1291
1804
  return false;
1292
1805
  try {
1293
1806
  const agentId = this.#requireAgentId(agentName);
1294
1807
  const blob = Buffer.from(plaintext);
1295
1808
  this.#db
1296
- .prepare(`INSERT OR IGNORE INTO transcript (agent_id, session_id, sequence, direction, blob, created_at)
1297
- VALUES (?, ?, ?, ?, ?, ?)`)
1298
- .run(agentId, sessionId, sequence, direction, blob, Date.now());
1809
+ .prepare(`INSERT OR IGNORE INTO transcript
1810
+ (agent_id, session_id, sequence, direction, blob, created_at, sender_pubkey, sender_sig, attribution)
1811
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1812
+ .run(agentId, sessionId, sequence, direction, blob, Date.now(), authorship ? Buffer.from(authorship.senderPubkey).toString("hex") : null, authorship ? Buffer.from(authorship.senderSig) : null,
1813
+ /**
1814
+ * THREE values, not two — caught by CELLO_Coder_1 reviewing the first version, and it was
1815
+ * the same defect this column exists to prevent, surviving one layer up in the enum.
1816
+ *
1817
+ * `local_session_state` covered two OPPOSITE rows: one this agent AUTHORED (provenance
1818
+ * fully known, merely not third-party-provable) and one RECEIVED on the soft fallback
1819
+ * (provenance unknown — something arrived on a socket and was trusted). A reader shown the
1820
+ * transcript later could not separate "he wrote this himself" from "nobody checked".
1821
+ * Structurally identical rows with different trustworthiness is exactly what I refused to
1822
+ * ship when I rejected a nullable signature column.
1823
+ *
1824
+ * No plumbing needed: `direction` already carries the answer at write time.
1825
+ */
1826
+ authorship ? "verified_signature" : direction === "sent" ? "self_authored" : "local_session_state");
1299
1827
  this.#logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
1300
1828
  return true;
1301
1829
  }
@@ -1819,7 +2347,54 @@ export class SessionNodeManager {
1819
2347
  // DOD-RENAME-1: a removed contact has no pending rename to resolve.
1820
2348
  if (res.changes > 0)
1821
2349
  this.clearRenameNotice(agentName, pubkey);
1822
- return res.changes > 0;
2350
+ /**
2351
+ * OUTSIDE the `changes > 0` guard — review N2, and inside it the F2 fix did nothing for the
2352
+ * case that matters.
2353
+ *
2354
+ * The pin is written on every ACCEPTED INBOUND session. A contact row is written only on an
2355
+ * outbound initiate, an explicit add, a reply, or a trust-signal presentation — and an inbound
2356
+ * requester is deliberately NOT auto-added. So a counterparty you never replied to (away-mode
2357
+ * auto-ack is exactly this) has a pin and no contact row.
2358
+ *
2359
+ * Guarded, `cello_contact_remove` for them returned `{ ok: true, removed: false }`, cleared
2360
+ * nothing, and the identity refusal stayed permanent — the original lockout, now wearing an
2361
+ * `ok: true`, which is harder to notice than the original.
2362
+ */
2363
+ const pinsCleared = this.clearPinnedCounterpartyPrimary(agentName, pubkey);
2364
+ return res.changes > 0 || pinsCleared > 0;
2365
+ }
2366
+ /**
2367
+ * Forget the pinned threshold group key for a counterparty, so the next session re-pins.
2368
+ *
2369
+ * DOD-M15-OFFER-SIGNED-1 review F2 — WITHOUT THIS THE REFUSAL WAS PERMANENT. The identity-change
2370
+ * check refuses a counterparty whose group key differs from the one recorded in an earlier
2371
+ * session, and its guidance told the operator to confirm out of band and then remove the contact
2372
+ * so the new identity is pinned afresh. `removeContact` deleted a row in `contacts`; the pin lives
2373
+ * in `sessions.counterparty_primary_pubkey`, and nothing in the daemon ever cleared it.
2374
+ *
2375
+ * So an operator who did exactly as instructed — called their counterparty, confirmed the
2376
+ * re-registration was genuine, removed the contact, retried — got the identical refusal, with no
2377
+ * way out short of editing the database. A security control that cannot be reset by the person it
2378
+ * protects is a lockout, and the printed remedy made it worse by reading as though it worked.
2379
+ *
2380
+ * Nulls the column rather than deleting the session rows: those rows are the transcript record,
2381
+ * and a re-pin is not a reason to lose them.
2382
+ */
2383
+ clearPinnedCounterpartyPrimary(agentName, counterpartyPubkeyHex) {
2384
+ if (!this.#db)
2385
+ return 0;
2386
+ const res = this.#db
2387
+ .prepare(
2388
+ // NO `updated_at` BUMP — review N6. `CAP_COUNTS` counts an interrupted session only while
2389
+ // `updated_at` is inside the staleness window, so touching it here reset the clock on every
2390
+ // stale session with that counterparty, re-inflating their per-sender cap — while removing
2391
+ // the contact simultaneously dropped them to UNKNOWN tier, which LOWERS it. The operator
2392
+ // follows the printed remedy and their counterparty's next session is refused for cap,
2393
+ // through a reason string deliberately identical to every other refusal. A second lockout
2394
+ // that says nothing. Nothing needs the timestamp: every candidate row ends up NULL.
2395
+ "UPDATE sessions SET counterparty_primary_pubkey = NULL WHERE agent_id = ? AND counterparty_pubkey = ?")
2396
+ .run(this.#requireAgentId(agentName), counterpartyPubkeyHex);
2397
+ return Number(res.changes);
1823
2398
  }
1824
2399
  /** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
1825
2400
  * and tolerant of a not-yet-open DB (a missing label degrades the doorbell, never blocks it). */
@@ -1934,14 +2509,14 @@ export class SessionNodeManager {
1934
2509
  // over-cap UNKNOWN must be indistinguishable, or the refusal tells someone they are blocked.
1935
2510
  // The operator's alarm needs numbers, so it asks for them SEPARATELY via capDiagnostics;
1936
2511
  // hanging them off this object would put a distinguishing oracle in the return value.
1937
- return { ok: false, reason: "abuse_bound_sessions_per_sender" };
2512
+ return { ok: false, reason: CAPACITY_REASONS.ABUSE_BOUND_SESSIONS_PER_SENDER };
1938
2513
  }
1939
2514
  // The global stranger cap is only for the UNKNOWN pool. A KNOWN+ sender is past it by trust;
1940
2515
  // a BLOCKED sender was already refused above (cap 0).
1941
2516
  if (tier === TIER.UNKNOWN) {
1942
2517
  const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
1943
2518
  if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
1944
- return { ok: false, reason: "abuse_bound_unknown_sessions_global" };
2519
+ return { ok: false, reason: CAPACITY_REASONS.ABUSE_BOUND_UNKNOWN_SESSIONS_GLOBAL };
1945
2520
  }
1946
2521
  }
1947
2522
  return { ok: true };
@@ -2058,7 +2633,11 @@ export class SessionNodeManager {
2058
2633
  const retry = this.#srReservationRetry.get(agentName);
2059
2634
  return retry !== undefined && retry.attempts > SR_RESERVATION_MAX_RETRIES ? "unreachable" : "retrying";
2060
2635
  }
2061
- /** First ready standing receiver (any agent) — for agent-agnostic OUTBOUND use (gater-open). */
2636
+ /**
2637
+ * First ready standing receiver (any agent) — for agent-agnostic OUTBOUND use. Its gater admits
2638
+ * nobody INBOUND until a session names them (DOD-M15-ASSIGN-1); outbound stays open, which is the
2639
+ * property these callers depend on.
2640
+ */
2062
2641
  #anyStandingReceiver() {
2063
2642
  for (const sr of this.#standingReceivers.values())
2064
2643
  return sr;
@@ -2081,10 +2660,115 @@ export class SessionNodeManager {
2081
2660
  return { peerId: sr.node.getPeerId(), addrs: sr.node.listenAddresses() };
2082
2661
  }
2083
2662
  /**
2084
- * The standing receiver's libp2p node a general-purpose, OPEN-gater node usable for
2085
- * OUTBOUND dials that are not session-scoped (e.g. the content-park deposit/pull to the
2086
- * relay, MSG-001-3b). Session nodes have restrictive gaters; the standing receiver does not.
2087
- * Returns null until the receiver is ready.
2663
+ * DOD-M15-ASSIGN-1 name the one peer allowed to dial this agent's standing receiver, at the
2664
+ * moment the directory's `session_offer` says who is coming.
2665
+ *
2666
+ * This is what makes the receiver's deny-by-default safe. The offer names
2667
+ * `initiator_session_peer_id`, and the responder answers it by advertising its OWN address in
2668
+ * `session_offer_accept`. Narrowing here — BEFORE that answer goes out — means the door opens to
2669
+ * exactly one peer at the same instant the address that reaches them is published, and never
2670
+ * before. The initiator cannot know where to dial until the accept it triggers has been sent.
2671
+ *
2672
+ * Returns WHICH failure it was, never a bare false (review F6). The caller reports a distinct
2673
+ * reason per cause: "no receiver" and "the directory named nobody" are different subsystems, and
2674
+ * collapsing them sent the operator to the directory for a local problem. This method never
2675
+ * widens the gate to compensate.
2676
+ *
2677
+ * Narrows INBOUND ONLY. The receiver is still the daemon's general-purpose dialer at this point
2678
+ * — no assignment exists yet — so revoking its outbound latitude here would break content
2679
+ * parking and restart-seal submission (review F2).
2680
+ */
2681
+ admitOfferedDialer(agentName, initiatorSessionPeerId, sessionIdHex) {
2682
+ const sr = this.#standingReceivers.get(agentName);
2683
+ if (!sr)
2684
+ return "no_receiver";
2685
+ if (initiatorSessionPeerId === "")
2686
+ return "no_peer_named";
2687
+ sr.gater.admitInboundPeer(initiatorSessionPeerId);
2688
+ this.#offeredDialer.set(this.#k(agentName, sessionIdHex), initiatorSessionPeerId);
2689
+ return "narrowed";
2690
+ }
2691
+ /**
2692
+ * What the UNSIGNED offer claimed, so the SIGNED assignment can be checked against it.
2693
+ *
2694
+ * DOD-M15-OFFER-SIGNED-1. Decision 2 rules that the listening socket is "gated on the
2695
+ * assignment", and the gate is narrowed from `session_offer` — a frame carrying no signature —
2696
+ * because that is the only thing that arrives early enough. Timing forced the offer; it does not
2697
+ * excuse trusting it.
2698
+ *
2699
+ * Keeping what the offer said turns the two frames into a CHECK ON EACH OTHER. The assignment is
2700
+ * FROST-signed by the initiator's own threshold group, which no single directory can produce, and
2701
+ * it names the same peer id. A directory that says one peer in the offer and another in the
2702
+ * assignment is naming two different dialers for one session — which a truthful directory never
2703
+ * does, and which is exactly the move a compromised one would make to slip a peer past the gate
2704
+ * before the signed document arrives.
2705
+ */
2706
+ getOfferedDialer(agentName, sessionIdHex) {
2707
+ return this.#offeredDialer.get(this.#k(agentName, sessionIdHex)) ?? null;
2708
+ }
2709
+ /**
2710
+ * Which peer this agent's standing receiver is currently admitting INBOUND — `null` for nobody.
2711
+ *
2712
+ * Read-only, and it answers a question the daemon otherwise cannot: *"whose dial would this
2713
+ * receiver accept right now?"* The gate is narrowed and re-closed from several paths (an offer
2714
+ * arrives, an assignment is refused, a session is promoted), and until now the only way to know
2715
+ * where it had ended up was to reproduce the sequence in your head.
2716
+ *
2717
+ * Added for `DOD-M15-RESPONDER-VERIFY-1`, where a refusal for one session was closing the gate a
2718
+ * DIFFERENT session had narrowed — a defect with no observable symptom short of the second
2719
+ * session's initiator being refused with "nothing invited it".
2720
+ */
2721
+ getStandingReceiverAllowedPeer(agentName) {
2722
+ return this.#standingReceivers.get(agentName)?.gater.getAllowedPeerId() ?? null;
2723
+ }
2724
+ /** Forget the offered dialer for ONE session — called on BOTH the claim and the refusal paths. */
2725
+ clearOfferedDialer(agentName, sessionIdHex) {
2726
+ this.#offeredDialer.delete(this.#k(agentName, sessionIdHex));
2727
+ }
2728
+ /**
2729
+ * RE-CLOSE the standing receiver — but ONLY if this session is still the one holding it.
2730
+ *
2731
+ * DOD-M15-OFFER-SIGNED-1 review F4, then N1. The first version closed the gate unconditionally,
2732
+ * and that was worse than the defect it fixed: an agent has ONE standing receiver with ONE allowed
2733
+ * peer, so a refusal for session P closed the gate that offer Q had narrowed. Q's initiator —
2734
+ * invited, legitimate — was then refused with *"nothing invited it"*, which this daemon had.
2735
+ *
2736
+ * That is the same cross-session interference F1 was written to remove, moved one method along,
2737
+ * and triggerable the same way: one bogus offer/assignment pair collapses a concurrent real
2738
+ * session.
2739
+ *
2740
+ * So the gate is closed only when it still names the peer THIS session opened it to. If a later
2741
+ * offer has already re-narrowed it, that offer owns the receiver and its narrowing stands.
2742
+ *
2743
+ * NO EVICTION SWEEP, deliberately (N4). The sweep evicts by "not the allowed peer", and
2744
+ * `getConnections()` returns OUTBOUND connections too — including the content-park and
2745
+ * restart-seal dials this node makes as the daemon's general-purpose dialer, whose targets are on
2746
+ * no allowlist by construction. Sweeping here hung those up, and the failure surfaced as
2747
+ * `relay_unavailable`: a transport label for a local decision, which is the exact substitution
2748
+ * that comment was written to prevent. The load-bearing control is `DOD-M15-FRAME-1`'s frame gate,
2749
+ * which refuses what an unauthorised peer sends; closing the door is enough here.
2750
+ */
2751
+ revokeOfferedDialer(agentName, sessionIdHex, offeredPeerId) {
2752
+ this.clearOfferedDialer(agentName, sessionIdHex);
2753
+ const sr = this.#standingReceivers.get(agentName);
2754
+ if (!sr || offeredPeerId === null)
2755
+ return;
2756
+ if (sr.gater.getAllowedPeerId() !== offeredPeerId) {
2757
+ // A later offer already owns the receiver. Closing it would refuse THAT session's initiator.
2758
+ this.#logger.debug("session.gate.revoke.skipped", {
2759
+ agentName,
2760
+ sessionId: sessionIdHex,
2761
+ reason: "a later offer has re-narrowed this receiver; its narrowing stands",
2762
+ });
2763
+ return;
2764
+ }
2765
+ sr.gater.closeInbound();
2766
+ }
2767
+ /**
2768
+ * The standing receiver's libp2p node — a general-purpose node usable for OUTBOUND dials that
2769
+ * are not session-scoped (e.g. the content-park deposit/pull to the relay, MSG-001-3b). Its
2770
+ * gater admits nobody INBOUND until a session names them (DOD-M15-ASSIGN-1), but leaves these
2771
+ * outbound errands open. Returns null until the receiver is ready.
2088
2772
  */
2089
2773
  getStandingReceiverNode(agentName) {
2090
2774
  // With an agentName: that agent's own standing-receiver node (needed when the dial must
@@ -2137,8 +2821,9 @@ export class SessionNodeManager {
2137
2821
  this.#onContentArrived = cb;
2138
2822
  }
2139
2823
  /** M14 / DOD-DOC-INBOUND-2: inject the document-frame interception. See the field's note. */
2140
- setOnDocumentFrame(cb) {
2824
+ setOnDocumentFrame(cb, classifyOnly) {
2141
2825
  this.#onDocumentFrame = cb;
2826
+ this.#isDocumentFrame = classifyOnly ?? null;
2142
2827
  }
2143
2828
  /**
2144
2829
  * DOD-LOOP-1: the session core is keyed by (agentName, sessionId), NOT sessionId alone. Two of
@@ -2266,6 +2951,7 @@ export class SessionNodeManager {
2266
2951
  }
2267
2952
  ({ node, gater, autoNat, seed } = sr);
2268
2953
  gater.setAllowedPeer(counterpartyPeerId);
2954
+ await this.#evictPeersOutsideGate(node, gater, sessionId, counterpartyPeerId, "outbound_promotion");
2269
2955
  // Hand this agent's standing receiver off to this session; a replacement is spun up below.
2270
2956
  this.#standingReceivers.delete(agentName);
2271
2957
  }
@@ -2606,6 +3292,21 @@ export class SessionNodeManager {
2606
3292
  correlationId,
2607
3293
  });
2608
3294
  }
3295
+ /**
3296
+ * DOD-M15-SEALWIRE-1 bullet 6 (part A) — ANNOUNCE OUR SALT STATE, here and nowhere else.
3297
+ *
3298
+ * This is the only hook that fires on BOTH sides for every way a session's direct path comes
3299
+ * up: the initiator's first dial, the responder's inbound connection, every reconnect, and a
3300
+ * revived node. `newStream` never dials — it only finds an already-open connection — so a
3301
+ * send placed at `createSessionNode` would be an announcement to a peer that is not attached
3302
+ * yet, and the responder's half has no dial of its own to hang one on at all.
3303
+ *
3304
+ * Fire-and-forget: a failed announcement must not turn a peer-connect handler into a rejected
3305
+ * promise, and there is nothing to await it. We re-announce on the next connect, and
3306
+ * `#handleSaltFrame` answers a peer contribution on a connection that is provably up — so a
3307
+ * single lost frame does not strand the agreement.
3308
+ */
3309
+ void this.#sendSaltFrame(agentName, sessionId, correlationId);
2609
3310
  });
2610
3311
  node.onPeerDisconnect((peerId) => {
2611
3312
  if (!isCounterparty(peerId)) {
@@ -2822,6 +3523,12 @@ export class SessionNodeManager {
2822
3523
  * unexpected peer could connect during the hand-off.
2823
3524
  */
2824
3525
  async acceptSession(sessionId, agentName, counterpartyPubkey, initiatorPeerId, correlationId, relay) {
3526
+ // DOD-M15-OFFER-SIGNED-1 review N5: the offer record has done its job the moment this session is
3527
+ // claimed. Keying it by session (the F1 fix) removed the accidental bound that agent-keying gave
3528
+ // it — each new offer used to overwrite the last — so without a clear on the SUCCESS path the
3529
+ // map gained one permanent entry per offer ever received, on directory-supplied keys. Cleared
3530
+ // here rather than only on refusal, which is what the doc comment always claimed.
3531
+ this.clearOfferedDialer(agentName, sessionId);
2825
3532
  const inboundSr = this.#standingReceivers.get(agentName);
2826
3533
  if (!inboundSr) {
2827
3534
  // DOD-LOOP-1: per-agent — kick off (idempotent) creation so a retry finds it.
@@ -2850,6 +3557,7 @@ export class SessionNodeManager {
2850
3557
  const { node, gater, autoNat, seed } = inboundSr;
2851
3558
  // AC-015: update gater BEFORE retrieving multiaddr / returning to caller
2852
3559
  gater.setAllowedPeer(initiatorPeerId);
3560
+ await this.#evictPeersOutsideGate(node, gater, sessionId, initiatorPeerId, "inbound_promotion");
2853
3561
  const peerId = node.getPeerId();
2854
3562
  const addrs = node.listenAddresses();
2855
3563
  // Persist to SQLite. D4 review F1 (same as createSessionNode): a swallowed row-write failure
@@ -3114,10 +3822,47 @@ export class SessionNodeManager {
3114
3822
  this.#sessionLiveness.delete(key);
3115
3823
  // M7-UPGRADE-002: drop the auto-acknowledge bookkeeping for a torn-down session.
3116
3824
  this.#contentDesynced.delete(key);
3825
+ // DOD-M15-REFUSED-INBOUND-SILENT-1: and the unshown refusals. Bounded (a fixed set of reasons
3826
+ // per session) so leaving them was a slow leak rather than a bug — but this list IS the
3827
+ // documented teardown set, and a map that is not in it drifts out of everyone's mental model.
3828
+ this.#contentRefusals.delete(key);
3829
+ this.#unreadableAlgSeen.delete(key);
3117
3830
  this.#responderSealSubmitted.delete(key);
3118
3831
  // DOD-MSG-4: drop the strict-in-order bookkeeping (witness map, held plaintext, high-water)
3119
3832
  // so a torn-down session retains no stale ordering state or buffered plaintext.
3120
3833
  this.#witnessedSeq.delete(key);
3834
+ /**
3835
+ * DOD-M15-SEALWIRE-1 bullet 6 (part A) — both salt maps are CACHES and both go.
3836
+ *
3837
+ * The salt is re-read from `sessions.content_salt` on revival, which is the reason Decision #8
3838
+ * persists it. The contribution is worthless once a salt exists, and if none was agreed yet, a
3839
+ * revival minting a fresh one is correct: the peer either also holds nothing (both re-derive
3840
+ * from the two current halves and reach the same bytes), or it derived against our old half
3841
+ * while we were down — in which case the agreement refuses by name, which is the outcome
3842
+ * Decision #10 asks for. What must never happen is a NEW contribution mid-session without a
3843
+ * teardown, and that is why `#saltContributionFor` mints once rather than per send.
3844
+ */
3845
+ this.#saltContributions.delete(key);
3846
+ this.#sessionSalts.delete(key);
3847
+ this.#saltRepairedAgainst.delete(key);
3848
+ /**
3849
+ * B2b-2's three, and the pending one is SETTLED rather than dropped.
3850
+ *
3851
+ * A `delete` alone leaves any send waiting on that promise waiting until its own timer fires —
3852
+ * five seconds of a torn-down session holding a message that has nowhere to go. `closed` is the
3853
+ * truthful outcome: the agreement is over, because the session is.
3854
+ *
3855
+ * `#hashedWithoutSalt` and `#unsaltedAnnounced` go with the caches, and that is correct rather
3856
+ * than merely tidy. A revived session re-derives its frontier from durable state — leaves, held
3857
+ * rows, queued rows — so the adoption question is answered from disk, not from a flag that would
3858
+ * be a stale in-memory claim about a process that no longer exists. Re-announcing the fallback
3859
+ * once per revival is the right frequency too: it is what an operator reading a fresh log needs
3860
+ * in order to know why this session has no salt.
3861
+ */
3862
+ this.#settleSaltPending(agentName, sessionId, "closed");
3863
+ this.#hashedWithoutSalt.delete(key);
3864
+ this.#unsaltedAnnounced.delete(key);
3865
+ this.#saltLastOutcome.delete(key);
3121
3866
  // HELD CONTENT IS LOST HERE, AND IT MUST SAY SO.
3122
3867
  //
3123
3868
  // These are frames we RECEIVED and VERIFIED and could not yet append, because the relay's
@@ -3189,7 +3934,24 @@ export class SessionNodeManager {
3189
3934
  // this unit exists to stop.
3190
3935
  this.#heldRestored.delete(key);
3191
3936
  this.#heldReleased.delete(key);
3192
- this.#diverged.delete(key);
3937
+ // DOD-M15-DIVERGE-1: `#diverged` is NOT evicted here, and the omission is the point.
3938
+ //
3939
+ // It was, and that made the gate that reads it best-effort in exactly the population it targets.
3940
+ // This eviction runs on EVERY teardown including `destroySessionNode` with a non-sealed reason,
3941
+ // which writes status `interrupted` — one of the two statuses the seal gate is scoped to. So a
3942
+ // session that diverged and was then torn down arrived at the gate with the fact already
3943
+ // forgotten, and the read site cannot tell "not diverged" from "we forgot": both are
3944
+ // `has() === false`, both read ready, and the close proceeds.
3945
+ //
3946
+ // Divergence is a fact about the DURABLE TREE, not about the live node — the tree keeps the
3947
+ // misplaced leaf whether or not a node exists — so it does not belong to a cache keyed on node
3948
+ // lifetime. It is cleared where it actually stops being true: `clearDivergedOnTerminal`, below.
3949
+ //
3950
+ // NOT YET DURABLE ACROSS A RESTART, stated here rather than left to be rediscovered — the same
3951
+ // way `frontier-mismatch.ts` states its own trade. A daemon restart still empties this set, and
3952
+ // unlike a frontier mismatch (re-detected by the very next close) divergence is only
3953
+ // re-detected by the next send that gets an ack behind the frontier. Until it has a column, a
3954
+ // restarted daemon can read a diverged session as ready. Tracked as `DOD-M15-DIVERGE-DURABLE-1`.
3193
3955
  // DOD-M12B-SESSION-SEED-1: HAND THEM TO THE REVIVAL RECORD BEFORE DROPPING THEM. This eviction
3194
3956
  // runs on every teardown, including the interruption a revival is meant to undo — so clearing
3195
3957
  // the addresses here is what left a revived session unable to dial anyone. The revival record
@@ -3277,9 +4039,42 @@ export class SessionNodeManager {
3277
4039
  else {
3278
4040
  const interruptedAt = new Date(now).toISOString();
3279
4041
  try {
3280
- this.#db.prepare(
4042
+ const res = this.#db.prepare(
3281
4043
  // DOD-CAP-SELF-HEAL-1: OURS. Our own shutdown ended these, not the counterparty.
3282
4044
  "UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?), interrupted_by = 'local' WHERE status = 'active'").run(now, interruptedAt);
4045
+ /**
4046
+ * ⚠️ THE AUTHORITATIVE PERSISTENCE STEP WAS SILENT ON SUCCESS, AND THAT IS WHY ITS OWN TEST
4047
+ * CANNOT BE DIAGNOSED.
4048
+ *
4049
+ * `AC-009 (binary): SIGTERM marks active sessions interrupted` failed twice in a row on CI
4050
+ * and blocked a publish. The captured daemon log ends at `daemon.started` with nothing after
4051
+ * it — because the ONLY thing this block could ever log was a thrown error. So the evidence
4052
+ * is equally consistent with two very different failures:
4053
+ *
4054
+ * - the shutdown never ran (signal handler, early exit, teardown ordering), or
4055
+ * - it ran and the UPDATE matched ZERO rows (visibility, or the rows genuinely were not
4056
+ * `active` at that moment).
4057
+ *
4058
+ * Nothing in the log separates them, so the test's own comment picked one — *"the daemon's
4059
+ * connection can begin its shutdown UPDATE against a snapshot that predates this commit"* —
4060
+ * added a `wal_checkpoint(TRUNCATE)` for it, and the test failed again. One diagnosis, one
4061
+ * fix, one recurrence: the point at which the diagnosis is the thing to doubt.
4062
+ *
4063
+ * `changes` was on the result object the whole time and nobody read it. It discriminates the
4064
+ * two outright, and this is the last line the daemon writes before it dies, so it is the last
4065
+ * thing anyone investigating a bad shutdown will see.
4066
+ *
4067
+ * ⚠️ INFO AND NOT AN ERROR EVEN AT ZERO. Zero is the ordinary case for a daemon with no
4068
+ * active sessions — most shutdowns. Making it a warning would fire on nearly every clean exit
4069
+ * and train operators to filter the one signal that matters.
4070
+ */
4071
+ this.#logger.info("session.interrupt.db.write.complete", {
4072
+ sessionId: "__all__",
4073
+ rowsMarkedInterrupted: Number(res.changes),
4074
+ impact: Number(res.changes) === 0
4075
+ ? "no session rows were 'active' at shutdown, so none was marked interrupted. Ordinary for a daemon with no live sessions — but if a session WAS expected to be interrupted, the UPDATE ran and matched nothing, which is a visibility or state question and NOT a shutdown that failed to run."
4076
+ : "these sessions are recorded as interrupted by OUR OWN shutdown, so they can be resumed or sealed rather than read as abandoned.",
4077
+ });
3283
4078
  }
3284
4079
  catch (err) {
3285
4080
  this.#logger.error("session.interrupt.db.write.failed", {
@@ -3711,6 +4506,27 @@ export class SessionNodeManager {
3711
4506
  * cello_get_sealed_receipt) depend on — without it an agent has no way to learn
3712
4507
  * its own session ids after a restart or from a fresh MCP connection.
3713
4508
  */
4509
+ /**
4510
+ * DOD-M15-REFUSED-INBOUND-SILENT-1, the DECLINED PROTECTION half — a FIELD, not an alert.
4511
+ *
4512
+ * An unsalted session is exactly as verifiable as every session shipped before salting existed,
4513
+ * so there is nothing to interrupt the operator with and no event to fire. What was missing is
4514
+ * STATE: nothing let anyone tell *"unsalted because this build predates the feature"* from
4515
+ * *"unsalted because adoption was refused"* — and only the second says something about their
4516
+ * setup. The session's own status now answers it, which costs nothing per message and cannot
4517
+ * become a flood.
4518
+ *
4519
+ * The raw salt is dropped on the way out rather than passed through. `SELECT *` was handing the
4520
+ * BLOB to a listing surface that has no use for it; the boolean is the whole question a reader of
4521
+ * this list is asking, and shipping key material to answer a yes/no is not a trade worth making.
4522
+ */
4523
+ #saltStatusOf(row) {
4524
+ const { content_salt, ...rest } = row;
4525
+ return {
4526
+ ...rest,
4527
+ content_hashes_salted: content_salt != null && content_salt.length > 0,
4528
+ };
4529
+ }
3714
4530
  getSessionsForAgent(agentName) {
3715
4531
  if (!this.#db)
3716
4532
  return [];
@@ -3720,7 +4536,7 @@ export class SessionNodeManager {
3720
4536
  const rows = this.#db
3721
4537
  .prepare("SELECT * FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC")
3722
4538
  .all(this.#requireAgentId(agentName));
3723
- return rows.map((r) => ({ ...r, agent_name: agentName }));
4539
+ return rows.map((r) => ({ ...this.#saltStatusOf(r), agent_name: agentName }));
3724
4540
  }
3725
4541
  /**
3726
4542
  * Every persisted session across ALL agents, most-recently-updated first. Backs the daemon-wide
@@ -3737,7 +4553,8 @@ export class SessionNodeManager {
3737
4553
  .prepare(`SELECT s.*, a.agent_name AS agent_name
3738
4554
  FROM sessions s LEFT JOIN agents a ON a.agent_id = s.agent_id
3739
4555
  ORDER BY s.updated_at DESC`)
3740
- .all();
4556
+ .all()
4557
+ .map((r) => this.#saltStatusOf(r));
3741
4558
  }
3742
4559
  /**
3743
4560
  * M7-SESSION-004 (AC-005): persist the seal certificate's legibility object with the
@@ -3807,6 +4624,35 @@ export class SessionNodeManager {
3807
4624
  * signature locally. Best-effort — a missing row (race) is a no-op; the seal then falls back to
3808
4625
  * accept-without-verify (still sound: the live frame arrives over the authenticated Noise channel).
3809
4626
  */
4627
+ /**
4628
+ * The counterparty's threshold group key as this agent has seen it BEFORE — trust on first use.
4629
+ *
4630
+ * DOD-M15-OFFER-SIGNED-1 / RESPONDER-VERIFY-1. The responder does not verify the assignment's
4631
+ * signature (deferred to SESSION-004), so every field in it is whatever the directory said. That
4632
+ * makes a same-frame check circular: a compromised directory just says the same thing twice.
4633
+ *
4634
+ * This is the one anchor the responder holds that a directory CANNOT retroactively change — its
4635
+ * own memory of previous sessions with this counterparty. A directory that names a different
4636
+ * threshold group key for someone you have already talked to is either substituting an identity
4637
+ * or has been compromised since; neither is a session to accept quietly.
4638
+ *
4639
+ * THE BOUND, stated rather than glossed: this is worth nothing on FIRST contact, which is the
4640
+ * definition of trust-on-first-use. It hardens every session after it, which is where a long-lived
4641
+ * counterparty relationship actually lives.
4642
+ *
4643
+ * Keyed on `counterparty_pubkey` — the K_local IDENTITY, which is the stable thing — not on a
4644
+ * session id or a display name.
4645
+ */
4646
+ getPinnedCounterpartyPrimary(agentName, counterpartyPubkeyHex) {
4647
+ if (!this.#db)
4648
+ return null;
4649
+ const row = this.#db
4650
+ .prepare(`SELECT counterparty_primary_pubkey FROM sessions
4651
+ WHERE agent_id = ? AND counterparty_pubkey = ? AND counterparty_primary_pubkey IS NOT NULL
4652
+ ORDER BY updated_at DESC LIMIT 1`)
4653
+ .get(this.#requireAgentId(agentName), counterpartyPubkeyHex);
4654
+ return row?.counterparty_primary_pubkey ?? null;
4655
+ }
3810
4656
  recordCounterpartyPrimary(agentName, sessionId, primaryPubkeyHex) {
3811
4657
  if (!this.#db)
3812
4658
  return;
@@ -4327,14 +5173,45 @@ export class SessionNodeManager {
4327
5173
  * cross-process ordering (and thus AC-002 root agreement under concurrent
4328
5174
  * bidirectional traffic) requires the relay-assigned sequence from MSG-001.
4329
5175
  */
4330
- async sendContent(agentName, sessionId, content, contentHash, correlationId,
5176
+ async sendContent(agentName, sessionId, content, contentHash,
5177
+ /**
5178
+ * Required alongside the two below — every production caller already passes one, and an optional
5179
+ * parameter in front of a required one is what TypeScript refuses. Making it explicit costs
5180
+ * nothing and removes the last place a positional argument can silently shift.
5181
+ */
5182
+ correlationId,
4331
5183
  /**
4332
5184
  * The DOMAIN this content belongs to, as the relay and the directory will see it. Defaults to
4333
5185
  * MESSAGE so `cello_send` is unchanged; the document path passes 0x04/0x05. Not cosmetic — the
4334
5186
  * directory computes `final_message` and `answered` from the witnessed kind, and both of its
4335
5187
  * document exclusions were dead while every document leaf arrived here as a message.
5188
+ *
5189
+ * ⚠️ ALSO REQUIRED NOW, and for the same reason as `contentHashAlg` below — this parameter is the
5190
+ * precedent, not a bystander. It defaulted to MESSAGE, the document adapter in `daemon.ts`
5191
+ * silently dropped it, and the wire was wrong for a whole release: *"0.0.145 shipped the fix
5192
+ * everywhere except here."* A default that matches the common case makes the omission invisible
5193
+ * at every call site and at typecheck. Every caller states its kind now.
4336
5194
  */
4337
- leafKind = LEAF_KIND_MSG) {
5195
+ leafKind,
5196
+ /**
5197
+ * `DOD-M15-SEALWIRE-1` part B2b — the algorithm `contentHash` was produced under, taken from
5198
+ * `contentHashForSession` by the caller that computed the hash.
5199
+ *
5200
+ * Passed rather than re-derived HERE, deliberately: re-deriving would ask "how would this
5201
+ * session hash something now?", and the answer can differ from how THIS message was actually
5202
+ * hashed. A hash and its label must travel together or the peer refuses a message nobody touched.
5203
+ *
5204
+ * ⚠️ REQUIRED, NOT DEFAULTED — review B2b-1 F4, and the default is what made four mutants
5205
+ * unfalsifiable. It was `= CONTENT_HASH_ALGS.SHA256`, which equals the only value in play today,
5206
+ * so DROPPING THE ARGUMENT AT ANY OF THE FIVE HOPS produced byte-identical output and the whole
5207
+ * 2,800-test daemon suite stayed green. Measured at all four send sites individually.
5208
+ *
5209
+ * A default that equals the current value makes every threading edit invisible until the value
5210
+ * changes — and the day it changes, the dropped argument mislabels the message and every peer
5211
+ * refuses it as a tamper. Required makes a dropped argument a TYPECHECK failure instead of a
5212
+ * test question nobody can answer.
5213
+ */
5214
+ contentHashAlg) {
4338
5215
  const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
4339
5216
  if (!entry) {
4340
5217
  // M12-P13: no node, so nothing was witnessed and nothing was queued — the caller must NOT
@@ -4442,6 +5319,26 @@ export class SessionNodeManager {
4442
5319
  // document for a killed conversation, which is a strictly worse bug than the one this
4443
5320
  // unit fixes. It still refuses the send; it just does not reach for the shovel.
4444
5321
  retireSession: (id) => {
5322
+ /**
5323
+ * `session_sealed` retires as SEALED. `seal_refused` retires as ABANDONED —
5324
+ * `DOD-M15-TERMINAL-REASON-1`, and the distinction is the whole reason that unit
5325
+ * exists.
5326
+ *
5327
+ * A refused seal has NO certificate: a directory read it and rejected it. Writing
5328
+ * `sealed` here would be a fabricated notarization claim — `cello_close_session`
5329
+ * answering "already sealed, view its notarization" while the receipt read answers
5330
+ * "not sealed yet", which is the two-answers-pointing-at-each-other deadlock
5331
+ * described above. `abandoned` is the state invented for exactly this:
5332
+ * locally terminal with nothing to notarize.
5333
+ */
5334
+ if (witnessed.reason === "seal_refused") {
5335
+ // `abandonSession`, not a hand-rolled flip-then-teardown: it already does the
5336
+ // status write synchronously BEFORE the async teardown yields, which is the
5337
+ // ordering the comment above spends a paragraph on. Reimplementing it here would
5338
+ // be a second copy of that reasoning, free to drift from the first.
5339
+ void this.abandonSession(agentName, id);
5340
+ return;
5341
+ }
4445
5342
  if (witnessed.reason !== "session_sealed")
4446
5343
  return;
4447
5344
  // STATUS FIRST AND SYNCHRONOUS, teardown second — the order `abandonSession` uses,
@@ -4461,6 +5358,10 @@ export class SessionNodeManager {
4461
5358
  this.#logger.warn("session.relay.hash.submit.failed", {
4462
5359
  sessionId,
4463
5360
  reason: witnessed.reason,
5361
+ // The relay's own words about what happened, when it sent any. `reason` is the class and
5362
+ // this is the cause — without it a refusal reaches the operator as a bare code, which is
5363
+ // the state this field existed to end and never actually did.
5364
+ ...(witnessed.detail === undefined ? {} : { detail: witnessed.detail }),
4464
5365
  correlationId,
4465
5366
  });
4466
5367
  relayRefusal = witnessed.reason;
@@ -4494,7 +5395,7 @@ export class SessionNodeManager {
4494
5395
  // resolves it (content.delivery.acked) and TTF expiry hands it to the park
4495
5396
  // backstop. The correlationId rides in the frame so the receiver's
4496
5397
  // session.content.received shares ONE flow id with the sender.
4497
- this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, orderingS1, orderingS2);
5398
+ this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, orderingS1, orderingS2, contentHashAlg);
4498
5399
  const frame = encodeCbor({
4499
5400
  type: "content_frame",
4500
5401
  session_id: sessionId,
@@ -4507,6 +5408,10 @@ export class SessionNodeManager {
4507
5408
  // prev_root (order). Omitted if the relay was unreachable — receiver falls back to the witness.
4508
5409
  structure1_cbor: orderingS1,
4509
5410
  structure2_cbor: orderingS2,
5411
+ // DOD-M15-SEALWIRE-1 part B2b: HOW `content_hash` was produced. An older peer ignores an
5412
+ // unknown CBOR key, so emitting it is safe for every build in existence; a newer one reads
5413
+ // it and verifies under the named algorithm instead of assuming.
5414
+ content_hash_alg: contentHashAlg,
4510
5415
  });
4511
5416
  // Injected dial failure — thrown from inside the try so it lands in exactly the catch the
4512
5417
  // real connection_lost lands in, and the whole downstream path (untrack → park → durable
@@ -4575,7 +5480,7 @@ export class SessionNodeManager {
4575
5480
  ...this.#streamCensus(entry.node, entry.counterpartySessionPeerId),
4576
5481
  correlationId,
4577
5482
  });
4578
- const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2);
5483
+ const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2, contentHashAlg);
4579
5484
  if (attempt.outcome === "parked") {
4580
5485
  this.#noteImpairmentRetention(agentName, sessionId, "parked");
4581
5486
  return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
@@ -4602,7 +5507,10 @@ export class SessionNodeManager {
4602
5507
  });
4603
5508
  }
4604
5509
  else {
4605
- durable = this.#onParkFailed(agentName, sessionId, hashHex, content, orderingS1, orderingS2);
5510
+ // B2b-1 review F1: the DURABLE writer. Without the 7th argument the column this unit
5511
+ // added has no producer at all — every queued row would carry NULL, and the crash
5512
+ // backstop would re-park a salted message as sha256 and have it refused forever.
5513
+ durable = this.#onParkFailed(agentName, sessionId, hashHex, content, orderingS1, orderingS2, contentHashAlg);
4606
5514
  }
4607
5515
  if (!durable) {
4608
5516
  if (this.#onParkFailed !== null) {
@@ -4678,9 +5586,24 @@ export class SessionNodeManager {
4678
5586
  // died inside #parkContent. An operator keying on `reason` alone is sent to the transport
4679
5587
  // when the blocker is the receiver.
4680
5588
  ...(attempt.cause !== undefined ? { cause: attempt.cause } : {}),
4681
- guidance: durable
4682
- ? "Direct delivery failed and the relay refused the hand-off, so the message is queued and will be re-sent automatically when the relay link is back. Do not re-send it: an identical re-send is not separately queued."
4683
- : "Direct delivery failed and the message could NOT be queued for retry — it is lost. Send it again.",
5589
+ /**
5590
+ * ⚠️ A FAULT THAT IS NOT THE RELAY MUST NOT SAY IT IS B2b-2 constraint 6.
5591
+ *
5592
+ * The `durable` branch below is written for a relay that is down: queued, retried, nothing
5593
+ * for you to do. That is right for the case it was written for and WRONG for a producer-side
5594
+ * refusal, which reaches the same branch by the same route. There the relay was never asked,
5595
+ * and the drain re-parks the same entry into the same throw — so the message is genuinely
5596
+ * durable and will genuinely never leave. Telling that operator to wait costs them however
5597
+ * long they are willing to wait before they stop believing the message.
5598
+ *
5599
+ * This is the reason `cause` had to become a code first: the distinction is unbranchable
5600
+ * while the field holds an English paragraph.
5601
+ */
5602
+ guidance: attempt.cause === PARK_ENVELOPE_REASONS.ALG_UNREADABLE
5603
+ ? "This message names a content-hash algorithm your build cannot produce, so it could not be sealed for hand-off. The relay is NOT involved and this will not clear on its own — the message is safely stored but every retry fails the same way. Upgrade to a build that knows the algorithm, or start a new session with this counterparty. Re-sending on this build changes nothing."
5604
+ : durable
5605
+ ? "Direct delivery failed and the relay refused the hand-off, so the message is queued and will be re-sent automatically when the relay link is back. Do not re-send it: an identical re-send is not separately queued."
5606
+ : "Direct delivery failed and the message could NOT be queued for retry — it is lost. Send it again.",
4684
5607
  };
4685
5608
  }
4686
5609
  }
@@ -4761,7 +5684,22 @@ export class SessionNodeManager {
4761
5684
  });
4762
5685
  // content_hash = SHA-256(0x02 || seal_payload) — the ctrl leaf kind byte is 0x02.
4763
5686
  const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(sealPayload).digest());
4764
- const result = await entry.relayClient.submitLeaf(entry.node, entry.relaySessionIdBytes, contentHash, LEAF_KIND_CTRL);
5687
+ /**
5688
+ * ⚠️ `sealPayload` IS PASSED, AND ITS ABSENCE WAS THE WHOLE DEFECT — `DOD-M15-SEALWIRE-1`
5689
+ * bullets 3+4, review pass 1, F1.
5690
+ *
5691
+ * These exact bytes were computed two lines above, hashed, and then dropped: `submitLeaf` had
5692
+ * no parameter for them. So the directory received a SHA-256 pre-image nobody transmitted, and
5693
+ * the client's SIGNED `final_root` — the one value in the seal the relay cannot produce — was
5694
+ * unrecoverable. Four legs of this line shipped and were reviewed green while the head of the
5695
+ * chain did not exist.
5696
+ *
5697
+ * The payload and the hash MUST come from the same derivation. If they ever diverge the
5698
+ * directory reports `seal_payload_unbound`, whose guidance says *"someone between them and here
5699
+ * altered or fabricated the payload — the relay is the only party on that path"* — a correct
5700
+ * relay accused by name, in an error written to sound like an attack, for a mismatch made here.
5701
+ */
5702
+ const result = await entry.relayClient.submitLeaf(entry.node, entry.relaySessionIdBytes, contentHash, LEAF_KIND_CTRL, sealPayload);
4765
5703
  if (!result.ok) {
4766
5704
  // Clear the idempotency mark so a genuine retry (agent close / reconnect) can proceed (DB-001).
4767
5705
  this.#responderSealSubmitted.delete(sealKey);
@@ -4810,7 +5748,19 @@ export class SessionNodeManager {
4810
5748
  const record = this.getSessionRecord(agentName, sessionId);
4811
5749
  return {
4812
5750
  known: !!record,
4813
- tampered: this.#contentDesynced.has(this.#k(agentName, sessionId)),
5751
+ /**
5752
+ * THE REASON TRAVELS WITH THE VERDICT — review F-A, correcting review F1's fix.
5753
+ *
5754
+ * F1 made the gate binary and moved the label into the log, and I did that in ONE consumer.
5755
+ * This struct feeds a second one (`evaluateSealUpgrade`), which had only a boolean to read and
5756
+ * therefore called every cause `content_tamper` — at ERROR, with no guidance. An honest peer on
5757
+ * a newer build raised a security alarm on B's reconnect: exactly the harm the F1 fix was
5758
+ * written to remove, reached through the consumer it did not check.
5759
+ *
5760
+ * Returning the LABEL rather than a boolean is what makes that impossible to reintroduce: a
5761
+ * caller cannot flatten what it never receives flat.
5762
+ */
5763
+ unverifiable: this.#contentDesynced.get(this.#k(agentName, sessionId)) ?? null,
4814
5764
  };
4815
5765
  }
4816
5766
  /**
@@ -4844,17 +5794,41 @@ export class SessionNodeManager {
4844
5794
  // scope (already sealing, or needs the interrupted/upgrade path), not an auto-ack candidate.
4845
5795
  if (!record || record.status !== "active")
4846
5796
  return;
4847
- // SI-002 verifiability gate: never auto-sign a session whose content we could not verify.
4848
- // Today the ONLY tracked unverifiable cause is a content_hash mismatch = TAMPER (#contentDesynced
4849
- // is set only there). Genuine tamper is a SECURITY event — log it at ERROR with the distinct
4850
- // reason `content_tamper` so the AC-008 tamper alarm can fire (it keys on that reason). The other
4851
- // two specced reasons `desynced` (B's tree is behind the canonical sealed tail) and
4852
- // `content_unverifiable` (parked content unrecoverable) require the MSG-001-3b canonical-
4853
- // sequence reconciliation that is deferred; they are reserved for that follow-on.
4854
- if (this.#contentDesynced.has(ackKey)) {
5797
+ /**
5798
+ * SI-002 verifiability gate: never auto-sign a session whose content we could not verify.
5799
+ *
5800
+ * ⚠️ THIS COMMENT SAID THE OPPOSITE UNTIL `DOD-M15-SEALWIRE-1` part B1 (review F-C). It read
5801
+ * *"Today the ONLY tracked unverifiable cause is a content_hash mismatch = TAMPER"*, which was
5802
+ * true when a mismatch was the only way to fail the cross-check and is false now: B1 added an
5803
+ * unreadable algorithm name and a salted frame with no salt, and **both are ordinary**.
5804
+ *
5805
+ * Genuine tamper is a SECURITY event — ERROR, reason `content_tamper`, which is what the AC-008
5806
+ * alarm keys on. The other two must NOT wear that name, or the alarm fires on an honest peer
5807
+ * running a newer build and the operator learns to dismiss it.
5808
+ *
5809
+ * 🚨 AND `content_unverifiable` IS RESERVED — DO NOT REUSE IT HERE. It is specced for *parked
5810
+ * content unrecoverable*, one of the two reasons (with `desynced`, B's tree behind the canonical
5811
+ * sealed tail) awaiting the deferred MSG-001-3b canonical-sequence reconciliation. B1's first
5812
+ * attempt emitted exactly that string for a different condition, which would have made the two
5813
+ * indistinguishable in the log the day the follow-on landed. Hence
5814
+ * `content_verification_unavailable`, deliberately distinct.
5815
+ */
5816
+ const unverifiable = this.#contentDesynced.get(ackKey);
5817
+ if (unverifiable) {
5818
+ /**
5819
+ * DOD-M15-SEALWIRE-1 part B1 (review F1) — THE GATE FIRES FOR BOTH, THE ALARM ONLY FOR ONE.
5820
+ *
5821
+ * `content_tamper` is what the AC-008 alarm keys on, and it must keep meaning what it says. A
5822
+ * frame we could not verify because the peer named an algorithm this build cannot read is an
5823
+ * ordinary version difference; raising a tamper alarm for it would train an operator to
5824
+ * dismiss the alarm, which costs more than the skew.
5825
+ *
5826
+ * But the REFUSAL TO AUTO-SIGN is identical in both cases and non-negotiable: SI-002 is "never
5827
+ * auto-sign a session whose content we could not verify", and "could not" covers both.
5828
+ */
4855
5829
  this.#logger.error("session.seal.autoack.skipped", {
4856
5830
  sessionId,
4857
- reason: "content_tamper",
5831
+ reason: unverifiable === "tampered" ? "content_tamper" : "content_verification_unavailable",
4858
5832
  correlationId,
4859
5833
  });
4860
5834
  // AC-002: the verifiability gate refused — surface counterparty_closing to B's agent as a
@@ -4966,36 +5940,206 @@ export class SessionNodeManager {
4966
5940
  *
4967
5941
  * @returns the appended leaf index (as sequenceNumber) on success.
4968
5942
  */
4969
- async ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId,
4970
5943
  /**
4971
- * DOD-FRONTIER-STRAND-1 AC1: the relay-assigned canonical position for THIS message, taken from
4972
- * the verified ordering record by the caller. Passed EXPLICITLY rather than recovered from
4973
- * `#witnessedSeq`, because that map is keyed by content hash — so two byte-identical messages
4974
- * collapse in it before dedup is ever consulted, which is the whole defect. Absent when the
4975
- * session has no relay witness (relay-degraded): see the announced fallback below.
5944
+ * Mark this session's content unverifiable review F1, and every path that fails the cross-check
5945
+ * must come through here.
5946
+ *
5947
+ * It gates `getSealUpgradeReadiness().tampered` and the auto-acknowledge check, i.e. whether this
5948
+ * agent's key signs anything covering content it could not check. Three refusal paths reach it and
5949
+ * they carry different labels, because what the operator is told must differ; **what the gate does
5950
+ * must not.**
5951
+ *
5952
+ * TAMPERED NEVER DOWNGRADES. A session that has already seen a hash mismatch stays `tampered` even
5953
+ * if a later frame merely names an unreadable algorithm — otherwise a sender that had been caught
5954
+ * could clear its own alarm by sending one more frame with a junk algorithm name, and the seal
5955
+ * would auto-complete.
4976
5956
  */
4977
- canonicalSeqIn) {
4978
- // The transcript is frozen ONLY once it is COMMITTED + signed 'sealed' or
4979
- // 'seal_interrupted_pending' (the bilateral seal commitment) because a later FROST
4980
- // notarization attests that exact root; a late leaf would diverge from it.
4981
- //
4982
- // MSG-001-3b recovery: a merely 'interrupted' session is NOT yet committed. The
4983
- // counterparty's last message(s) may have been parked while this party was offline, so its
4984
- // local transcript is INCOMPLETE (not frozen-final). Recovering that parked content COMPLETES
4985
- // the local view to match the counterparty BEFORE the bilateral seal — it is not a resumption
4986
- // (no new activity, no re-accept) and its root was never committed. So allow 'active' AND
4987
- // 'interrupted'; reject only the two committed states.
4988
- const record = this.getSessionRecord(agentName, sessionId);
4989
- // DOD-UNREAD-1 D4a: NEVER record content you cannot attribute. With no sessions row there is
4990
- // no counterparty — the transcript has no counterparty column, so a row written here is
4991
- // unattributable forever, counted unread by getUnreadSummary, and unreadable by cello_receive
4992
- // (the phantom-session residue). The old "(No DB row = test-only path, allowed.)" fallback
4993
- // papered that in with senderPubkey="unknown". Refuse loudly instead; the content stays
4994
- // un-acked, so a live sender redelivers once the session actually exists. After D3
4995
- // (DOD-INBOUND-GUARD-1) this path is unreachable from the wire — a fail-loud assertion.
4996
- if (!record) {
4997
- this.#logger.warn("session.content.orphaned", { agentName, sessionId, correlationId });
4998
- return { ok: false, reason: "session_orphaned" };
5957
+ /**
5958
+ * Remember that THIS frame was refused for naming an unreadable algorithm, so the park path can
5959
+ * say so if the same message comes back the other way (review F2/F-D).
5960
+ *
5961
+ * BOUNDED, and it has to be: a peer that keeps sending unreadable frames would otherwise grow this
5962
+ * without limit, and it is fed entirely by a remote party. The cap is per session and it drops the
5963
+ * OLDEST entry losing one only costs a missing reconciliation line, whereas an unbounded map fed
5964
+ * by a counterparty is the leak class this codebase has already caught twice.
5965
+ */
5966
+ #noteUnreadableAlgFrame(agentName, sessionId, contentHash, declaredAlg) {
5967
+ const key = this.#k(agentName, sessionId);
5968
+ let byHash = this.#unreadableAlgSeen.get(key);
5969
+ if (!byHash) {
5970
+ byHash = new Map();
5971
+ this.#unreadableAlgSeen.set(key, byHash);
5972
+ }
5973
+ if (byHash.size >= MAX_UNREADABLE_ALG_FRAMES) {
5974
+ const oldest = byHash.keys().next();
5975
+ if (!oldest.done)
5976
+ byHash.delete(oldest.value);
5977
+ }
5978
+ byHash.set(Buffer.from(contentHash).toString("hex"), declaredAlg);
5979
+ }
5980
+ /**
5981
+ * ─── DOD-M15-REFUSED-INBOUND-SILENT-1: refusals the RECEIVING operator can actually see ───────
5982
+ *
5983
+ * Every inbound refusal already logs a `reason`, an `impact` and a `guidance` — and they are
5984
+ * good. They had no reader. From the receiving operator's chair a refused message simply never
5985
+ * arrives: the conversation goes quiet with a full explanation sitting in a file they have no
5986
+ * reason to open, and they conclude the other person stopped replying.
5987
+ *
5988
+ * `content_hash_alg_unknown` is why this matters more than it sounds. It is a VERSION SKEW, so it
5989
+ * affects every message from that counterparty, permanently — not a rare one-off.
5990
+ *
5991
+ * **DEDUPLICATED PER SESSION PER REASON, and that is the design, not an optimisation.** A skewed
5992
+ * peer turns one problem into a flood: the first refusal of a kind is the signal, the ninetieth is
5993
+ * noise that trains the operator to ignore the surface. `count` keeps the scale visible without
5994
+ * repeating the alert.
5995
+ *
5996
+ * **NEVER carries the content.** It failed verification; surfacing it is the injection path the
5997
+ * cross-check exists to close. The operator learns that a message was refused and why — never
5998
+ * what it said.
5999
+ *
6000
+ * In memory, deliberately: a restart re-signalling a still-broken peer is correct behaviour, not
6001
+ * duplication, and durability here would buy nothing the next refusal does not.
6002
+ */
6003
+ /**
6004
+ * (agentName, sessionId) → reason → the notice. `firstAt` was dropped: it was written and read by
6005
+ * nothing, and a field nobody consumes is a claim the code does not keep.
6006
+ */
6007
+ #contentRefusals = new Map();
6008
+ /** Record an inbound refusal for the operator. First of its kind per session is the signal. */
6009
+ noteContentRefusal(agentName, sessionId, reason, detail) {
6010
+ const key = this.#k(agentName, sessionId);
6011
+ let perSession = this.#contentRefusals.get(key);
6012
+ if (!perSession) {
6013
+ perSession = new Map();
6014
+ this.#contentRefusals.set(key, perSession);
6015
+ }
6016
+ const existing = perSession.get(reason);
6017
+ if (existing) {
6018
+ existing.count += 1;
6019
+ return;
6020
+ }
6021
+ perSession.set(reason, {
6022
+ reason,
6023
+ impact: detail?.impact,
6024
+ guidance: detail?.guidance,
6025
+ count: 1,
6026
+ // Per CONSUMER, not one global flag. See `takeContentRefusals`.
6027
+ surfacedTo: new Map(),
6028
+ });
6029
+ }
6030
+ /**
6031
+ * Drain the refusals a GIVEN CONSUMER has not been shown yet, and remember what it was shown.
6032
+ *
6033
+ * ─── Why this is keyed by connection, and not by a single flag ─────────────────────────────────
6034
+ *
6035
+ * It used to set one `surfaced: boolean` on the notice. Two MCP windows attending the same agent
6036
+ * is the ordinary case, and under that flag whoever read FIRST consumed the notice — the second
6037
+ * window was told nothing, permanently. **That is the same defect `takeReceivedContent` had**, and
6038
+ * the comment above the delivery loop in `session-content-handlers.ts` spells out why it was
6039
+ * removed: *"reading is non-destructive by construction. Nothing one consumer does mutates state
6040
+ * another consumer reads."* The whole `taken_by_sibling` apparatus exists because this was paid
6041
+ * for once already; re-introducing it on a different surface makes it no less true.
6042
+ *
6043
+ * ─── Why the count now has a reader ────────────────────────────────────────────────────────────
6044
+ *
6045
+ * The old docstring claimed *"count still grows underneath, so a later reader can ask how many
6046
+ * without being told again."* **There was no later reader.** After the first surfacing the count
6047
+ * incremented under a flag the drain skipped unconditionally, so 3 refusals became 903 and nothing
6048
+ * anywhere could say so — while the comment asserted the opposite.
6049
+ *
6050
+ * So a reason RE-ANNOUNCES to a consumer when its count has grown by an order of magnitude since
6051
+ * that consumer last saw it (1 → 10 → 100 → …), marked `repeat: true`. That keeps the first
6052
+ * refusal the signal and the ninetieth silent, which is the dedup's point, while still making a
6053
+ * skew that has swallowed hundreds of messages visible — at a handful of announcements per
6054
+ * session, not one per message.
6055
+ */
6056
+ takeContentRefusals(agentName, sessionId,
6057
+ /**
6058
+ * REQUIRED, deliberately — no default.
6059
+ *
6060
+ * It had one (`"default"`), and a default is the defect this method was rewritten to remove,
6061
+ * lying in wait: any future call site that omits the argument silently shares ONE bucket across
6062
+ * every window, the first reader consumes the notice for all the others, and nothing fails to
6063
+ * compile and no test goes red. The parameter existing is not the protection; being unable to
6064
+ * forget it is.
6065
+ */
6066
+ consumerId) {
6067
+ const perSession = this.#contentRefusals.get(this.#k(agentName, sessionId));
6068
+ if (!perSession)
6069
+ return [];
6070
+ const out = [];
6071
+ for (const notice of perSession.values()) {
6072
+ const shownAt = notice.surfacedTo.get(consumerId);
6073
+ const firstTime = shownAt === undefined;
6074
+ // `shownAt` is at least 1 whenever it is set, so this cannot loop on zero.
6075
+ if (!firstTime && notice.count < shownAt * 10)
6076
+ continue;
6077
+ notice.surfacedTo.set(consumerId, notice.count);
6078
+ out.push({
6079
+ reason: notice.reason,
6080
+ impact: notice.impact,
6081
+ guidance: notice.guidance,
6082
+ count: notice.count,
6083
+ ...(firstTime ? {} : { repeat: true }),
6084
+ });
6085
+ }
6086
+ return out;
6087
+ }
6088
+ #markContentUnverifiable(agentName, sessionId, why) {
6089
+ const key = this.#k(agentName, sessionId);
6090
+ if (why === "unverifiable" && this.#contentDesynced.get(key) === "tampered")
6091
+ return;
6092
+ this.#contentDesynced.set(key, why);
6093
+ }
6094
+ async ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId,
6095
+ /**
6096
+ * DOD-FRONTIER-STRAND-1 AC1: the relay-assigned canonical position for THIS message, taken from
6097
+ * the verified ordering record by the caller. Passed EXPLICITLY rather than recovered from
6098
+ * `#witnessedSeq`, because that map is keyed by content hash — so two byte-identical messages
6099
+ * collapse in it before dedup is ever consulted, which is the whole defect. Absent when the
6100
+ * session has no relay witness (relay-degraded): see the announced fallback below.
6101
+ */
6102
+ canonicalSeqIn,
6103
+ /**
6104
+ * DOD-M15-SEALWIRE-1 part B1 — the algorithm the SENDER named on the frame, verbatim.
6105
+ *
6106
+ * `undefined` means the frame carried no name, which is a peer that predates the field and is
6107
+ * the one case we may safely assume `sha256` for. It is threaded through rather than read off
6108
+ * the session, because whether a hash is salted is a fact about the FRAME and its sender, never
6109
+ * about what this side happens to hold.
6110
+ */
6111
+ contentHashAlgIn,
6112
+ /**
6113
+ * DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof for this message, when the caller
6114
+ * has one. The caller is the only place that has it — `#recordFrameOrdering` verifies the
6115
+ * signature against the key inside the sender's own signed bytes and matches the signer to this
6116
+ * session's counterparty, and that result reaches here or nowhere.
6117
+ *
6118
+ * Optional, because the soft decode-failure path ingests without it. The row records which it
6119
+ * was, so absence is never silent.
6120
+ */
6121
+ verifiedAuthorship) {
6122
+ // The transcript is frozen ONLY once it is COMMITTED + signed — 'sealed' or
6123
+ // 'seal_interrupted_pending' (the bilateral seal commitment) — because a later FROST
6124
+ // notarization attests that exact root; a late leaf would diverge from it.
6125
+ //
6126
+ // MSG-001-3b recovery: a merely 'interrupted' session is NOT yet committed. The
6127
+ // counterparty's last message(s) may have been parked while this party was offline, so its
6128
+ // local transcript is INCOMPLETE (not frozen-final). Recovering that parked content COMPLETES
6129
+ // the local view to match the counterparty BEFORE the bilateral seal — it is not a resumption
6130
+ // (no new activity, no re-accept) and its root was never committed. So allow 'active' AND
6131
+ // 'interrupted'; reject only the two committed states.
6132
+ const record = this.getSessionRecord(agentName, sessionId);
6133
+ // DOD-UNREAD-1 D4a: NEVER record content you cannot attribute. With no sessions row there is
6134
+ // no counterparty — the transcript has no counterparty column, so a row written here is
6135
+ // unattributable forever, counted unread by getUnreadSummary, and unreadable by cello_receive
6136
+ // (the phantom-session residue). The old "(No DB row = test-only path, allowed.)" fallback
6137
+ // papered that in with senderPubkey="unknown". Refuse loudly instead; the content stays
6138
+ // un-acked, so a live sender redelivers once the session actually exists. After D3
6139
+ // (DOD-INBOUND-GUARD-1) this path is unreachable from the wire — a fail-loud assertion.
6140
+ if (!record) {
6141
+ this.#logger.warn("session.content.orphaned", { agentName, sessionId, correlationId });
6142
+ return { ok: false, reason: "session_orphaned" };
4999
6143
  }
5000
6144
  // DOD-TERMINAL-WAKE-1 (review F1): `abandoned` belongs here too. It is terminal and, unlike
5001
6145
  // `interrupted`, can NEVER complete — there is nothing left to append to and no seal to join.
@@ -5018,18 +6162,126 @@ export class SessionNodeManager {
5018
6162
  });
5019
6163
  return { ok: false, reason: "session_committed" };
5020
6164
  }
5021
- const computed = wireContentHash(content);
6165
+ /**
6166
+ * DOD-M15-SEALWIRE-1 part B1 — VERIFY UNDER THE ALGORITHM THE SENDER NAMED.
6167
+ *
6168
+ * Three outcomes and they must stay apart, because two of them are version differences and only
6169
+ * the third is evidence of tampering. Collapsing them is how a routine skew becomes a security
6170
+ * incident in the operator's log, and how a real tamper gets dismissed as a skew.
6171
+ */
6172
+ /**
6173
+ * ⚠️ `content_hash_alg` IS NOT COVERED BY ANY SIGNATURE — review F1, and it shapes both branches
6174
+ * below.
6175
+ *
6176
+ * The sender's signature is over `structure1_cbor`, which binds `content_hash`. It does NOT bind
6177
+ * the frame envelope, so this field is an unauthenticated CLAIM by whoever sent the frame. That
6178
+ * is fine for choosing how to verify — a wrong choice simply fails — but it means neither branch
6179
+ * may state, as fact, anything it learned only from this field.
6180
+ *
6181
+ * It also means both branches MUST mark the session unverifiable. Before B1 every frame that
6182
+ * failed the cross-check reached `#contentDesynced`, which gates auto-co-signing and unilateral
6183
+ * ratification. Returning early here would have let a sender bypass the tamper detector by
6184
+ * appending one unsigned string: sign hash H, send different bytes, add an unreadable algorithm
6185
+ * name, and the receiver refuses politely, records nothing, and auto-co-signs at seal time.
6186
+ */
6187
+ const algResolved = resolveContentHashAlg(contentHashAlgIn);
6188
+ if (!algResolved.ok) {
6189
+ // A NAME WE CANNOT READ. Not a legacy peer — an unreadable one. There is no value to compare
6190
+ // against, so `content_hash_mismatch` here would be an exit-point label standing in for
6191
+ // "their build is newer than ours" (Invariant 2). Refused by its own name instead.
6192
+ this.#markContentUnverifiable(agentName, sessionId, "unverifiable");
6193
+ this.#noteUnreadableAlgFrame(agentName, sessionId, contentHash, algResolved.value);
6194
+ this.#logger.error("session.content.cross_check.failed", {
6195
+ sessionId, correlationId,
6196
+ reason: "content_hash_alg_unknown",
6197
+ declaredAlg: algResolved.value,
6198
+ // States only what is KNOWN. The old wording said "nothing was altered and nobody did
6199
+ // anything wrong" and "Do not treat this as a security event" — both inferred from the
6200
+ // unsigned field, i.e. from the attacker in the case that matters.
6201
+ impact: "this message could not be verified, so it was NOT ingested and NOT shown. The algorithm name is a claim by the sender and is not covered by any signature, so it does not establish what they actually did. This session will not auto-co-sign at close.",
6202
+ guidance: "Almost always their CELLO build is newer than this one: ask which version they are running, and upgrade. If they are on the SAME version as you, that explanation does not hold and the frame was malformed or crafted — do not close the session by auto-acknowledgement.",
6203
+ });
6204
+ // DOD-M15-REFUSED-INBOUND-SILENT-1: the SAME strings the log just carried, to the operator.
6205
+ // This reason is a version skew, so it affects every message from that counterparty — without
6206
+ // this the conversation goes permanently quiet and they conclude the peer stopped replying.
6207
+ this.noteContentRefusal(agentName, sessionId, "content_hash_alg_unknown", {
6208
+ impact: "this message could not be verified, so it was NOT ingested and NOT shown. The algorithm name is a claim by the sender and is not covered by any signature, so it does not establish what they actually did. This session will not auto-co-sign at close.",
6209
+ guidance: "Almost always their CELLO build is newer than this one: ask which version they are running, and upgrade. If they are on the SAME version as you, that explanation does not hold and the frame was malformed or crafted — do not close the session by auto-acknowledgement.",
6210
+ });
6211
+ return { ok: false, reason: "content_hash_alg_unknown" };
6212
+ }
6213
+ let computed;
6214
+ try {
6215
+ computed = contentHashFor(content, {
6216
+ alg: algResolved.alg,
6217
+ // The salt is OURS — the sender's frame never carries one, and could not be trusted if it
6218
+ // did. A salted frame we hold no salt for throws below and is refused by name.
6219
+ salt: this.#getSessionSalt(agentName, sessionId),
6220
+ });
6221
+ }
6222
+ catch (err) {
6223
+ // Reached when the peer named the salted algorithm and this side holds no salt for the
6224
+ // session — the agreement never completed, or its record is gone. Distinct from a mismatch
6225
+ // for the same reason as above: nothing was tampered with, we simply cannot check it.
6226
+ this.#markContentUnverifiable(agentName, sessionId, "unverifiable");
6227
+ this.#logger.error("session.content.cross_check.failed", {
6228
+ sessionId, correlationId,
6229
+ reason: "content_hash_salt_unavailable",
6230
+ declaredAlg: algResolved.alg,
6231
+ detail: extractErrorMessage(err),
6232
+ // "Nothing was altered" was the same mistake as the branch above: it is not knowable from
6233
+ // here. What IS knowable is that we could not check.
6234
+ impact: "this message could not be verified — the sender says it is salted and this side holds no salt for the session — so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
6235
+ // Review F6: `#getSessionSalt` returns null for THREE conditions and only one of them wants
6236
+ // a close. A read failure and a corrupt row both leave us holding no salt, which is exactly
6237
+ // what makes the agreement re-offer a contribution and repair itself on the next connect.
6238
+ //
6239
+ // The adoption refusal is the FOURTH, added with the Decision #8 guard, and it is the only
6240
+ // one that does not repair: this side declined the salt permanently for this session, so
6241
+ // waiting for a reconnect is exactly the wrong advice. Leaving it out of this list would
6242
+ // have sent an operator to look for a read failure that is not there and never will be.
6243
+ guidance: "Look for session.salt.adoption.refused first: if it is there, this side declined the salt because the session had already hashed messages, that is permanent for this session, and reconnecting will NOT fix it — close the session and start a new one. Otherwise look for session.salt.read.failed or session.salt.persist.failed. If either is present the agreement re-runs on the next reconnect and this repairs itself — wait for that before doing anything. If none of the three is present, the agreement never completed with this counterparty: close the session and start a new one. In every case the transcript up to here is intact.",
6244
+ });
6245
+ // DOD-M15-REFUSED-INBOUND-SILENT-1 — and this branch needed it MORE than the two that had it.
6246
+ //
6247
+ // It was refused, logged with a full impact and guidance, not ingested, not shown — and the
6248
+ // operator was told nothing. Twenty lines below the branches that were wired, in the same
6249
+ // function, with the same shape.
6250
+ //
6251
+ // One of its four causes is permanent, and the guidance above says so in its own words: an
6252
+ // adoption refusal means this side declined the salt for the life of the session and
6253
+ // reconnecting will NOT fix it. So the failure this line exists to close — the conversation
6254
+ // goes quiet, the explanation sits in a log nobody opens — was still live on the one branch
6255
+ // that never repairs itself.
6256
+ //
6257
+ // The guidance is passed by reference to the log's own text rather than duplicated: a second
6258
+ // copy is a second thing to keep true, and the log's version is the one that gets maintained.
6259
+ this.noteContentRefusal(agentName, sessionId, "content_hash_salt_unavailable", {
6260
+ impact: "this message could not be verified — the sender says it is salted and this side holds no salt for the session — so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
6261
+ guidance: "If this side refused the salt because the session had already hashed messages, that is PERMANENT for this session and reconnecting will not fix it — close the session and start a new one. Otherwise the salt agreement re-runs on the next reconnect and this repairs itself. Check session.salt.adoption.refused in the log to tell which. The transcript up to here is intact either way.",
6262
+ });
6263
+ return { ok: false, reason: "content_hash_salt_unavailable" };
6264
+ }
5022
6265
  const contentHashHex = Buffer.from(contentHash).toString("hex");
5023
6266
  if (Buffer.from(computed).toString("hex") !== contentHashHex) {
5024
6267
  this.#logger.warn("session.content.cross_check.failed", {
5025
6268
  sessionId,
5026
6269
  reason: "content_hash_mismatch",
6270
+ // WHICH algorithm the comparison ran under. Without it, a mismatch is unfalsifiable from the
6271
+ // log: an operator cannot tell "the bytes were altered" from "we checked it the wrong way".
6272
+ declaredAlg: algResolved.alg,
5027
6273
  correlationId,
5028
6274
  });
5029
6275
  // M7-UPGRADE-002 (SI-002): a tamper makes this session's content unverifiable — the
5030
6276
  // auto-acknowledge gate must never auto-co-sign it. The session stays alive (DOD-MSG-7),
5031
6277
  // but the responder seal now requires the agent's explicit decision, not an auto-ack.
5032
- this.#contentDesynced.add(this.#k(agentName, sessionId));
6278
+ this.#markContentUnverifiable(agentName, sessionId, "tampered");
6279
+ // DOD-M15-REFUSED-INBOUND-SILENT-1. Deliberately does NOT include the content or the hashes:
6280
+ // it failed verification, and showing it is the injection path this cross-check closes.
6281
+ this.noteContentRefusal(agentName, sessionId, "content_hash_mismatch", {
6282
+ impact: "a message arrived whose bytes do not match the hash the sender committed to, so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
6283
+ guidance: "Either the message was altered in transit or the sender's record is wrong. Ask the counterparty to resend. Do not close this session by auto-acknowledgement — seal it only by an explicit decision.",
6284
+ });
5033
6285
  return { ok: false, reason: "content_hash_mismatch" };
5034
6286
  }
5035
6287
  const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
@@ -5183,12 +6435,36 @@ export class SessionNodeManager {
5183
6435
  // verdict means the content is NOT delivered to the agent: it is not held, not buffered, and
5184
6436
  // no leaf is appended — the message stays un-acked so the sender's TTF/park/retry redelivers
5185
6437
  // it once the gateway is reachable again (DB-001 fail-closed: hold, never expose ungated).
5186
- const inboundVerdict = await this.#securityGateway.screenInbound(content, {
5187
- direction: "inbound",
5188
- agentName,
5189
- sessionId,
5190
- correlationId,
5191
- });
6438
+ // DOD-DOC-SCREEN-CLASSIFY-1: a DOCUMENT frame skips the gateway's content screen HERE, and is
6439
+ // screened later on text instead of bytes. Every content step is inert or worse for one at this
6440
+ // point — the sanitizer's rewrites are deliberately discarded by the funnel below (rewriting a
6441
+ // signed envelope destroys it), and language/injection judge a UTF-8 decode of binary. Size stays
6442
+ // bounded twice (MAX_DOCUMENT_FRAME_BYTES at classify, the gate's own cap).
6443
+ //
6444
+ // WHAT IS TRADED, stated plainly: the screen skipped here is fail-CLOSED (a gateway that is down
6445
+ // returns a transient block, and the frame is held un-acked for redelivery). Its replacement —
6446
+ // the gate's in-process rules, then the semantic screen at `document-inbound.ts` step 7a-bis —
6447
+ // is fail-OPEN on that same condition, because holding document convergence hostage to an
6448
+ // optional layer breaks a layer that degrades by design. That degradation is LOGGED BY NAME
6449
+ // there (`document.inbound.screen.unavailable`); it is not silent, and it is not free.
6450
+ //
6451
+ // Logged by name so the skip is visible rather than assumed.
6452
+ const isDocFrame = this.#isDocumentFrame?.(content) === true;
6453
+ if (isDocFrame) {
6454
+ this.#logger.info("session.content.screen.skipped_document_frame", {
6455
+ sessionId,
6456
+ agentName,
6457
+ correlationId,
6458
+ });
6459
+ }
6460
+ const inboundVerdict = isDocFrame
6461
+ ? { disposition: "allow", content }
6462
+ : await this.#securityGateway.screenInbound(content, {
6463
+ direction: "inbound",
6464
+ agentName,
6465
+ sessionId,
6466
+ correlationId,
6467
+ });
5192
6468
  // M9 terminal-vs-transient split. A TERMINAL block (inboundVerdict.terminal) is a detector
5193
6469
  // rejecting the CONTENT itself — a confident non-allowlisted language (IN-003), a high-score
5194
6470
  // injection (IN-002), or an oversized payload (IN-001). The identical bytes would be rejected
@@ -5377,7 +6653,7 @@ export class SessionNodeManager {
5377
6653
  // agent (screenedOut); a delivered message buffers + leafs via #appendVerifiedContent.
5378
6654
  const leafIndex = terminalBlock
5379
6655
  ? this.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId).leafIndex
5380
- : this.#appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId, content).leafIndex;
6656
+ : this.#appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId, content, verifiedAuthorship).leafIndex;
5381
6657
  // DOD-COATTEND-1 (review F2): the plaintext failed to reach the transcript, and since Tier 1 the
5382
6658
  // transcript IS the delivery path — so this message can never be handed to any session. Report
5383
6659
  // the ingest as failed. Reporting `ok: true` here is what let a local SQLCipher failure surface,
@@ -5580,11 +6856,20 @@ export class SessionNodeManager {
5580
6856
  // Review HIGH-2: NOT `(highWaterSeq + 1) - treeSize`. That subtraction silently assumes the
5581
6857
  // relay's sequence space and this tree's index space count the same things, and they do not:
5582
6858
  // `relay-node.ts` increments seq_counter for EVERY accepted leaf including CTRL (0x02), while
5583
- // `appendSessionLeaf` is only ever called with "msg" — `submitSealLeaf` deliberately computes
5584
- // its root without mutating the durable tree. So one seal ctrl leaf offsets the two spaces
5585
- // permanently, and any msg witnessed afterwards would read as a missing leaf FOREVER. That is a
5586
- // false positive, and a false positive here is worse than the bug it guards: it makes a healthy
6859
+ // NOTHING APPENDS A CTRL LEAF TO THIS TREE — `submitSealLeaf` deliberately computes its root
6860
+ // without mutating the durable tree. So one seal ctrl leaf offsets the two spaces permanently,
6861
+ // and any msg witnessed afterwards would read as a missing leaf FOREVER. That is a false
6862
+ // positive, and a false positive here is worse than the bug it guards: it makes a healthy
5587
6863
  // session unsealable, leaving force-abandon (no receipt) as the only exit. `seal-upgrade.ts`
6864
+ //
6865
+ // ⚠️ THIS SENTENCE USED TO READ *"`appendSessionLeaf` is only ever called with 'msg'"*, AND THAT
6866
+ // WAS FALSE WHEN IT WAS WRITTEN — corrected, not deleted, per review pass 1 F8. Line ~7730
6867
+ // appends "doc", and `WritableSessionTreeLeafKind` permits "ctrl" outright, so the type does not
6868
+ // enforce it either. Doc leaves are harmless to this subtraction because they are witnessed by
6869
+ // the relay too (LEAF_KIND_DOC) and counted in BOTH spaces; the load-bearing property is only
6870
+ // ever about CTRL. Stating it as "msg only" made a narrower claim than the code supports and a
6871
+ // stronger one than it holds — so a reader checking it would find a counter-example, conclude
6872
+ // the reasoning was stale, and be one step from "fixing" the subtraction back.
5588
6873
  // already documents the same `leaf_count - 1` offset.
5589
6874
  //
5590
6875
  // `#witnessedSeq` answers the question directly instead of inferring it. It gains an entry when
@@ -5601,10 +6886,16 @@ export class SessionNodeManager {
5601
6886
  for (const e of this.#heldContent.get(key)?.values() ?? [])
5602
6887
  if (e.origin === "sent")
5603
6888
  heldOwn++;
6889
+ // DOD-M15-DIVERGE-1: the third term, and the one that closes the asymmetry. `#diverged` is set
6890
+ // only where the parting is PROVEN — an ack came back behind our frontier — never where it is
6891
+ // merely suspected, because a gate that refuses a healthy session forever is worse than the bug
6892
+ // it guards: force-abandon, with no receipt, becomes the only exit.
6893
+ const diverged = this.#diverged.has(key);
5604
6894
  return {
5605
- ready: missingLeaves === 0 && heldCount === 0,
6895
+ ready: missingLeaves === 0 && heldCount === 0 && !diverged,
5606
6896
  treeSize, highWaterSeq, heldCount, missingLeaves,
5607
6897
  heldOwn, heldReceived: heldCount - heldOwn,
6898
+ diverged,
5608
6899
  };
5609
6900
  }
5610
6901
  /**
@@ -5626,6 +6917,17 @@ export class SessionNodeManager {
5626
6917
  sealReadinessView(agentName, sessionId) {
5627
6918
  const key = this.#k(agentName, sessionId);
5628
6919
  const r = this.sealReadiness(agentName, sessionId);
6920
+ // DOD-M15-DIVERGE-1: READ THIS BEFORE THE `!ready` BRANCH, and the order is load-bearing.
6921
+ // `diverged` is now a term in `ready`, so a diverged session reaches `!ready` — where it would
6922
+ // report `blocked` with awaitingArrival and heldBehindGap both ZERO, replacing an accurate,
6923
+ // specific answer with one that describes nothing and invites a retry that can never work.
6924
+ // Divergence is permanent and has its own state; the gap cases below are the ones that resolve.
6925
+ if (r.diverged) {
6926
+ // NOT `ready`. The tree is ahead of the relay's counter for good, so a close here signs a root
6927
+ // the counterparty answers `leaf_count_mismatch` to — terminal, and the receipt is gone. The
6928
+ // raw counters cannot see this: nothing is missing and nothing is held.
6929
+ return { state: "unknown", reason: "record_diverged_from_relay" };
6930
+ }
5629
6931
  if (!r.ready) {
5630
6932
  const oldestHeldMs = this.#oldestHeldMs(agentName, sessionId);
5631
6933
  return {
@@ -5638,12 +6940,6 @@ export class SessionNodeManager {
5638
6940
  oldestHeldMs,
5639
6941
  };
5640
6942
  }
5641
- if (this.#diverged.has(key)) {
5642
- // NOT `ready`. The tree is ahead of the relay's counter for good, so a close here signs a root
5643
- // the counterparty answers `leaf_count_mismatch` to — terminal, and the receipt is gone. The
5644
- // raw counters cannot see this: nothing is missing and nothing is held.
5645
- return { state: "unknown", reason: "record_diverged_from_relay" };
5646
- }
5647
6943
  if (r.treeSize > 0 && !this.#orderingObserved.has(key)) {
5648
6944
  return {
5649
6945
  state: "unknown",
@@ -5788,7 +7084,17 @@ export class SessionNodeManager {
5788
7084
  * CRDT is not a false positive, it is permanent divergence that both sides converge on and
5789
7085
  * neither can see.
5790
7086
  */
5791
- originalContent) {
7087
+ originalContent,
7088
+ /**
7089
+ * DOD-M15-SEALWIRE-1 bullet 5: threaded from `ingestReceivedContent`, which is the only place
7090
+ * that has it — `#recordFrameOrdering` verified this signature against the pubkey inside the
7091
+ * sender's own signed bytes and matched the signer to this session's counterparty. It reaches
7092
+ * the transcript row from here or not at all.
7093
+ *
7094
+ * Undefined on the held-release and soft-fallback paths; the row records that as
7095
+ * `local_session_state` rather than leaving it indistinguishable from a proven one.
7096
+ */
7097
+ verifiedAuthorship) {
5792
7098
  // M14 / DOD-DOC-INBOUND-2 — DOCUMENT FRAMES DIVERGE HERE, and the three-way split is the whole
5793
7099
  // contract:
5794
7100
  //
@@ -5838,7 +7144,11 @@ export class SessionNodeManager {
5838
7144
  // DOD-LOG-1: persist the readable RECEIVED plaintext to the durable transcript, keyed by the
5839
7145
  // canonical leaf sequence so it joins the committed hash chain (survives restart; INV-3 — the
5840
7146
  // relay/directory never see this plaintext, only the hash).
5841
- const durable = this.recordTranscriptMessage(agentName, sessionId, leafIndex, "received", content, correlationId);
7147
+ const durable = this.recordTranscriptMessage(agentName, sessionId, leafIndex, "received", content, correlationId,
7148
+ // DOD-M15-SEALWIRE-1 bullet 5: present only when the ordering record verified AND the signer
7149
+ // matched this session's counterparty. Undefined on the soft fallback, which the row records
7150
+ // as `local_session_state` rather than leaving indistinguishable.
7151
+ verifiedAuthorship);
5842
7152
  const recvKey = this.#k(agentName, sessionId);
5843
7153
  if (!durable) {
5844
7154
  // The leaf is committed and the plaintext is not. Delivery reads the transcript, so this
@@ -6178,6 +7488,33 @@ export class SessionNodeManager {
6178
7488
  const nextExpected = this.getSessionTree(agentName, sessionId).size();
6179
7489
  if (assignedSeq === undefined) {
6180
7490
  const { leafIndex } = this.appendSessionLeaf(agentName, sessionId, kind, contentHashHex, correlationId);
7491
+ /**
7492
+ * ─── DOD-M15-UNWITNESSED-1(b): SAY IT HERE, where the code says the damage happens ────────
7493
+ *
7494
+ * This branch was entirely silent — no log, no flag — while the `position_behind_frontier`
7495
+ * branch twenty lines below logs at ERROR and its own comment says the loss occurred
7496
+ * *"at the unwitnessed append, not here."* So the system announced the consequence one send
7497
+ * later than it announced nothing about the cause.
7498
+ *
7499
+ * **This does NOT gate the seal, deliberately.** The DoD bar is explicit: do not gate on
7500
+ * suspicion, because a relay that has not witnessed a leaf YET is indistinguishable here from
7501
+ * one that never will, and refusing on the first would make a healthy session unsealable —
7502
+ * which is worse than the thing it guards. What was missing is not a refusal, it is the
7503
+ * ERROR at the moment the code already believes something was lost.
7504
+ */
7505
+ this.#logger.error("session.tree.own_leaf_unwitnessed", {
7506
+ sessionId,
7507
+ leafIndex,
7508
+ kind,
7509
+ correlationId,
7510
+ impact: "this message was appended to the local record with NO relay witness, so the ordering " +
7511
+ "authority has no copy of it. The counterparty's tree cannot gain this leaf from the " +
7512
+ "relay, so the two records may no longer agree — and a bilateral seal needs them to.",
7513
+ guidance: "Usually the relay was briefly unreachable and the next send re-establishes ordering. If " +
7514
+ "it repeats, the relay is not carrying this session: check connectivity before closing, " +
7515
+ "because sealing on a record the counterparty cannot match produces a receipt only one " +
7516
+ "side can verify.",
7517
+ });
6181
7518
  return { placed: true, leafIndex };
6182
7519
  }
6183
7520
  if (assignedSeq === nextExpected) {
@@ -6202,7 +7539,7 @@ export class SessionNodeManager {
6202
7539
  agentName, sessionId, assignedSeq, nextExpected, contentHash: contentHashHex, correlationId,
6203
7540
  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",
6204
7541
  });
6205
- this.#diverged.add(this.#k(agentName, sessionId));
7542
+ this.markSessionDiverged(agentName, sessionId);
6206
7543
  const { leafIndex } = this.appendSessionLeaf(agentName, sessionId, kind, contentHashHex, correlationId);
6207
7544
  return { placed: true, leafIndex, diverged: true };
6208
7545
  }
@@ -6569,7 +7906,15 @@ export class SessionNodeManager {
6569
7906
  * `unref`'d so an in-flight wait never keeps the daemon process (or a test runner)
6570
7907
  * alive on its own.
6571
7908
  */
6572
- #trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, structure1Cbor, structure2Cbor) {
7909
+ /**
7910
+ * `contentHashAlg` is `string | undefined`, NOT optional — B2b-1 pass-2 F1.
7911
+ *
7912
+ * The previous fix applied this shape to `#parkContent` and left its SIBLING on the same code path
7913
+ * optional, so dropping the argument here compiled clean and no test could see it — the TTF park
7914
+ * route reads this map, and a v2 envelope omits the field entirely whenever the value is `sha256`,
7915
+ * which is every value in play today. That re-opened the exact finding the fix closed.
7916
+ */
7917
+ #trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, structure1Cbor, structure2Cbor, contentHashAlg) {
6573
7918
  const hashHex = Buffer.from(contentHash).toString("hex");
6574
7919
  const ackKey = this.#k(agentName, sessionId);
6575
7920
  let bySession = this.#awaitingAck.get(ackKey);
@@ -6588,7 +7933,10 @@ export class SessionNodeManager {
6588
7933
  timer.unref();
6589
7934
  // DOD-MSG-4 (2b, review #1): retain the relay's ordering record so a TTF-triggered park carries
6590
7935
  // it too (not only the direct-dial-fail park) — so a TTF-parked entry is self-ordering on recover.
6591
- bySession.set(hashHex, { timer, content, correlationId, structure1Cbor, structure2Cbor });
7936
+ // B2b-1 review F2: the algorithm rides WITH the entry. The TTF-expiry park route reads this map
7937
+ // minutes later, in-process, and without it that copy names nothing (= sha256) while the direct
7938
+ // frame named something else — the same message, two claims about what it is, no restart needed.
7939
+ bySession.set(hashHex, { timer, content, correlationId, structure1Cbor, structure2Cbor, contentHashAlg });
6592
7940
  }
6593
7941
  /**
6594
7942
  * Resolve an awaiting-ACK entry on a `persisted` delivery ACK (AC-001/AC-002): cancel
@@ -6644,7 +7992,7 @@ export class SessionNodeManager {
6644
7992
  // M12-P12 (review pass 2): the ordering record travels on THIS path too. It is in hand — the
6645
7993
  // very next statement hands it to #parkContent — and a TTF row written without it re-parks in
6646
7994
  // arrival order, which is the divergent-leaf-index failure the durable columns exist to stop.
6647
- this.#onAwaitingTtf?.(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor);
7995
+ this.#onAwaitingTtf?.(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor, entry.contentHashAlg);
6648
7996
  }
6649
7997
  catch (err) {
6650
7998
  this.#logger.error("content.park.backstop.failed", {
@@ -6658,7 +8006,8 @@ export class SessionNodeManager {
6658
8006
  // Fire-and-forget: unlike sendContent's live caller, nothing here is awaiting an IPC response
6659
8007
  // to shape (the TTF timer fires long after cello_send already returned) — the deposit's own
6660
8008
  // success/failure logging inside #parkContent is the only observability this path needs.
6661
- void this.#parkContent(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor);
8009
+ // B2b-1 review F2 — the THIRD `#parkContent` caller, and the one that was left unthreaded.
8010
+ void this.#parkContent(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor, entry.contentHashAlg);
6662
8011
  }
6663
8012
  /**
6664
8013
  * Send an unsigned `persisted` delivery ACK back to the sender over the same
@@ -6915,7 +8264,88 @@ export class SessionNodeManager {
6915
8264
  contentHash: contentHashHex,
6916
8265
  correlationId,
6917
8266
  });
6918
- return await this.ingestReceivedContent(agentName, sessionId, env.content, contentHash, correlationId, recoveredSeq ?? undefined);
8267
+ /**
8268
+ * DOD-M15-SEALWIRE-1 part B1 — RECOVERED-FROM-PARK CONTENT CARRIES NO ALGORITHM NAME, and that
8269
+ * is correct today rather than an oversight.
8270
+ *
8271
+ * The park envelope has no field for one, so this passes `undefined`, which resolves to
8272
+ * `sha256`. In part B1 that is exactly right and provably so: **no sender salts yet**, so every
8273
+ * parked entry in existence was hashed unsalted.
8274
+ *
8275
+ * ✅ FIXED IN PART B2a, at BOTH sites: here, and the independent verifier in `content-park.ts`.
8276
+ * The envelope carries the algorithm from v3 onward, and a v2 envelope's absent field resolves to
8277
+ * `sha256` — which is what a peer predating the field actually used. Every envelope this build
8278
+ * emits is still v2, because nothing salts yet.
8279
+ *
8280
+ * ─── AND THE REFUSAL DOES NOT HOLD — review F2 ────────────────────────────────────────────
8281
+ *
8282
+ * A direct-path refusal sends no delivery ACK, so the sender's TTF backstop parks the message
8283
+ * and it arrives here seconds later, where `undefined` means `sha256` and it may well succeed.
8284
+ * A frame refused BY NAME on one path is then accepted on the other, with nothing tying the two
8285
+ * together in the log — an operator sees an ERROR and, ten seconds on, a healthy delivery.
8286
+ *
8287
+ * That is not repaired by refusing here as well: today's park entry genuinely IS `sha256`, and
8288
+ * refusing it would drop good mail. What is wrong is the SILENCE, so the reconciliation is
8289
+ * logged instead — the two events become one story, and B2 removes the ambiguity for real by
8290
+ * putting the name in the envelope.
8291
+ */
8292
+ /**
8293
+ * ⚠️ LOGGED AFTER THE INGEST, NOT BEFORE IT — review B2a F1, and the previous version of this
8294
+ * block ANNOUNCED A DELIVERY THAT DOES NOT HAPPEN.
8295
+ *
8296
+ * It fired on a memo hit and said *"the message is being delivered by the other route"*, which
8297
+ * was true-by-construction only while the park path passed `undefined` for the algorithm. Part
8298
+ * B2a made the park path carry `env.contentHashAlg` — so a peer that names an unreadable
8299
+ * algorithm on the direct path AND parks the same content as v3 with the same name is refused
8300
+ * AGAIN here, re-arms the memo, is never confirm-deleted, and repeats on every drain. An
8301
+ * unbounded stream of warnings asserting a delivery that never occurs, drowning the real
8302
+ * reconciliation when the sender eventually re-parks as v2.
8303
+ *
8304
+ * The claim is only sound once the ingest has actually succeeded, so it is made there.
8305
+ */
8306
+ const memoKey = this.#k(agentName, sessionId);
8307
+ const priorDeclaredAlg = this.#unreadableAlgSeen.get(memoKey)?.get(contentHashHex);
8308
+ const result = await this.ingestReceivedContent(agentName, sessionId, env.content, contentHash, correlationId, recoveredSeq ?? undefined,
8309
+ // The envelope's own claim, verbatim — `undefined` on a v2 envelope, which resolves to
8310
+ // `sha256` and is exactly right for a peer that predates the field.
8311
+ env.contentHashAlg);
8312
+ /**
8313
+ * `ok` IS NOT "DELIVERED" — review B2a pass-2 F1, and this is the same class the F1 fix was
8314
+ * raised for, one predicate over.
8315
+ *
8316
+ * `ingestReceivedContent` returns `ok` in three shapes and only one is a delivery:
8317
+ * `{ok, held: true}` — buffered behind an ordering gap; not appended, not shown YET.
8318
+ * `{ok, screenedOut: true}` — leafed and PERMANENTLY never shown to the agent.
8319
+ * Announcing *"the message was delivered by the other route"* for either is a false all-clear on
8320
+ * the operator's one line about this message, and the memo is deleted in the same breath, so
8321
+ * nothing ever re-raises it.
8322
+ *
8323
+ * Leaving the memo ARMED on `held` is deliberate: the release path re-enters ingest, which is
8324
+ * when delivery actually happens, and the claim becomes true there.
8325
+ *
8326
+ * ⚠️ THE `screenedOut` CLAUSE IS UNREACHABLE TODAY and is kept anyway — said out loud so nobody
8327
+ * reads it as covered. A terminal inbound block needs a detector the shipping security gateway
8328
+ * does not wire, so it returns only `allow` and a fail-closed non-terminal `block`. Measured: a
8329
+ * mutant dropping just that clause SURVIVES the suite. It stays because the day a detector is
8330
+ * wired, this line is the difference between an all-clear and a permanent silent discard — and
8331
+ * finding that then costs more than the clause costs now.
8332
+ */
8333
+ if (priorDeclaredAlg !== undefined && result.ok && result.held !== true && result.screenedOut !== true) {
8334
+ // Cleared ONLY on a real reconciliation. Clearing on the lookup (as this did) forgets the
8335
+ // refusal even when the recovery fails, so the next genuine reconciliation says nothing.
8336
+ const byHash = this.#unreadableAlgSeen.get(memoKey);
8337
+ byHash?.delete(contentHashHex);
8338
+ if (byHash && byHash.size === 0)
8339
+ this.#unreadableAlgSeen.delete(memoKey);
8340
+ this.#logger.warn("content.recover.alg_refusal_reconciled", {
8341
+ agentName, sessionId, correlationId,
8342
+ contentHash: contentHashHex,
8343
+ priorDeclaredAlg,
8344
+ recoveredAlg: env.contentHashAlg ?? "(absent → sha256)",
8345
+ impact: "THIS EXACT MESSAGE was refused on the direct path because it named an algorithm this build cannot read, and the same content has now been accepted via the relay park under an algorithm this build CAN read. The refusal did not hold: the message was delivered by the other route.",
8346
+ });
8347
+ }
8348
+ return result;
6919
8349
  }
6920
8350
  /**
6921
8351
  * Review M4: bounded memo of parked entries we have already refused, so a mailbox stuffed with
@@ -6937,101 +8367,1349 @@ export class SessionNodeManager {
6937
8367
  * record (the recover handler lives in daemon.ts, which has no access to the private method).
6938
8368
  */
6939
8369
  recordOrderingRecord(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId) {
6940
- return this.#recordFrameOrdering(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId, "park");
6941
- }
6942
- #recordFrameOrdering(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId, source = "content_frame") {
8370
+ // DOD-M15-FRAME-1: the POSITION only, deliberately this path does not act on `fatal`, and the
8371
+ // reason is that its identity proof is somewhere else and is already fail-closed. Parked content
8372
+ // arrives inside a sealed envelope carrying the sender's signature over
8373
+ // (session_id, recipient_pubkey, content_hash), and recovery already refuses a missing, bad, or
8374
+ // wrong-signer envelope. Adding a second, weaker refusal here on the ordering record would gate
8375
+ // mail retrieval on a record the relay-degraded path is allowed to omit, which is the
8376
+ // false-positive shape this unit is careful to avoid. The live direct path is where the ordering
8377
+ // record IS the proof, and that is where `fatal` is consumed.
8378
+ return this.#recordFrameOrdering(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId, "park").seq;
8379
+ }
8380
+ /**
8381
+ * DOD-M15-FRAME-1 — NARROWING THE GATE DOES NOT EVICT ANYONE ALREADY INSIDE. This does.
8382
+ *
8383
+ * libp2p consults the gater only when a connection is ESTABLISHED, so narrowing it never evicts a
8384
+ * peer already attached. That is why this sweep exists and why it cannot be replaced by the gate.
8385
+ *
8386
+ * A peer that attached early can therefore hold its connection open, still be attached when the
8387
+ * receiver is promoted, and be sitting there when the content protocol activates. DOD-M15-ASSIGN-1
8388
+ * shrank who can get that foothold — an unclaimed standing receiver now admits nobody inbound,
8389
+ * where it used to admit everyone — but it did not, and could not, change the constraint above.
8390
+ *
8391
+ * That is the foothold the whole injection path depends on — placed before the door narrows. The
8392
+ * frame-level gate above refuses what they send; this closes the connection they send it on, so
8393
+ * the stranger is not merely ineffective but gone, and is not sitting there for the next protocol
8394
+ * to activate.
8395
+ *
8396
+ * BEST-EFFORT BY CONSTRUCTION, and it must stay that way. A failure to hang up one peer must not
8397
+ * fail the session setup that is mid-flight — the frame gate is the load-bearing control and it
8398
+ * does not depend on this succeeding. Relay peers are exempt: they are on the OUTBOUND allowlist
8399
+ * because reservation refreshes ride them, and hanging one up would cost the agent its inbound
8400
+ * reachability to remove a peer that cannot speak the content protocol anyway.
8401
+ */
8402
+ async #evictPeersOutsideGate(node, gater, sessionId, allowedPeerId, trigger) {
8403
+ let connections;
6943
8404
  try {
6944
- const s1 = decode(structure1Cbor);
6945
- const s2 = decode(structure2Cbor);
6946
- const s1Hash = s1?.[1];
6947
- const s1Pubkey = s1?.[2];
6948
- const seq = typeof s2?.[0] === "number" ? s2[0] : -1;
6949
- const s2Sig = s2?.[3];
6950
- if (!(s1Hash instanceof Uint8Array) || !(s1Pubkey instanceof Uint8Array) || !(s2Sig instanceof Uint8Array) || seq < 1) {
6951
- this.#logger.warn("session.content.ordering.malformed", { sessionId, correlationId });
6952
- return null;
6953
- }
6954
- // The framed ordering record must bind to THIS content (its hash) — else it orders the wrong bytes.
6955
- const contentHashHex = Buffer.from(contentHash).toString("hex");
6956
- if (Buffer.from(s1Hash).toString("hex") !== contentHashHex) {
6957
- this.#logger.warn("session.content.ordering.hash_mismatch", { sessionId, correlationId });
6958
- return null;
6959
- }
6960
- // Verify the SENDER's Ed25519 signature over the exact signed bytes (structure1_cbor) — the same
6961
- // check the relay performs. Proves the counterparty committed to this (content_hash @ sequence).
6962
- if (!verify(s1Pubkey, structure1Cbor, s2Sig)) {
6963
- this.#logger.warn("session.content.ordering.bad_signature", { sessionId, correlationId });
6964
- return null;
6965
- }
6966
- // Sovereign-node cross-check: the signer MUST be THIS session's counterparty, not an unrelated
6967
- // key. FAIL CLOSED (review L) — if the counterparty pubkey is unknown we cannot prove the signer,
6968
- // so we do NOT trust the framed ordering record (fall back to the witness stream / arrival). The
6969
- // "B does not trust the counterparty for ordering" invariant is non-negotiable; never fail open.
6970
- // Review M1: compare BYTES, not hex strings — `counterparty_pubkey` is stored verbatim from the
6971
- // IPC param and is never case-normalized, so a string compare would fail for a mixed-case
6972
- // pubkey and silently strip the canonical ordering from every message in that session.
6973
- const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
6974
- if (!pubkeyMatchesHex(s1Pubkey, counterparty)) {
6975
- this.#logger.warn("session.content.ordering.wrong_signer", {
6976
- sessionId,
6977
- reason: counterparty ? "signer_not_counterparty" : "counterparty_unknown",
6978
- correlationId,
6979
- });
6980
- return null;
6981
- }
6982
- // Verified — record the relay-assigned canonical sequence (1-based → 0-based leaf index) for the gate.
6983
- this.recordWitnessedSequence(agentName, sessionId, contentHashHex, seq - 1);
6984
- this.#logger.info("session.content.ordering.recorded", {
6985
- sessionId,
6986
- canonicalSeq: seq - 1,
6987
- source,
6988
- correlationId,
6989
- });
6990
- return seq - 1;
8405
+ connections = node.getConnections();
6991
8406
  }
6992
8407
  catch (err) {
6993
- this.#logger.warn("session.content.ordering.decode_failed", {
6994
- sessionId,
6995
- error: err instanceof Error ? err.message : String(err),
6996
- correlationId,
8408
+ this.#logger.debug("session.gate.evict.unavailable", {
8409
+ sessionId, trigger, error: extractErrorMessage(err),
6997
8410
  });
8411
+ return;
6998
8412
  }
6999
- // No verified position — the caller falls back to the announced hash-dedup path.
7000
- return null;
8413
+ const toEvict = connections
8414
+ .map((c) => c.peerId)
8415
+ .filter((peerId) => peerId !== allowedPeerId && !gater.isAllowedOutboundPeer(peerId));
8416
+ /**
8417
+ * CONCURRENT AND CAPPED, because the count is ATTACKER-CONTROLLED (review F4).
8418
+ *
8419
+ * This runs inside `acceptSession`, before the session row is written, and the standing receiver
8420
+ * used to accept everyone (closed by DOD-M15-ASSIGN-1) — so opening N connections to an agent's advertised receiver used
8421
+ * to make every later session setup on that agent wait for N sequential graceful closes.
8422
+ * `hangUp` is libp2p's graceful close and takes no timeout, so the wait was unbounded in both
8423
+ * directions. Evicting an injection foothold must not itself become the way to stall an agent.
8424
+ *
8425
+ * The cap is a LOGGED truncation, never a silent one: what is left behind still cannot inject
8426
+ * (the frame gate refuses it and now hangs it up on first contact), and the next promotion
8427
+ * sweeps again — but an operator reading this needs to know the sweep did not finish.
8428
+ */
8429
+ const EVICT_CAP = 32;
8430
+ const batch = toEvict.slice(0, EVICT_CAP);
8431
+ if (toEvict.length > batch.length) {
8432
+ this.#logger.warn("session.gate.evict.capped", {
8433
+ sessionId, trigger, attached: toEvict.length, evicting: batch.length,
8434
+ impact: "more peers were attached outside the gate than one promotion evicts; the rest keep their connections until a later sweep, and are refused and hung up by the frame gate if they speak",
8435
+ });
8436
+ }
8437
+ await Promise.allSettled(batch.map(async (peerId) => {
8438
+ try {
8439
+ await node.hangUp(peerId);
8440
+ this.#logger.warn("session.gate.evicted", {
8441
+ sessionId, trigger, evictedPeerId: peerId, allowedPeerId,
8442
+ impact: "a peer attached to this node before the session narrowed its gate was disconnected; libp2p does not re-run the gater against live connections, so it would otherwise have stayed attached when the content protocol activated",
8443
+ });
8444
+ }
8445
+ catch (err) {
8446
+ // Best-effort: the frame gate still refuses anything this peer sends, and now hangs it up.
8447
+ this.#logger.debug("session.gate.evict.failed", {
8448
+ sessionId, trigger, peerId, error: extractErrorMessage(err),
8449
+ });
8450
+ }
8451
+ }));
7001
8452
  }
7002
- async #handleContentStream(agentName, sessionId, stream, remotePeerId) {
7003
- // CLOSING THIS STREAM IS WHAT KEEPS THE SESSION ALIVE PAST ITS 33RD MESSAGE.
7004
- //
7005
- // Every content frame and every delivery ACK opens a fresh /cello/content/1.0.0 stream on the
7006
- // one muxed connection the session holds, and libp2p caps INBOUND streams per protocol per
7007
- // connection. It enforces that cap AFTER multistream-select has answered, so an over-cap stream
7008
- // negotiates fine and is reset an instant later, and the SENDER's next `stream.send(...)`
7009
- // throws "Cannot write to a stream that is closed" — an error that names the exit point and not
7010
- // one thing about the cause.
7011
- //
7012
- // A stream leaves the muxer's set only on its `close` event, and closing our write end triggers
7013
- // that only once the peer has closed its end too. So a handler that reads its frame and returns
7014
- // leaves the stream half-open for the life of the connection and the count only ever rises.
7015
- // Measured on a live daemon: 115 failures over 3.5 hours, with EXACTLY 32 successful streams
7016
- // before the first one on both affected sessions (M12B Entry 10).
7017
- //
7018
- // The decoder is built INSIDE the try so a malformed stream cannot throw past the close below.
7019
- let iter;
7020
- try {
7021
- iter = lp.decode(stream)[Symbol.asyncIterator]();
7022
- const result = await iter.next();
7023
- if (result.done || result.value === undefined)
7024
- return;
7025
- const bytes = result.value instanceof Uint8Array ? result.value
7026
- : Buffer.isBuffer(result.value) ? new Uint8Array(result.value)
7027
- : result.value.slice();
7028
- const frame = decode(bytes);
7029
- const correlationId = typeof frame["correlation_id"] === "string" ? frame["correlation_id"] : undefined;
7030
- // CELLO-M7-MSG-001 (AC-001/AC-002): a `persisted` delivery ACK arriving on the
7031
- // same /cello/content/1.0.0 protocol resolves the sender's awaiting-ACK timer.
7032
- // The protocol acts on `persisted` ONLY — any other level leaves the timer armed.
7033
- if (frame["type"] === "content_delivery_ack") {
7034
- const ackHash = frame["content_hash"];
8453
+ /**
8454
+ * DOD-M15-FRAME-1 a proven identity failure ends the session, and says so as an OBSERVATION.
8455
+ *
8456
+ * SESSION-ENDING, NOT PER-MESSAGE. One frame that fails to verify against the expected
8457
+ * counterparty is not a bad message to drop while hoping the next is better — it is evidence
8458
+ * about the CONNECTION. Dropping the frame and continuing leaves the same peer able to try again
8459
+ * with a frame that omits the proof entirely.
8460
+ *
8461
+ * THE WORDING IS NOT A VERDICT, and that is deliberate rather than squeamish. The identical
8462
+ * signal comes from a real impersonation attempt and from our own infrastructure mishandling a
8463
+ * fallback a relay bug, a bad deploy, an uncovered edge in the direct-connection failover. The
8464
+ * daemon cannot tell those apart from this signal, so it must not pretend to. This mirrors the
8465
+ * account-recovery pattern already in the codebase, which anchors a compromise window to logged
8466
+ * events and accepts that some evidence cannot separate misconduct from an innocent cause.
8467
+ *
8468
+ * IT MUST NEVER FEED A TRUST SIGNAL. An automatic reputation consequence driven by a signal this
8469
+ * ambiguous would let a hostile peer or a bad deploy of ours manufacture a mark against an
8470
+ * innocent counterparty. Recorded here because the absence of that wiring is a decision, not an
8471
+ * omission, and the next person to reach for it should find this comment first.
8472
+ *
8473
+ * The accusatory half stays local for the same reason. Freezing what THIS daemon trusts is always
8474
+ * safe unilaterally; asserting on the record that a counterparty misbehaved needs corroboration
8475
+ * from a party the accusing client does not control, which is `DOD-M15-CORROBORATE-1` (the relay
8476
+ * holds the sender's signed hash independently and never routes it through the receiver).
8477
+ */
8478
+ /**
8479
+ * DOD-M15-SEALWIRE-1 bullet 6 (part A) — THE SALT AGREEMENT, the I/O half.
8480
+ *
8481
+ * The decisions live in `session-salt-agreement.ts` as a pure function; everything here is the
8482
+ * three things that function cannot do: read and write the durable row, put a frame on the wire,
8483
+ * and stop a session.
8484
+ *
8485
+ * ─── Where a contribution may travel, and it is the one rule that cannot be fixed later ───────
8486
+ *
8487
+ * `/cello/content/1.0.0` ONLY. It rides circuit-relay-v2 carrying its own Noise session, so a
8488
+ * relay forwarding it sees ciphertext. It must never be added to `session_offer` /
8489
+ * `session_offer_accept` or anything a DIRECTORY brokers — and that is the trap, because the only
8490
+ * round trip at session open today runs on the directory's signaling stream, which makes it the
8491
+ * obvious place to put one. A session that shipped it there could not be repaired: the relay
8492
+ * would already hold the salt and every hash it protects.
8493
+ */
8494
+ #saltContributionFor(agentName, sessionId) {
8495
+ const key = this.#k(agentName, sessionId);
8496
+ let contribution = this.#saltContributions.get(key);
8497
+ if (!contribution) {
8498
+ contribution = generateSaltContribution();
8499
+ this.#saltContributions.set(key, contribution);
8500
+ }
8501
+ return contribution;
8502
+ }
8503
+ /**
8504
+ * Our half for a session, **without minting one** — review F1, and the distinction is the whole
8505
+ * safety of the repair.
8506
+ *
8507
+ * A session that already holds a salt must never mint a fresh half. If it did, the repair would
8508
+ * offer the peer a half the stored salt was NOT derived from, they would compute a different salt,
8509
+ * and both sides would believe they had agreed — silently, which is the one outcome worse than
8510
+ * refusing. So `null` from here means exactly "we hold a salt and the half behind it is gone",
8511
+ * and that is the only state the agreement is allowed to call unrepairable.
8512
+ */
8513
+ #ownSaltHalf(agentName, sessionId) {
8514
+ return this.#saltContributions.get(this.#k(agentName, sessionId)) ?? null;
8515
+ }
8516
+ /**
8517
+ * Test seam: force this session's own salt half, so the LOCAL-defect path is reachable.
8518
+ *
8519
+ * `generateSaltContribution` cannot produce a degenerate half, which is the point of it — so the
8520
+ * only way to exercise "our own random source is broken" end-to-end is to stand in for the broken
8521
+ * source. Named `…ForTest` like every other seam in this file, and it writes the same map
8522
+ * production writes rather than a parallel one, so a test cannot pass against state the daemon
8523
+ * never reads.
8524
+ */
8525
+ /**
8526
+ * Test seam: run the auto-acknowledge gate, exactly as the counterparty's SEAL ctrl leaf does.
8527
+ *
8528
+ * `DOD-M15-SEALWIRE-1` part B1, review F-B. The gate has ONE production call site — inside the
8529
+ * relay leaf handler, behind `leaf_kind === CTRL && !authored_by_us` — so reaching it from a test
8530
+ * needs a live relay client delivering a real ctrl leaf. The consequence was measured: my
8531
+ * "tampered never downgrades" test wrapped its decisive assertion in
8532
+ * `if (skipped.length > 0)`, which was ALWAYS FALSE, so the whole `content_tamper` vs
8533
+ * `content_verification_unavailable` branch had no coverage anywhere in the repo and two mutants
8534
+ * on it survived the full gate.
8535
+ *
8536
+ * It calls the REAL private method rather than reproducing its logic, so a test cannot pass
8537
+ * against a decision production does not make.
8538
+ */
8539
+ runAutoAcknowledgeGateForTest(agentName, sessionId, correlationId = "test") {
8540
+ this.#maybeAutoAcknowledgeSeal(agentName, sessionId, correlationId);
8541
+ }
8542
+ setSaltContributionForTest(agentName, sessionId, contribution) {
8543
+ this.#saltContributions.set(this.#k(agentName, sessionId), contribution);
8544
+ }
8545
+ /**
8546
+ * Test seam: drop this session's own half while leaving the stored salt in place — the state every
8547
+ * teardown produces, because `#evictSessionCaches` clears the map and the row survives.
8548
+ *
8549
+ * It clears the SAME map the eviction clears rather than a stand-in, so a test cannot pass against
8550
+ * a state the daemon never reaches. Reproducing it through a real teardown/revive would also drag
8551
+ * in node rebuild and relay reconnection, none of which this is about.
8552
+ */
8553
+ forgetSaltContributionForTest(agentName, sessionId) {
8554
+ this.#saltContributions.delete(this.#k(agentName, sessionId));
8555
+ }
8556
+ /**
8557
+ * The pair the agreement reasons over: our salt, and the half that goes with it.
8558
+ *
8559
+ * Minting is deliberate and conditional. With NO salt we are certain to need a half — to offer, or
8560
+ * to derive with — so minting here is what makes the exchange work at all. WITH a salt we must
8561
+ * never mint; see `#ownSaltHalf`.
8562
+ */
8563
+ #saltState(agentName, sessionId) {
8564
+ const ownSalt = this.#getSessionSalt(agentName, sessionId);
8565
+ return {
8566
+ ownSalt,
8567
+ ownContribution: ownSalt
8568
+ ? this.#ownSaltHalf(agentName, sessionId)
8569
+ : this.#saltContributionFor(agentName, sessionId),
8570
+ };
8571
+ }
8572
+ /**
8573
+ * THE ONE PLACE THAT DECIDES HOW A SESSION'S OUTBOUND CONTENT IS HASHED —
8574
+ * `DOD-M15-SEALWIRE-1` part B2b.
8575
+ *
8576
+ * Returns the hash AND the algorithm that produced it, together, because the two must not be
8577
+ * decided separately. `wire-content-hash.ts` exists for exactly this reason and says so in its own
8578
+ * header: the expression was written out at five call sites, the two added last got it wrong, and
8579
+ * the failure was invisible — *"the send succeeds, `parked: false`, the sender's log says the frame
8580
+ * left, and the receiver discards it at the authenticity check."* It took two real daemons.
8581
+ *
8582
+ * There are FOUR outbound sites (`session-content-handlers.ts`, two in `daemon.ts`,
8583
+ * `document-delivery-transport.ts`). Once salting is switchable, each of them independently
8584
+ * deciding whether to salt is that defect again with a worse failure mode — a message hashed one
8585
+ * way and LABELLED another is refused by every peer, including a correct one.
8586
+ *
8587
+ * ⚠️ ASYNC, AND THAT IS THE POINT — B2b-2 constraint 2, not an implementation detail.
8588
+ *
8589
+ * The agreement is in flight while the operator composes their first message. Hash without waiting
8590
+ * and it comes out unsalted, and that first unsalted hash closes adoption for the LIFE of the
8591
+ * session (Decision #8, unit 1). Every session would fall back permanently while every log line
8592
+ * about it stayed true — the feature present, wired, tested, and never once reached.
8593
+ *
8594
+ * The wait lives HERE rather than at the four call sites for the same reason `contentHashAlg` is a
8595
+ * required parameter rather than a defaulted one: a site that forgets it must fail to compile. A
8596
+ * caller that drops the `await` gets a `Promise` where bytes belong, which is a typecheck error;
8597
+ * a caller that forgot to call a separate `awaitSaltSettled()` would silently send unsalted.
8598
+ */
8599
+ async contentHashForSession(agentName, sessionId, content) {
8600
+ const { salt, reason } = await this.#saltForHashing(agentName, sessionId);
8601
+ if (salt !== null) {
8602
+ const alg = CONTENT_HASH_ALGS.HMAC_SALT_V1;
8603
+ return { hash: contentHashFor(content, { alg, salt }), alg };
8604
+ }
8605
+ /**
8606
+ * ⚠️ MARKED BEFORE THE HASH IS RETURNED, and this closes a window the row cannot see.
8607
+ *
8608
+ * `#saltAdoptionClosed` counts leaves, held content and in-flight sends. For the FIRST message of
8609
+ * a session none of the three exists at this moment — the leaf lands after `sendContent` returns,
8610
+ * which is a network round trip later. A peer contribution arriving in that gap would be adopted,
8611
+ * and the message already on the wire would become the single unsalted leaf in an otherwise
8612
+ * salted transcript: the exact split Decision #8 forbids, reached by the one route every count
8613
+ * reads as empty.
8614
+ *
8615
+ * In memory rather than in a column, and that is sufficient rather than convenient: if this
8616
+ * process survives, the flag holds; if it does not, the message it protects either reached a
8617
+ * durable form (leaf, held row, queued row — all of which the counts see) or never left, in which
8618
+ * case there is nothing to split. The one remaining case — hashed, sent, and no local record —
8619
+ * is covered from the other side, because the peer DID leaf it and closes its own adoption, and
8620
+ * the wire state added in unit 1 tells us so.
8621
+ */
8622
+ /**
8623
+ * ⚠️ NOT FOR A TORN-DOWN SESSION — review Finding 5. `#evictSessionCaches` settles the wait and
8624
+ * clears both of these sets; a `.add()` afterwards re-populates a map whose eviction has already
8625
+ * run, and the entries then outlive the session they describe. There is also nothing to protect:
8626
+ * a session that no longer exists cannot adopt a salt or split a transcript.
8627
+ */
8628
+ if (reason !== UNSALTED_REASONS.SESSION_TORN_DOWN) {
8629
+ /**
8630
+ * ⚠️ A COUNT, NOT A BIT — review pass 2, F1 (HIGH). It was a `Set`, and that made it ONE FLAG
8631
+ * PER SESSION for a fact that is per MESSAGE.
8632
+ *
8633
+ * The `sibling_send_in_flight` refusal path exists precisely when another connection is
8634
+ * mid-send with an unsalted hash it computed itself — and `sendContent` awaits a full relay
8635
+ * round trip before `#trackAwaitingAck` records anything. So: connection A hashes and sets the
8636
+ * flag; A enters that round trip, visible in no count; connection B hashes, sees A's claim,
8637
+ * refuses, and calls `abandonUnsaltedHash` — **deleting the flag A is still relying on.** The
8638
+ * frontier then reads entirely empty, a salt frame arriving in that window is adopted, and A's
8639
+ * message lands as leaf 0 hashed sha256 in a session that hashes everything after it under
8640
+ * HMAC.
8641
+ *
8642
+ * That is the split transcript this unit exists to prevent, through a window a relay round
8643
+ * trip wide. A count makes each in-flight hash hold its own claim.
8644
+ */
8645
+ const key = this.#k(agentName, sessionId);
8646
+ this.#hashedWithoutSalt.set(key, (this.#hashedWithoutSalt.get(key) ?? 0) + 1);
8647
+ }
8648
+ /**
8649
+ * NO `??` DEFAULT — review pass 2, F6. It read `reason ?? ADOPTION_CLOSED_LOCALLY`, which is the
8650
+ * shape the closed set was built to eliminate: a seventh return path forgetting its reason would
8651
+ * have been silently labelled *"you already hashed"* and inherited guidance about a frontier that
8652
+ * never moved. `#saltForHashing` returns a discriminated union now, so a null salt without a
8653
+ * reason does not compile.
8654
+ */
8655
+ this.#announceUnsaltedOnce(agentName, sessionId, reason);
8656
+ const alg = CONTENT_HASH_ALGS.SHA256;
8657
+ return { hash: contentHashFor(content, { alg, salt: null }), alg };
8658
+ }
8659
+ /**
8660
+ * The salt to hash this session's next message under, waiting for a pending agreement if one is
8661
+ * genuinely in flight — B2b-2 constraints 2 and 5.
8662
+ *
8663
+ * Three exits, and the order matters:
8664
+ *
8665
+ * 1. We already hold one. No wait, ever.
8666
+ * 2. Adoption is closed — this session has hashed or leafed something already, so a salt could
8667
+ * never be adopted now even if one arrived. Waiting would be waiting for a value we would
8668
+ * then have to refuse.
8669
+ * 3. Nothing is pending. **This is the park-only case (constraint 5)**: the announcement hangs
8670
+ * off `onPeerConnect`, an offline counterparty never connects, so no agreement was ever
8671
+ * started. Waiting the full bound there pauses every message to an offline peer and falls
8672
+ * back anyway — a stall bought for nothing.
8673
+ *
8674
+ * Only a session with an agreement actually in flight waits, and only until it settles or the
8675
+ * bound expires.
8676
+ */
8677
+ async #saltForHashing(agentName, sessionId) {
8678
+ const held = this.#getSessionSalt(agentName, sessionId);
8679
+ if (held !== null)
8680
+ return { salt: held };
8681
+ const key = this.#k(agentName, sessionId);
8682
+ if (this.#saltAdoptionClosed(agentName, sessionId).closed) {
8683
+ return { salt: null, reason: UNSALTED_REASONS.ADOPTION_CLOSED_LOCALLY };
8684
+ }
8685
+ const pending = this.#saltPending.get(key);
8686
+ if (pending === undefined) {
8687
+ // An agreement that already ENDED is not an agreement that never started. Only the second is
8688
+ // "your counterparty was not connected", and only an absent entry means it.
8689
+ const last = this.#saltLastOutcome.get(key);
8690
+ if (last !== undefined)
8691
+ return { salt: null, reason: this.#reasonForOutcome(last) };
8692
+ return { salt: null, reason: UNSALTED_REASONS.NO_AGREEMENT_STARTED };
8693
+ }
8694
+ const settled = await pending.settled;
8695
+ if (settled === "agreed") {
8696
+ const agreed = this.#getSessionSalt(agentName, sessionId);
8697
+ /**
8698
+ * A settled-`agreed` that reads back NULL is a READ failure, not a persist failure — pass 2,
8699
+ * F4. `persist_failed` has its own outcome now, so the only way to arrive here empty is
8700
+ * `#getSessionSalt` returning null after the salt was stored: a throwing read, or a
8701
+ * wrong-width row, with the cache evicted in the microtask between settle and resume. Rare —
8702
+ * and labelling it `our_persist_failed` sent the operator to look for a
8703
+ * `session.salt.persist.failed` line that will not be there.
8704
+ */
8705
+ return agreed !== null
8706
+ ? { salt: agreed }
8707
+ : { salt: null, reason: UNSALTED_REASONS.OUR_READ_FAILED };
8708
+ }
8709
+ if (settled === "announce_failed") {
8710
+ return { salt: null, reason: UNSALTED_REASONS.ANNOUNCE_FAILED };
8711
+ }
8712
+ if (settled === "persist_failed") {
8713
+ // Named separately from the timeout on purpose: the peer answered in time and OUR write
8714
+ // failed, so nothing about their build is involved and sending the operator there wastes them.
8715
+ return { salt: null, reason: UNSALTED_REASONS.OUR_PERSIST_FAILED };
8716
+ }
8717
+ if (settled === "closed") {
8718
+ /**
8719
+ * Two very different things reach `closed`, and only one of them is about the counterparty.
8720
+ *
8721
+ * `#handleSaltFrame`'s terminal branch — the peer told us it cannot adopt — is a settled
8722
+ * bilateral outcome and the session is fine. `#evictSessionCaches` — this session is being
8723
+ * torn down underneath us — is not: there is no session left to be unsalted, and a caller that
8724
+ * marks `#hashedWithoutSalt` for it re-populates a map whose eviction has already run
8725
+ * (review Finding 5). `#saltPending` is gone by the time we look, so the live node is what
8726
+ * distinguishes them.
8727
+ */
8728
+ return {
8729
+ salt: null,
8730
+ reason: this.#activeNodes.has(key)
8731
+ ? UNSALTED_REASONS.PEER_CLOSED_ADOPTION
8732
+ : UNSALTED_REASONS.SESSION_TORN_DOWN,
8733
+ };
8734
+ }
8735
+ if (settled === "timeout") {
8736
+ /**
8737
+ * A DECISION, NOT A RETRY. Logged once, here, because this is the moment the session became
8738
+ * permanently unsalted — and an operator reading a later `session.content.unsalted` needs to
8739
+ * be able to find out WHY this session has no salt when their others do.
8740
+ */
8741
+ this.#logger.warn("session.salt.agreement.timeout", {
8742
+ agentName, sessionId, waitedMs: pending.boundMs,
8743
+ impact: "the counterparty did not answer the salt agreement in time, so this session is unsalted FOR ITS LIFE — the message is being sent now rather than held any longer. Nothing is lost and nothing is degraded relative to any shipped release.",
8744
+ // Review F4: `session.salt.persist.failed` reaches this same timeout by a completely
8745
+ // different route — the peer answered promptly and OUR OWN write failed, so we returned
8746
+ // before announcing and nothing came back. Omitting it sent that operator to ask their
8747
+ // counterparty about a version mismatch that was never involved.
8748
+ guidance: "Most often the counterparty is on a build that predates the salt agreement, in which case this is expected and permanent for this session — a newer one will agree normally. If you know they are on the same version, look for session.salt.persist.failed on THIS side first (our own write failing produces this same timeout), then session.salt.announce.failed on either side.",
8749
+ });
8750
+ return { salt: null, reason: UNSALTED_REASONS.AGREEMENT_TIMED_OUT };
8751
+ }
8752
+ return { salt: null, reason: UNSALTED_REASONS.AGREEMENT_TIMED_OUT };
8753
+ }
8754
+ /**
8755
+ * Decision #15's fallback announcement — ONCE per session, never per message.
8756
+ *
8757
+ * A warning that fires on every message of every unsalted session is not a signal, it is a reason
8758
+ * to build a filter; and the operator who filters it also filters the one session where it meant
8759
+ * something. Stated once, with what the session actually loses.
8760
+ */
8761
+ #announceUnsaltedOnce(agentName, sessionId, reason) {
8762
+ const key = this.#k(agentName, sessionId);
8763
+ if (this.#unsaltedAnnounced.has(key))
8764
+ return;
8765
+ this.#unsaltedAnnounced.add(key);
8766
+ this.#logger.info("session.content.unsalted", {
8767
+ agentName, sessionId,
8768
+ // The REASON is the field that makes this line diagnosable, and it was the missing one. The
8769
+ // impact is the same for all six; what to do about it is not.
8770
+ reason,
8771
+ impact: "this session hashes its messages the way every build before this feature did. Nothing is degraded relative to any shipped release and no message is affected — it only means a relay holding the hashes could confirm a guess at a short message in THIS conversation, which a salt would have prevented.",
8772
+ guidance: UNSALTED_GUIDANCE[reason],
8773
+ });
8774
+ }
8775
+ /**
8776
+ * Register that a salt agreement is IN FLIGHT for this session, so the first send waits for it.
8777
+ *
8778
+ * Called where we announce our own state — not at session creation. That distinction is
8779
+ * constraint 5: an agreement exists to be waited for only once a frame has actually gone out.
8780
+ */
8781
+ #markSaltPending(agentName, sessionId, boundMs = SALT_AGREEMENT_WAIT_MS) {
8782
+ const key = this.#k(agentName, sessionId);
8783
+ if (this.#saltPending.has(key))
8784
+ return;
8785
+ let resolve = () => { };
8786
+ const settled = new Promise((r) => { resolve = r; });
8787
+ const timer = setTimeout(() => this.#settleSaltPending(agentName, sessionId, "timeout"), boundMs);
8788
+ // The daemon must be able to exit with this outstanding — a pending agreement is not a reason to
8789
+ // hold the process open.
8790
+ if (typeof timer.unref === "function")
8791
+ timer.unref();
8792
+ this.#saltPending.set(key, { settled, resolve, timer, boundMs });
8793
+ }
8794
+ /**
8795
+ * ONE mapping from a settled outcome to the operator-facing reason, so the send that WAITED and the
8796
+ * send that arrived afterwards cannot disagree about what happened.
8797
+ */
8798
+ #reasonForOutcome(outcome) {
8799
+ if (outcome === "announce_failed")
8800
+ return UNSALTED_REASONS.ANNOUNCE_FAILED;
8801
+ if (outcome === "persist_failed")
8802
+ return UNSALTED_REASONS.OUR_PERSIST_FAILED;
8803
+ if (outcome === "closed")
8804
+ return UNSALTED_REASONS.PEER_CLOSED_ADOPTION;
8805
+ return UNSALTED_REASONS.AGREEMENT_TIMED_OUT;
8806
+ }
8807
+ /** Resolve a pending agreement. Idempotent: the first outcome wins and the timer is cleared. */
8808
+ #settleSaltPending(agentName, sessionId, outcome) {
8809
+ const key = this.#k(agentName, sessionId);
8810
+ const pending = this.#saltPending.get(key);
8811
+ if (pending === undefined)
8812
+ return;
8813
+ this.#saltPending.delete(key);
8814
+ // `agreed` is not recorded: the salt itself is the record, and `#getSessionSalt` answers first.
8815
+ if (outcome !== "agreed")
8816
+ this.#saltLastOutcome.set(key, outcome);
8817
+ clearTimeout(pending.timer);
8818
+ pending.resolve(outcome);
8819
+ }
8820
+ /**
8821
+ * THIS SESSION'S UNSALTED HASH NEVER BECAME A MESSAGE — release the permanent closure it caused.
8822
+ *
8823
+ * ⚠️ REVIEW FINDING 3, and it is the opposite of the direction the flag was written to defend.
8824
+ * `#hashedWithoutSalt` closes adoption at hash time, because for a session's first message the
8825
+ * leaf is a network round trip away and every frontier count reads zero in between. Correct — but
8826
+ * `cello_send` has three paths that compute the hash and then produce NOTHING: a sibling send
8827
+ * holding the in-flight claim, the frontier moving under the send, and a non-durable send failure
8828
+ * whose bytes go to a queue with no production consumer.
8829
+ *
8830
+ * In all three the session was permanently unsalted for a message that exists nowhere: no leaf, no
8831
+ * wire, no copy at the peer. And B2b-2 made two of them MORE likely on a first message, because
8832
+ * the five-second wait widens the very window the frontier re-check is watching.
8833
+ *
8834
+ * Only safe because it is called on paths that provably sent nothing. It deliberately does NOT
8835
+ * clear `#unsaltedAnnounced`: the announcement was true when it fired and re-announcing on the
8836
+ * retry would be the per-message flood Decision #15 forbids.
8837
+ *
8838
+ * ─── THREE OTHER SITES HASH AND MAY SEND NOTHING, AND ARE EXEMPT ON PURPOSE (pass 2, F8) ──────
8839
+ *
8840
+ * `daemon.ts`'s one-shot rejection and away reply, and `document-delivery-transport.ts`'s frame
8841
+ * send, can all fail after hashing. None of them needs to abandon, and the reason is the same in
8842
+ * each: every one is a REPLY. The inbound message that triggered it has already been leafed on
8843
+ * this side, so `#saltAdoptionClosed` is already closed by the leaf count and would stay closed
8844
+ * whatever this flag said. Calling abandon there would be a no-op that looks like a guarantee.
8845
+ *
8846
+ * Written down rather than left to be re-derived: the next reader's first question is why the
8847
+ * list is three and not six.
8848
+ */
8849
+ abandonUnsaltedHash(agentName, sessionId) {
8850
+ const key = this.#k(agentName, sessionId);
8851
+ const held = this.#hashedWithoutSalt.get(key) ?? 0;
8852
+ if (held === 0)
8853
+ return;
8854
+ // DECREMENT, never delete — F1. Deleting released a sibling's claim along with this one.
8855
+ if (held > 1) {
8856
+ this.#hashedWithoutSalt.set(key, held - 1);
8857
+ return;
8858
+ }
8859
+ this.#hashedWithoutSalt.delete(key);
8860
+ /**
8861
+ * INFO, not DEBUG — review pass 2, F3. `session.content.unsalted` has already told this operator
8862
+ * at INFO that the session is unsalted *"permanently… start a new session if you want the
8863
+ * protection."* That statement is now false, and a retraction logged below the level of the
8864
+ * claim it retracts is not a retraction. The announcement itself is deliberately NOT re-armed —
8865
+ * re-announcing on the retry is the per-message flood Decision #15 forbids.
8866
+ */
8867
+ this.#logger.info("session.content.unsalted.retracted", {
8868
+ agentName, sessionId,
8869
+ impact: "a hash computed unsalted never became a message — no leaf, nothing on the wire, no copy at the counterparty — so this session CAN still adopt a salt. An earlier session.content.unsalted line said the session was permanently unsalted; that no longer applies.",
8870
+ });
8871
+ }
8872
+ /**
8873
+ * TEST SEAM — put a session into the state a real one is in between announcing and being answered.
8874
+ *
8875
+ * Reaching that state for real needs a live counterparty connection, which the daemon-level
8876
+ * fixtures do not have; without a seam the wait could only be tested by not testing it. It calls
8877
+ * the same private registration the announce path calls, so it cannot drift from it.
8878
+ */
8879
+ markSaltAgreementPendingForTest(agentName, sessionId, boundMs) {
8880
+ this.#markSaltPending(agentName, sessionId, boundMs);
8881
+ }
8882
+ /**
8883
+ * PUBLIC read of a session's agreed salt — `DOD-M15-SEALWIRE-1` part B2a.
8884
+ *
8885
+ * `content-park.ts` runs a SECOND, independent content-hash verifier (the park signature does not
8886
+ * cover the envelope content, so it checks before `ingestReceivedContent` is ever reached), and it
8887
+ * hardcoded `sha256`. It needs the salt to verify a v3 envelope, and it is outside this class.
8888
+ *
8889
+ * Read-only and cache-backed, so exposing it adds no way to CHANGE the salt from outside — the
8890
+ * only writer remains `#persistSessionSalt`, behind the one-salt-per-session predicate.
8891
+ */
8892
+ getSessionContentSalt(agentName, sessionId) {
8893
+ return this.#getSessionSalt(agentName, sessionId);
8894
+ }
8895
+ /**
8896
+ * This session's agreed salt, or null. Reads the durable row through a cache, because Decision #8
8897
+ * persists it for exactly one reason: *"a restart silently splits the transcript"* if the lookup
8898
+ * misses and a fresh salt is minted.
8899
+ *
8900
+ * A read failure returns null WITH a log rather than throwing — except the bare `!this.#db` guard,
8901
+ * which is this file's convention at 60+ sites and only reachable during shutdown. Null means "we
8902
+ * hold no salt", which drives the agreement to offer a contribution — and against a peer that does
8903
+ * hold one that is a named, loud `salt_state_divergent` refusal. So the degraded path ends in a
8904
+ * diagnosis, not in a session that quietly hashes under the wrong value.
8905
+ *
8906
+ * ⚠️ THIS PARAGRAPH SPENT A UNIT STRANDED 180 LINES AWAY, directly above `contentHashForSession`
8907
+ * and followed by that method's own block — so a reader hovering the hash decision got prose about
8908
+ * salt read failures. Harmless and exactly the kind of drift that makes a comment stop being read.
8909
+ */
8910
+ #getSessionSalt(agentName, sessionId) {
8911
+ const key = this.#k(agentName, sessionId);
8912
+ const cached = this.#sessionSalts.get(key);
8913
+ if (cached)
8914
+ return cached;
8915
+ if (!this.#db)
8916
+ return null;
8917
+ try {
8918
+ const row = this.#db
8919
+ .prepare("SELECT content_salt FROM sessions WHERE agent_id = ? AND session_id = ?")
8920
+ .get(this.#requireAgentId(agentName), sessionId);
8921
+ const stored = row?.content_salt;
8922
+ if (!stored || stored.length === 0)
8923
+ return null;
8924
+ /**
8925
+ * A WRONG-WIDTH ROW IS NOT A SALT — review F8.
8926
+ *
8927
+ * Any non-empty blob used to be accepted, so a truncated row became "our salt", the digests
8928
+ * then differed, and the operator was told *"one of you is running an older build — compare
8929
+ * versions with them"*: sent to their counterparty over corruption on their own disk. Refusing
8930
+ * it here makes this side hold NO salt, which re-offers a contribution and repairs.
8931
+ */
8932
+ if (stored.length !== SESSION_SALT_BYTES) {
8933
+ this.#logger.error("session.salt.read.failed", {
8934
+ agentName, sessionId, storedBytes: stored.length, expected: SESSION_SALT_BYTES,
8935
+ reason: "wrong_width",
8936
+ impact: "the stored salt is the wrong size, so it is not used; this session is treated as holding no salt and will re-agree one with the counterparty rather than comparing a corrupt value and blaming their build",
8937
+ });
8938
+ return null;
8939
+ }
8940
+ const salt = new Uint8Array(stored);
8941
+ this.#sessionSalts.set(key, salt);
8942
+ return salt;
8943
+ }
8944
+ catch (err) {
8945
+ this.#logger.error("session.salt.read.failed", {
8946
+ agentName, sessionId, error: extractErrorMessage(err),
8947
+ impact: "this session is treated as holding no salt, so it will offer a fresh contribution; against a counterparty that still holds theirs the agreement refuses by name rather than hashing under a value only one side has",
8948
+ });
8949
+ return null;
8950
+ }
8951
+ }
8952
+ /**
8953
+ * Is this session past the point where a salt can be adopted? — Decision #8, part B2b-2.
8954
+ *
8955
+ * ⚠️ THE PREDICATE IS "HAS ANYTHING BEEN HASHED", NOT "IS THERE A LEAF" — review F5. A leaf is
8956
+ * APPENDED after `await sendContent(...)` returns, so a message can be hashed, put on the wire, and
8957
+ * still be invisible to `tree.size()`. Adopting inside that window makes leaf 0 unsalted and the
8958
+ * rest salted — the exact split this exists to prevent, with the guard green.
8959
+ *
8960
+ * ⚠️ HELD CONTENT COUNTS, AND MUST BE HYDRATED FIRST — review F6. `#ensureHeldRestored` is lazy and
8961
+ * is not called at session-node creation, so a revived session whose first inbound frame is the
8962
+ * salt frame reads a frontier that excludes durable `held_content` rows — rows already hashed
8963
+ * unsalted, which `#releaseHeld` will append moments later. Every other frontier reader in this
8964
+ * file hydrates first, for this reason. `release: false`, because a salt frame must never deliver
8965
+ * messages as a side effect.
8966
+ *
8967
+ * ⚠️ "CANNOT TELL" IS CLOSED, NOT OPEN. `#requireAgentId` throws for a retired agent, and inferring
8968
+ * "zero leaves" from a failure to count them is how a guard becomes a formality. The cost of
8969
+ * refusing is an unsalted session; the cost of permitting is a transcript neither rule can verify.
8970
+ */
8971
+ #saltAdoptionClosed(agentName, sessionId) {
8972
+ try {
8973
+ this.#ensureHeldRestored(agentName, sessionId, { release: false });
8974
+ }
8975
+ catch { /* hydration is best-effort; the counts below still refuse on their own failure */ }
8976
+ try {
8977
+ const key = this.#k(agentName, sessionId);
8978
+ const leaves = this.getSessionTree(agentName, sessionId).size();
8979
+ const held = this.#heldContent.get(key)?.size ?? 0;
8980
+ const inFlight = this.#awaitingAck.get(key)?.size ?? 0;
8981
+ /**
8982
+ * ⚠️ THE HASH ITSELF COUNTS — B2b-2, and none of the three counts above can see it.
8983
+ *
8984
+ * Decision #8 closes adoption when content is HASHED. For a session's first message the leaf
8985
+ * lands after `sendContent` returns, a network round trip later; there is no held row and no
8986
+ * in-flight entry yet either. So between the hash and the leaf every count reads zero, and a
8987
+ * peer contribution arriving in that window would be adopted — leaving the message already on
8988
+ * the wire as the one unsalted leaf in a salted transcript.
8989
+ */
8990
+ const hashed = this.#hashedWithoutSalt.get(key) ?? 0;
8991
+ const total = leaves + held + inFlight + hashed;
8992
+ return {
8993
+ closed: total > 0,
8994
+ // The label crosses the WIRE, so it carries no counts and no error text — only which of the
8995
+ // two refusals this is. The counts stay in `why`, which stays local.
8996
+ label: SALT_ADOPTION_LABELS.ALREADY_HASHING,
8997
+ leafCount: total,
8998
+ why: `leaves=${leaves} held=${held} awaiting_ack=${inFlight} hashed=${hashed}`,
8999
+ };
9000
+ }
9001
+ catch (err) {
9002
+ return {
9003
+ closed: true,
9004
+ label: SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE,
9005
+ leafCount: -1,
9006
+ why: `frontier_unreadable: ${extractErrorMessage(err)}`,
9007
+ };
9008
+ }
9009
+ }
9010
+ /**
9011
+ * Persist the agreed salt, and DO NOT ANNOUNCE ONE WE FAILED TO STORE.
9012
+ *
9013
+ * The caller sends its fingerprint only if this returns true. A salt held in memory and not on
9014
+ * disk would confirm agreement to the counterparty and then be gone at the next restart — turning
9015
+ * a loud `salt_state_divergent` refusal, which is the whole point of Decision #10, into the silent
9016
+ * split it exists to prevent, one restart later.
9017
+ *
9018
+ * ⚠️ SECOND ORPHAN OF THE SAME KIND. This paragraph was stranded above `#saltAdoptionClosed` and
9019
+ * followed by that method's own block, exactly like the `#getSessionSalt` one re-homed in the
9020
+ * previous pass — which walked straight past this one sixty lines below it. Two in one file is not
9021
+ * coincidence: inserting a method between a doc block and its subject leaves no error, no lint,
9022
+ * and no test, so the drift is invisible until someone reads for it.
9023
+ */
9024
+ #persistSessionSalt(agentName, sessionId, salt) {
9025
+ if (!this.#db) {
9026
+ // NOT a silent return — review F7. The other two persist failures each emit an event, so a
9027
+ // derive that could not store because the handle is closed was the ONE salt path producing no
9028
+ // record at all. Only reachable during shutdown, which is exactly when a lone unexplained
9029
+ // gap in the log is hardest to account for later.
9030
+ this.#logger.error("session.salt.persist.failed", {
9031
+ agentName, sessionId, reason: "db_closed",
9032
+ impact: "the salt was NOT stored and is not announced; the agreement stays open and re-runs on the next connect",
9033
+ });
9034
+ return false;
9035
+ }
9036
+ try {
9037
+ /**
9038
+ * ORDER MATTERS HERE, and getting it wrong cost three findings — review F3, F4, F7.
9039
+ *
9040
+ * The adoption guard used to run FIRST, above `!this.#db` and outside this `try`. That:
9041
+ * - short-circuited the `salt_already_stored` discrimination below, so a session that DOES
9042
+ * hold a valid salt was told it "stays unsalted FOR THE LIFE of the session" after a
9043
+ * transient read failure — a refusal asserting something false about the row (F4);
9044
+ * - put `getSessionTree`'s `#requireAgentId` throw outside the `try`, where it surfaced as
9045
+ * *"the stream read failed"* instead of a named salt-persist failure (F7).
9046
+ *
9047
+ * So the row's own state is established first, and only a session with no salt at all reaches
9048
+ * the adoption question.
9049
+ */
9050
+ const existingRow = this.#db
9051
+ .prepare("SELECT length(content_salt) AS n FROM sessions WHERE agent_id = ? AND session_id = ?")
9052
+ .get(this.#requireAgentId(agentName), sessionId);
9053
+ if (existingRow?.n === SESSION_SALT_BYTES) {
9054
+ this.#logger.error("session.salt.persist.failed", {
9055
+ agentName, sessionId, reason: "salt_already_stored",
9056
+ impact: "this session already has a salt and it was NOT replaced — Decision #8 is one salt per session. Reaching here means a read failure made this side believe it had none; the stored salt is intact, nothing was announced, and the agreement re-runs against it on the next connect.",
9057
+ });
9058
+ return false;
9059
+ }
9060
+ const adoption = this.#saltAdoptionClosed(agentName, sessionId);
9061
+ if (adoption.closed) {
9062
+ this.#logger.warn("session.salt.adoption.refused", {
9063
+ agentName, sessionId, reason: "already_hashing", leafCount: adoption.leafCount, frontier: adoption.why,
9064
+ impact: "this session has already hashed content under the unsalted rule, so the salt was NOT adopted — it stays unsalted FOR THE LIFE of the session. Adopting now would hash the rest of the conversation differently and leave a transcript that neither rule can verify end to end.",
9065
+ guidance: "Nothing is broken and no message was lost: an unsalted session is exactly as verifiable as every session before this feature existed. It only means a relay holding the hashes could confirm a guess at a short message in THIS conversation. If you want the protection, start a new session — the agreement runs at open, before anything is hashed.",
9066
+ });
9067
+ return false;
9068
+ }
9069
+ /**
9070
+ * THE ROW COUNT IS THE CHECK, and without it this method reported success for a write that
9071
+ * stored nothing.
9072
+ *
9073
+ * An `UPDATE` that matches no row does not throw — it returns `changes: 0`. So a session whose
9074
+ * row is missing (retired agent, a row that failed to write at creation, an id that does not
9075
+ * line up) took the success branch, cached the salt in memory, and announced our fingerprint
9076
+ * to the counterparty. Agreement confirmed, nothing on disk, and the failure surfaces at the
9077
+ * next restart as the divergence this whole design exists to make loud — except one restart
9078
+ * late and with both sides believing they had agreed.
9079
+ *
9080
+ * Found by a mutant that removed the caller's `if (!persisted) return`: the suite stayed green,
9081
+ * because nothing could produce a false from here.
9082
+ */
9083
+ /**
9084
+ * ONE SALT PER SESSION, ENFORCED AT THE WRITE — review F18.
9085
+ *
9086
+ * This `UPDATE` was unconditional, so it could replace an already-stored VALID salt. The path
9087
+ * is real: `#getSessionSalt` returns null on a transient read failure, which sends this side
9088
+ * down the derive path, which then overwrote the perfectly good salt on disk. The read error
9089
+ * was logged; the destruction of the durable value was not — and the read log actively said
9090
+ * the wrong thing, promising only that we would "offer a fresh contribution".
9091
+ *
9092
+ * The predicate has to allow ONE overwrite: a wrong-width blob is refused by `#getSessionSalt`
9093
+ * (F8) precisely so a corrupt row can be replaced rather than stranding the session forever.
9094
+ * So: write when there is nothing there, or when what is there is not a salt.
9095
+ */
9096
+ const written = this.#db
9097
+ .prepare("UPDATE sessions SET content_salt = ? WHERE agent_id = ? AND session_id = ? " +
9098
+ "AND (content_salt IS NULL OR length(content_salt) <> ?)")
9099
+ .run(Buffer.from(salt), this.#requireAgentId(agentName), sessionId, SESSION_SALT_BYTES);
9100
+ if (Number(written.changes) !== 1) {
9101
+ // WHICH of the two it was. "No row" is a broken session record; "a salt is already there" is
9102
+ // this guard doing its job, and telling an operator the row is missing when it is not would
9103
+ // send them to look at the wrong thing.
9104
+ // The `salt_already_stored` case is decided above now, before the adoption question, so
9105
+ // reaching here with a valid salt in the row is not possible. Re-read anyway rather than
9106
+ // assume: a wrong-width blob also fails the predicate and must not be reported as a missing
9107
+ // row, which would send the operator to look at session state for a corrupt value.
9108
+ const existing = this.#db
9109
+ .prepare("SELECT length(content_salt) AS n FROM sessions WHERE agent_id = ? AND session_id = ?")
9110
+ .get(this.#requireAgentId(agentName), sessionId);
9111
+ const alreadyStored = existing?.n === SESSION_SALT_BYTES;
9112
+ this.#logger.error("session.salt.persist.failed", {
9113
+ agentName, sessionId, changes: Number(written.changes),
9114
+ reason: alreadyStored ? "salt_already_stored" : "no_session_row",
9115
+ impact: alreadyStored
9116
+ ? "this session already has a salt and it was NOT replaced — Decision #8 is one salt per session. Reaching here means a read failure made this side believe it had none; the stored salt is intact, nothing was announced, and the agreement re-runs against it on the next connect."
9117
+ : "the salt was NOT stored — no session row matched — so it is not announced either; the agreement stays open rather than being confirmed against a value that exists only in memory",
9118
+ });
9119
+ return false;
9120
+ }
9121
+ this.#sessionSalts.set(this.#k(agentName, sessionId), salt);
9122
+ return true;
9123
+ }
9124
+ catch (err) {
9125
+ this.#logger.error("session.salt.persist.failed", {
9126
+ agentName, sessionId, error: extractErrorMessage(err),
9127
+ impact: "the salt was NOT stored, so it is not announced to the counterparty either; the agreement stays open rather than being confirmed against a value that would vanish at the next restart",
9128
+ });
9129
+ return false;
9130
+ }
9131
+ }
9132
+ /**
9133
+ * Announce our state to the counterparty: a contribution if we hold no salt, a fingerprint if we
9134
+ * do. Called on every counterparty connect — first connection, reconnect and revival alike — and
9135
+ * on receiving a peer contribution.
9136
+ *
9137
+ * ⚠️ THIS PARAGRAPH USED TO SAY *"nothing hashes with the salt yet, so a frame that never lands
9138
+ * costs nothing today"*, and ended by instructing the next unit not to carry it forward unchanged.
9139
+ * **This is that unit, and pass 2 caught me leaving it** — I corrected the same claim inside
9140
+ * `session.salt.announce.failed`'s `impact` in the previous pass and walked past the method header
9141
+ * saying the opposite twenty lines above it.
9142
+ *
9143
+ * As it stands now: the SEND is still best-effort, but a frame that never lands is no longer free.
9144
+ * A first send waiting on this agreement falls back to an unsalted hash, and that is permanent for
9145
+ * the session. The catch therefore settles the pending under `announce_failed` rather than leaving
9146
+ * the send to time out — so the cost is one dial attempt and an accurate reason, instead of five
9147
+ * seconds and a diagnosis blaming the counterparty for a frame we never sent.
9148
+ */
9149
+ async #sendSaltFrame(agentName, sessionId, correlationId, override) {
9150
+ const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
9151
+ if (!entry)
9152
+ return;
9153
+ // `override` is the repair and the mismatch notice: a frame the AGREEMENT chose, which is not
9154
+ // the one our current state would produce. A side that holds a salt normally announces a
9155
+ // fingerprint — the repair has it send its CONTRIBUTION instead, which is the whole mechanism.
9156
+ /**
9157
+ * `#saltState`, NOT a direct call to the minting accessor — review F15, and this one line was
9158
+ * the difference between the safety argument written for the repair and the code that ran.
9159
+ *
9160
+ * It read `ownContribution: this.#saltContributionFor(...)`, which mints unconditionally. Both
9161
+ * arguments are evaluated, so a session that already held a salt got a fresh half minted into
9162
+ * `#saltContributions` even though `ownSaltFrame` discards it on that branch — and
9163
+ * `#evictSessionCaches` drops the half on every teardown while the salt stays on disk, so ANY
9164
+ * revived session hit it, before any inbound frame could be handled.
9165
+ *
9166
+ * The consequence was not cosmetic. `#ownSaltHalf` never returned null, so `STATE_DIVERGENT`
9167
+ * was dead code, and a revived session would REPAIR its counterparty onto a half its salt was
9168
+ * never built from — then both sides froze on `salt_fingerprint_mismatch`, whose guidance sends
9169
+ * the operator to compare build versions with a counterparty that did nothing wrong.
9170
+ */
9171
+ const state = this.#saltState(agentName, sessionId);
9172
+ const frame = override ?? ownSaltFrame(state);
9173
+ if (!frame) {
9174
+ // Neither a salt nor a half. `#saltState` does not produce this, so it is a defect rather
9175
+ // than a state to paper over — and inventing a contribution here is exactly what F15 was.
9176
+ this.#logger.error("session.salt.announce.failed", {
9177
+ agentName, sessionId, correlationId, reason: "no_salt_and_no_contribution",
9178
+ impact: "this side has neither an agreed salt nor a half to offer, so it announced nothing; no half was invented, because one minted now would not be the half any stored salt was built from",
9179
+ });
9180
+ return;
9181
+ }
9182
+ /**
9183
+ * ⚠️ REGISTERED BEFORE THE DIAL, NOT AFTER IT — review Finding 2, and this ordering is the whole
9184
+ * of constraint 2 for the case that actually happens.
9185
+ *
9186
+ * This sat after `await newStream(...)`, which negotiates the protocol with the peer and takes
9187
+ * tens to hundreds of milliseconds. A `cello_send` landing inside that interval found nothing
9188
+ * pending, took the park-only exit, hashed unsalted, and **closed adoption for the life of the
9189
+ * session** — a session with a live counterparty and an agreement about to complete. The feature
9190
+ * off forever, and the log telling the operator their counterparty was on an old build.
9191
+ *
9192
+ * That is precisely the failure constraint 2 exists to prevent, arriving by the one route the
9193
+ * implementation did not cover: the gap between deciding to announce and the frame leaving.
9194
+ *
9195
+ * Park-only is untouched — it never reaches this line, because `#sendSaltFrame` returns at the
9196
+ * `!entry` guard above when there is no active node. And a failed announce settles the waiter in
9197
+ * the catch below rather than leaving a send to sit out the full bound for a frame that never
9198
+ * left.
9199
+ */
9200
+ if (!frame.adoptionClosed)
9201
+ this.#markSaltPending(agentName, sessionId);
9202
+ // Held outside the try so the catch can retire a stream that was opened and then failed to
9203
+ // write — the same leak, and the same fix, as `#sendDeliveryAck`.
9204
+ let saltStream;
9205
+ try {
9206
+ const stream = await entry.node.newStream(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
9207
+ saltStream = stream;
9208
+ stream.send(lp.encode.single(encodeCbor({
9209
+ type: "session_salt_agreement",
9210
+ session_id: sessionId,
9211
+ ...(frame.contribution ? { contribution: frame.contribution } : {}),
9212
+ ...(frame.fingerprint ? { fingerprint: frame.fingerprint } : {}),
9213
+ ...(frame.adoptionClosed ? { adoption_closed: frame.adoptionClosed } : {}),
9214
+ })));
9215
+ await stream.close();
9216
+ this.#logger.debug("session.salt.announced", {
9217
+ agentName, sessionId, correlationId,
9218
+ /**
9219
+ * THREE STATES, not two. This read `fingerprint ? holds_salt : offering_contribution`, which
9220
+ * was exhaustive until the adoption refusal put a third frame on the wire — and a refusal
9221
+ * carries no fingerprint, so it logged as `offering_contribution`: the log claiming we asked
9222
+ * for a salt at the exact moment we told the peer we were declining one. An operator reading
9223
+ * the pair would see an offer that was never answered and go looking for a dropped frame.
9224
+ */
9225
+ state: frame.adoptionClosed ? "adoption_closed" : frame.fingerprint ? "holds_salt" : "offering_contribution",
9226
+ });
9227
+ }
9228
+ catch (err) {
9229
+ /**
9230
+ * ⚠️ RELEASE THE WAITER — the frame never left, so there is nothing to wait for.
9231
+ *
9232
+ * Registering before the dial (above) means a failed announce would otherwise leave a first
9233
+ * send holding for the full five seconds against a frame that was never sent. Settling here
9234
+ * makes the failure cost one dial attempt instead of the whole bound, and B2b-2 turned this
9235
+ * from theory into something a user feels: it is the pause before their first message.
9236
+ */
9237
+ this.#settleSaltPending(agentName, sessionId, "announce_failed");
9238
+ this.#logger.warn("session.salt.announce.failed", {
9239
+ agentName, sessionId, correlationId, error: extractErrorMessage(err),
9240
+ // Review F1: this used to end "so no message is affected", which was FALSE in the situation
9241
+ // it fires in — a one-sided announce is exactly what put the two sides out of step, and the
9242
+ // peer used to freeze the session over it. It converges now, so the claim is true again;
9243
+ // it is stated with its reason rather than as a bare reassurance.
9244
+ //
9245
+ // ⚠️ B2b-2 CHANGED THE LAST CLAUSE'S TRUTH. "Nothing hashes with the salt yet" was true for
9246
+ // part A and is now false: a first send waiting on this agreement falls back to sha256 when
9247
+ // it fails, which permanently unsalts the session. Not a message LOST — a protection not
9248
+ // taken, and the sentence has to stop promising otherwise.
9249
+ impact: "the counterparty was not told our salt state on this attempt. We re-announce on every reconnect, and a side that is out of step re-offers its half rather than refusing, so the agreement converges from here. No message is affected, but a first send waiting on this agreement now falls back to an unsalted hash, which is permanent for this session.",
9250
+ });
9251
+ if (saltStream !== undefined) {
9252
+ try {
9253
+ saltStream.abort(err instanceof Error ? err : new Error(String(err)));
9254
+ }
9255
+ catch { /* already gone */ }
9256
+ }
9257
+ }
9258
+ }
9259
+ /** Apply one inbound salt-agreement frame. The verdict is the pure function's; this executes it. */
9260
+ async #handleSaltFrame(agentName, sessionId, frame, correlationId) {
9261
+ const key = this.#k(agentName, sessionId);
9262
+ const peerHalfHex = frame.contribution ? Buffer.from(frame.contribution).toString("hex") : null;
9263
+ const adoption = this.#saltAdoptionClosed(agentName, sessionId);
9264
+ const action = onPeerSaltFrame({
9265
+ ...this.#saltState(agentName, sessionId),
9266
+ // Review F2: the frontier is what decides whether THIS side can still adopt, and only the
9267
+ // caller can count it. Without this the state machine derives, the persist refuses, and the
9268
+ // peer never learns — which is how the two sides end up on opposite verdicts.
9269
+ ownAdoption: adoption.closed
9270
+ ? { closed: true, label: adoption.label, why: adoption.why }
9271
+ : { closed: false },
9272
+ // Keyed on the peer's BYTES, not on a repair counter: a genuinely NEW half from the peer must
9273
+ // still get our contribution back, and only an identical re-offer is the loop (review F14).
9274
+ alreadyRepairedAgainstPeerHalf: peerHalfHex !== null && this.#saltRepairedAgainst.get(key) === peerHalfHex,
9275
+ frame,
9276
+ });
9277
+ if (action.action === "confirmed") {
9278
+ this.#logger.info("session.salt.agreed", {
9279
+ agentName, sessionId, correlationId, via: "fingerprint_match",
9280
+ });
9281
+ // B2b-2: release a first send that is waiting on this agreement. Both `confirmed` and
9282
+ // `derive_and_announce` end with a salt this side can hash under, so both settle the wait.
9283
+ this.#settleSaltPending(agentName, sessionId, "agreed");
9284
+ return;
9285
+ }
9286
+ if (action.action === "derive_and_announce") {
9287
+ /**
9288
+ * ⚠️ I DEFENDED THE OPPOSITE OF THIS TWICE, AND BOTH DEFENCES WERE WRONG. The code now does
9289
+ * what the "surviving mutant" did; recording that rather than quietly switching, because the
9290
+ * reasoning is the useful part.
9291
+ *
9292
+ * A failed persist used to fall through with no settle, so a waiting first send sat out the
9293
+ * FULL FIVE SECONDS and was then told, by the timeout path, to go and check its counterparty's
9294
+ * build version — for a fault that was this machine's own disk.
9295
+ *
9296
+ * Defence #1 said releasing the waiter "would hand it a null it would hash unsalted under."
9297
+ * True, and not a consequence: that is exactly what the timeout does. Defence #2 said the
9298
+ * remaining bound gave a repair a chance to land — and the review showed that essentially
9299
+ * cannot fire. This branch returns BEFORE the announce, so nothing goes out and nothing comes
9300
+ * back; all five of `#sendSaltFrame`'s callers are triggered by a peer connect or an inbound
9301
+ * frame. Only a counterparty reconnect inside those seconds could do it.
9302
+ *
9303
+ * So the real trade was a rare reconnect-within-five-seconds repair against five seconds of
9304
+ * visible latency on the operator's first message AND a diagnosis pointing at the wrong
9305
+ * machine. The repair loses. Settle immediately under its own name, so `#saltForHashing` can
9306
+ * say *our own write failed* instead of *they did not answer*.
9307
+ */
9308
+ if (!this.#persistSessionSalt(agentName, sessionId, action.salt)) {
9309
+ this.#settleSaltPending(agentName, sessionId, "persist_failed");
9310
+ return;
9311
+ }
9312
+ this.#logger.info("session.salt.agreed", {
9313
+ agentName, sessionId, correlationId, via: "derived",
9314
+ });
9315
+ this.#settleSaltPending(agentName, sessionId, "agreed");
9316
+ // `void`, not `await` — review F10. This runs inside the INBOUND content-stream handler, so
9317
+ // awaiting an outbound `newStream` here lets a stalled dial hold up the stream we are reading.
9318
+ // The connect-side call is `void`-ed for the same reason and this is now consistent with it.
9319
+ void this.#sendSaltFrame(agentName, sessionId, correlationId);
9320
+ return;
9321
+ }
9322
+ if (action.action === "adoption_closed") {
9323
+ // B2b-2: terminal means there is nothing left to wait for. A send still holding on the bound
9324
+ // would otherwise sit out the full five seconds for an answer that has already arrived and
9325
+ // said no — the slowest possible way to reach a decision both sides already agree on.
9326
+ this.#settleSaltPending(agentName, sessionId, "closed");
9327
+ /**
9328
+ * Terminal, and NOT a freeze — review F1/F2. Both sides stay unsalted, which is exactly as
9329
+ * verifiable as every session shipped before the salt existed; the thing that was broken was
9330
+ * them disagreeing about it silently.
9331
+ *
9332
+ * WHICH SIDE DECLINED decides the level, and it is not decoration.
9333
+ *
9334
+ * If WE closed, an operator has lost a protection they could otherwise have had, and there is
9335
+ * something they can do about it — that is a WARN under `session.salt.adoption.refused`, which
9336
+ * keeps meaning what it has always meant.
9337
+ *
9338
+ * If we are merely LEARNING the peer closed, nothing about this machine is at fault and there
9339
+ * is nothing for its operator to do. Logging that at WARN would fire on the innocent side of
9340
+ * every such session and train them to ignore the name.
9341
+ */
9342
+ const shared = {
9343
+ agentName, sessionId, correlationId, detail: action.detail,
9344
+ impact: "neither side will use a content salt for this session, and both now know it. Messages are hashed the way every build before this feature hashed them — nothing is degraded relative to any shipped release, and no message is affected.",
9345
+ };
9346
+ if (adoption.closed) {
9347
+ /**
9348
+ * ⚠️ TWO REFUSALS, TWO DIFFERENT THINGS TO DO — and this used to report both as
9349
+ * `already_hashing`.
9350
+ *
9351
+ * A session that has already sent messages is the feature working: the fix is a new session,
9352
+ * and it will work. A frontier this side could not READ is local storage trouble: a new
9353
+ * session will refuse in exactly the same way, so sending the operator to open one is
9354
+ * sending them somewhere that cannot help. `frontier` carries the counts (or the error) so
9355
+ * the two are separable from the log alone.
9356
+ */
9357
+ const unreadable = adoption.label === SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE;
9358
+ this.#logger.warn("session.salt.adoption.refused", {
9359
+ ...shared,
9360
+ reason: adoption.label,
9361
+ leafCount: adoption.leafCount,
9362
+ frontier: adoption.why,
9363
+ guidance: unreadable
9364
+ ? "This side could not read its own message frontier, so it refused the salt rather than risk hashing half the session one way and half the other. Starting a new session will NOT help — it will refuse the same way. Look for session.content.held.restore.failed or other storage errors around this line; the conversation still works and every message is intact, it is just unsalted."
9365
+ : "Nothing is broken and no message was lost: an unsalted session is exactly as verifiable as every session before this feature existed. It only means a relay holding the hashes could confirm a guess at a short message in THIS conversation. If you want the protection, start a new session — the agreement runs at open, before anything is hashed.",
9366
+ });
9367
+ }
9368
+ else {
9369
+ this.#logger.info("session.salt.adoption.closed", shared);
9370
+ }
9371
+ if (action.announce) {
9372
+ void this.#sendSaltFrame(agentName, sessionId, correlationId, action.announce);
9373
+ }
9374
+ return;
9375
+ }
9376
+ if (action.action === "repair") {
9377
+ /**
9378
+ * THE REPAIR — review F1. The two sides are out of step and CAN converge, so re-send our half
9379
+ * rather than destroying the session.
9380
+ *
9381
+ * At INFO because it is a real event an operator may need to correlate with a
9382
+ * `session.salt.announce.failed` or `session.salt.persist.failed` on either machine, and
9383
+ * because a session that repairs REPEATEDLY is a signal even though each repair is benign.
9384
+ */
9385
+ this.#logger.info("session.salt.repair", {
9386
+ agentName, sessionId, correlationId, detail: action.detail,
9387
+ answeredWith: action.frame.contribution ? "contribution" : "fingerprint",
9388
+ });
9389
+ // Recorded ONLY for a repair that sent our half, because that is the one a second identical
9390
+ // offer must not repeat (review F14). Recording the fingerprint answer too would be harmless
9391
+ // but says nothing — that branch is already terminal for the peer.
9392
+ if (peerHalfHex && action.frame.contribution)
9393
+ this.#saltRepairedAgainst.set(key, peerHalfHex);
9394
+ void this.#sendSaltFrame(agentName, sessionId, correlationId, action.frame);
9395
+ return;
9396
+ }
9397
+ // `detail` is the primitive's own sentence wherever the primitive produced it — never a code of
9398
+ // ours substituted for it (Invariant 2). `guidance` is what the operator can DO, and it comes
9399
+ // from the total map so a reason can never reach a log without one.
9400
+ this.#logger.error("session.salt.disagreement", {
9401
+ agentName, sessionId, correlationId,
9402
+ reason: action.reason,
9403
+ detail: action.detail,
9404
+ guidance: SALT_FREEZE_GUIDANCE[action.reason],
9405
+ });
9406
+ /**
9407
+ * TELL THE PEER BEFORE TEARING DOWN — review F1's mirror.
9408
+ *
9409
+ * Only the fingerprint mismatch carries a notice, and only it can: the peer holds everything
9410
+ * needed to run the identical comparison and has simply not been given our side of it. Without
9411
+ * this the session stops answering and the far operator gets no reason at all, while ours gets a
9412
+ * full explanation — Decision #10 asks for BOTH sides to refuse by name.
9413
+ *
9414
+ * Awaited, unlike the other sends, because `destroySessionNode` on the next line takes the node
9415
+ * away and an un-awaited write would race its own transport. A failure is already handled
9416
+ * inside — the refusal here has happened either way.
9417
+ */
9418
+ if (action.notifyPeer) {
9419
+ await this.#sendSaltFrame(agentName, sessionId, correlationId, action.notifyPeer);
9420
+ }
9421
+ await this.#freezeSession(agentName, sessionId, action.reason, {
9422
+ event: "session.salt.frozen",
9423
+ observation: `the salt agreement could not be completed with this counterparty: ${action.detail}`,
9424
+ impact: "the session was stopped rather than left to hash under a value the two sides do not share; no message was lost and the transcript is unaffected — only a NEW session moves this forward",
9425
+ reviveReason: `session_frozen_${action.reason}`,
9426
+ // The operator-facing sentence comes from the TOTAL guidance map, so a reason can never reach
9427
+ // this refusal without one — and it is what stops a salt disagreement being reported to them
9428
+ // as their counterparty failing a key check.
9429
+ reviveGuidance: SALT_FREEZE_GUIDANCE[action.reason],
9430
+ }, correlationId);
9431
+ }
9432
+ async #freezeOnIdentityFailure(agentName, sessionId, reason, correlationId) {
9433
+ await this.#freezeSession(agentName, sessionId, reason, {
9434
+ event: "session.content.identity.frozen",
9435
+ observation: "a frame failed to verify against the expected counterparty's key; the session was frozen defensively; cause undetermined",
9436
+ reviveReason: "session_frozen_identity_failure",
9437
+ reviveGuidance: "This session was frozen because a message failed to verify against the expected counterparty's key. " +
9438
+ "Cause undetermined — that signal looks the same whether someone was impersonating your counterparty or CELLO's own delivery mishandled a fallback, so it is recorded as an observation and nothing has been concluded about them.",
9439
+ // Review F1: this said "no further content will be accepted on this session", which was FALSE
9440
+ // as shipped — the next read revived it. It is true now, and it says how it is true, because
9441
+ // an operator who reads "frozen" and then watches the session work again learns to distrust
9442
+ // the log rather than the session.
9443
+ impact: "the content was NOT ingested, NOT displayed and NOT attributed to anyone; the session will not be revived by a read or a send, and only a close or a fresh session moves it now",
9444
+ }, correlationId);
9445
+ }
9446
+ /**
9447
+ * Stop a session and refuse to revive it. Shared by the identity freeze and the salt
9448
+ * disagreement, because the MECHANISM is identical — mark, then tear down — while the two have
9449
+ * nothing else in common and must not describe each other. An identity failure is evidence about
9450
+ * the counterparty; a salt disagreement is usually two builds that do not match, and telling an
9451
+ * operator their counterparty failed a key check when they did not is worse than saying nothing.
9452
+ * Hence the caller supplies both sentences.
9453
+ */
9454
+ async #freezeSession(agentName, sessionId, reason, narrative, correlationId) {
9455
+ // Review F1: MARK BEFORE TEARING DOWN. `destroySessionNode` writes `interrupted`, which is the
9456
+ // revivable status — if the mark landed after, a read racing the teardown could revive the
9457
+ // session out from under the freeze.
9458
+ //
9459
+ // `reviveReason` is SPELLED OUT by the caller rather than built from `reason`. Deriving it looks
9460
+ // tidier and silently changed a shipped contract: the identity freeze's `reason` is the specific
9461
+ // ordering failure (`bad_signature`, `signer_not_counterparty`), so a derived code would have
9462
+ // turned the stable `session_frozen_identity_failure` into a family of varying strings that no
9463
+ // caller matches. Caught by that path's own test.
9464
+ this.#frozenSessions.set(this.#k(agentName, sessionId), {
9465
+ reason: narrative.reviveReason,
9466
+ guidance: narrative.reviveGuidance,
9467
+ });
9468
+ // The EVENT NAME is the caller's, not this method's. Both freezes share a mechanism and nothing
9469
+ // else, and a salt disagreement logged under `…identity.frozen` would tell an operator their
9470
+ // counterparty failed a key check when they did not — a worse outcome than saying nothing.
9471
+ this.#logger.error(narrative.event, {
9472
+ agentName, sessionId, reason, correlationId,
9473
+ observation: narrative.observation,
9474
+ // Review F1: the identity path's impact line said "no further content will be accepted on this
9475
+ // session", which was FALSE as shipped — the next read revived it. It is true now, and it says
9476
+ // how it is true, because an operator who reads "frozen" and then watches the session work
9477
+ // again learns to distrust the log rather than the session.
9478
+ impact: narrative.impact,
9479
+ });
9480
+ try {
9481
+ /**
9482
+ * `"error"` maps to DB status `interrupted`, which is the SAME row an ordinary
9483
+ * counterparty-gone teardown writes — so a freeze is distinguishable in the log and in
9484
+ * behaviour, but not in the session record.
9485
+ *
9486
+ * ⚠️ THIS COMMENT USED TO SAY THE COLUMNS DID NOT EXIST. They do now: `frozen_at` and
9487
+ * `frozen_reason` were added to `sessions` by the salt migration, which carried them for
9488
+ * `DOD-M15-FREEZE-STATUS-1` so that two lanes would not both edit this file's migration list.
9489
+ * What is still missing is a WRITER — that is `FREEZE-STATUS-1`'s work and it belongs to the
9490
+ * other lane, so nothing here fills them in. The gap is unchanged; only the reason for it is.
9491
+ */
9492
+ await this.destroySessionNode(agentName, sessionId, "error");
9493
+ }
9494
+ catch (err) {
9495
+ // The refusal already happened — the content did not ingest. A teardown failure must not
9496
+ // turn a successful refusal into a thrown handler, which would close the stream on a path
9497
+ // that reads as "nothing arrived".
9498
+ this.#logger.warn("session.content.identity.freeze_teardown_failed", {
9499
+ agentName, sessionId, error: extractErrorMessage(err), correlationId,
9500
+ });
9501
+ }
9502
+ }
9503
+ #recordFrameOrdering(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId, source = "content_frame") {
9504
+ try {
9505
+ const s1 = decode(structure1Cbor);
9506
+ const s2 = decode(structure2Cbor);
9507
+ const s1Hash = s1?.[1];
9508
+ const s1Pubkey = s1?.[2];
9509
+ const seq = typeof s2?.[0] === "number" ? s2[0] : -1;
9510
+ const s2Sig = s2?.[3];
9511
+ if (!(s1Hash instanceof Uint8Array) || !(s1Pubkey instanceof Uint8Array) || !(s2Sig instanceof Uint8Array) || seq < 1) {
9512
+ // SOFT: we could not read the record, so we learned nothing about the signer either way.
9513
+ // Position falls back to the witness stream, exactly as an absent record does.
9514
+ this.#logger.warn("session.content.ordering.malformed", { sessionId, correlationId });
9515
+ return { seq: null };
9516
+ }
9517
+ // The framed ordering record must bind to THIS content (its hash) — else it orders the wrong bytes.
9518
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
9519
+ if (Buffer.from(s1Hash).toString("hex") !== contentHashHex) {
9520
+ // SOFT: the record does not describe this content. Nothing is proven about the signer's
9521
+ // identity — only that this record and these bytes do not belong together.
9522
+ this.#logger.warn("session.content.ordering.hash_mismatch", { sessionId, correlationId });
9523
+ return { seq: null };
9524
+ }
9525
+ // Verify the SENDER's Ed25519 signature over the exact signed bytes (structure1_cbor) — the same
9526
+ // check the relay performs. Proves the counterparty committed to this (content_hash @ sequence).
9527
+ if (!verify(s1Pubkey, structure1Cbor, s2Sig)) {
9528
+ // FATAL. The sender supplied a signature and it does not verify against the key inside its
9529
+ // own record. That is not an absence we could not resolve — it is a proof that failed.
9530
+ this.#logger.warn("session.content.ordering.bad_signature", { sessionId, correlationId });
9531
+ return { seq: null, fatal: { reason: "bad_signature" } };
9532
+ }
9533
+ // Sovereign-node cross-check: the signer MUST be THIS session's counterparty, not an unrelated
9534
+ // key. FAIL CLOSED (review L) — if the counterparty pubkey is unknown we cannot prove the signer,
9535
+ // so we do NOT trust the framed ordering record (fall back to the witness stream / arrival). The
9536
+ // "B does not trust the counterparty for ordering" invariant is non-negotiable; never fail open.
9537
+ // Review M1: compare BYTES, not hex strings — `counterparty_pubkey` is stored verbatim from the
9538
+ // IPC param and is never case-normalized, so a string compare would fail for a mixed-case
9539
+ // pubkey and silently strip the canonical ordering from every message in that session.
9540
+ const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
9541
+ if (!pubkeyMatchesHex(s1Pubkey, counterparty)) {
9542
+ /**
9543
+ * FATAL when the counterparty is KNOWN and the signer is someone else. SOFT when we simply
9544
+ * do not know who the counterparty is.
9545
+ *
9546
+ * The fatal half is the session-open MITM detection from the 2026-08-21 T-of-N
9547
+ * investigation, which found this check *"fires correctly, and its answer is thrown away."*
9548
+ * A rogue quorum of the directories holding shares for agent B can sign a false
9549
+ * SessionAssignment naming M's key as B's, and everything downstream is genuinely real —
9550
+ * M signs with M's own valid key. Nothing is missing for A to notice. This comparison is
9551
+ * where the substitution shows, because `counterparty_pubkey` comes from A's own request
9552
+ * and is untouched by anything the directory returns.
9553
+ *
9554
+ * ⚠️ IT SHOWS ONLY WHEN THE RECORD IS PRESENT (review F3). An earlier version of this
9555
+ * comment said this was "the one place the substitution shows", full stop — and that
9556
+ * asserted a property the code does not have: M can decline to supply an ordering record
9557
+ * and be ingested without ever reaching this line. The caller logs
9558
+ * `session.content.ordering.absent` so the weaker case is at least visible, and closing it
9559
+ * needs a check that does not depend on the sender's cooperation — the relay's independent
9560
+ * copy, `DOD-M15-CORROBORATE-1`.
9561
+ *
9562
+ * The soft half stays soft deliberately: `counterparty_unknown` means we cannot prove the
9563
+ * signer either way, and refusing there would strand sessions whose record we failed to
9564
+ * read rather than sessions that are under attack.
9565
+ */
9566
+ const reason = counterparty ? "signer_not_counterparty" : "counterparty_unknown";
9567
+ this.#logger.warn("session.content.ordering.wrong_signer", { sessionId, reason, correlationId });
9568
+ return counterparty ? { seq: null, fatal: { reason } } : { seq: null };
9569
+ }
9570
+ // Verified — record the relay-assigned canonical sequence (1-based → 0-based leaf index) for the gate.
9571
+ this.recordWitnessedSequence(agentName, sessionId, contentHashHex, seq - 1);
9572
+ this.#logger.info("session.content.ordering.recorded", {
9573
+ sessionId,
9574
+ canonicalSeq: seq - 1,
9575
+ source,
9576
+ correlationId,
9577
+ });
9578
+ /**
9579
+ * DOD-M15-SEALWIRE-1 bullet 5: return the VERIFIED proof, not just the position.
9580
+ *
9581
+ * Three lines above, `verify(s1Pubkey, structure1Cbor, s2Sig)` has already passed and the
9582
+ * signer has been matched to this session's counterparty. That is the strongest statement
9583
+ * this daemon ever makes about who wrote a message — and until now it was made, used to
9584
+ * decide a sequence number, and then discarded. The transcript row that outlives it recorded
9585
+ * only a direction.
9586
+ *
9587
+ * Returned rather than stashed, for the same reason `seq` is: a caller that has to go looking
9588
+ * for it in a side map is a caller that will not.
9589
+ */
9590
+ return { seq: seq - 1, senderPubkey: s1Pubkey, senderSig: s2Sig };
9591
+ }
9592
+ catch (err) {
9593
+ this.#logger.warn("session.content.ordering.decode_failed", {
9594
+ sessionId,
9595
+ error: err instanceof Error ? err.message : String(err),
9596
+ correlationId,
9597
+ });
9598
+ }
9599
+ // No verified position — the caller falls back to the announced hash-dedup path. SOFT: a decode
9600
+ // throw tells us nothing about the signer, so it is the absent case, not the refuted one.
9601
+ return { seq: null };
9602
+ }
9603
+ async #handleContentStream(agentName, sessionId, stream, remotePeerId) {
9604
+ // CLOSING THIS STREAM IS WHAT KEEPS THE SESSION ALIVE PAST ITS 33RD MESSAGE.
9605
+ //
9606
+ // Every content frame and every delivery ACK opens a fresh /cello/content/1.0.0 stream on the
9607
+ // one muxed connection the session holds, and libp2p caps INBOUND streams per protocol per
9608
+ // connection. It enforces that cap AFTER multistream-select has answered, so an over-cap stream
9609
+ // negotiates fine and is reset an instant later, and the SENDER's next `stream.send(...)`
9610
+ // throws "Cannot write to a stream that is closed" — an error that names the exit point and not
9611
+ // one thing about the cause.
9612
+ //
9613
+ // A stream leaves the muxer's set only on its `close` event, and closing our write end triggers
9614
+ // that only once the peer has closed its end too. So a handler that reads its frame and returns
9615
+ // leaves the stream half-open for the life of the connection and the count only ever rises.
9616
+ // Measured on a live daemon: 115 failures over 3.5 hours, with EXACTLY 32 successful streams
9617
+ // before the first one on both affected sessions (M12B Entry 10).
9618
+ //
9619
+ // The decoder is built INSIDE the try so a malformed stream cannot throw past the close below.
9620
+ let iter;
9621
+ try {
9622
+ iter = lp.decode(stream)[Symbol.asyncIterator]();
9623
+ const result = await iter.next();
9624
+ if (result.done || result.value === undefined)
9625
+ return;
9626
+ const bytes = result.value instanceof Uint8Array ? result.value
9627
+ : Buffer.isBuffer(result.value) ? new Uint8Array(result.value)
9628
+ : result.value.slice();
9629
+ const frame = decode(bytes);
9630
+ const correlationId = typeof frame["correlation_id"] === "string" ? frame["correlation_id"] : undefined;
9631
+ const frameType = typeof frame["type"] === "string" ? frame["type"] : "(absent)";
9632
+ /**
9633
+ * DOD-M15-FRAME-1 — ONE GATE, BEFORE THE DISPATCH, FOR EVERY FRAME ON THIS PROTOCOL.
9634
+ *
9635
+ * A stranger could dial an agent's standing receiver (it admitted everyone until DOD-M15-ASSIGN-1), hold
9636
+ * the connection open through promotion — libp2p's gater runs only at connection
9637
+ * establishment, so narrowing it does not evict anyone already attached — and then speak the
9638
+ * content protocol the moment it activated. The frame was ingested, leafed, transcribed, and
9639
+ * attributed to the legitimate counterparty, because attribution is read from local session
9640
+ * state rather than from anything the frame proved.
9641
+ *
9642
+ * DELIBERATELY SHARED RATHER THAN COPIED INTO EACH BRANCH. `session_abandoned_notice` already
9643
+ * had both checks, correct and complete, twenty lines below — and the other two frame types
9644
+ * did not. Copying the pattern a third and fourth time would fix today's three and leave the
9645
+ * fifth frame type, added later by someone who did not read this comment, unguarded again.
9646
+ * Placing it above the dispatch makes the guard the DEFAULT: a new frame type is protected by
9647
+ * construction and has to opt OUT visibly rather than opt in silently.
9648
+ *
9649
+ * Verified safe for all three current types by enumeration, not assumption — `content_frame`
9650
+ * (:5169), `session_abandoned_notice` (:6663) and `content_delivery_ack` (:7439) are the only
9651
+ * senders on `CELLO_CONTENT_PROTOCOL_ID`, and all three put `session_id` in the frame.
9652
+ *
9653
+ * MISSING, MALFORMED AND MISMATCHED TAKE ONE PATH. An attacker evading a mismatch check does
9654
+ * not send a wrong value — it sends no value, and a guard that only fires on a present-and-
9655
+ * wrong field is a guard that is trivially skipped. That is exactly what the old
9656
+ * `content_frame` check did: `typeof x === "string" && x !== sessionId`.
9657
+ */
9658
+ const expectedPeer = this.#activeNodes.get(this.#k(agentName, sessionId))?.counterpartySessionPeerId;
9659
+ if (!remotePeerId || !expectedPeer || remotePeerId !== expectedPeer) {
9660
+ // Loud in the LOG — there is no caller to answer on an inbound stream, so this is the whole
9661
+ // surface. Neutral wording: this is an observation, not a verdict about intent. The same
9662
+ // signal comes from a real impersonation attempt and from our own fallback paths
9663
+ // mishandling a reconnect, and nothing here can tell them apart.
9664
+ this.#logger.warn("session.content.peer_mismatch", {
9665
+ agentName, sessionId, frameType,
9666
+ remotePeerId: remotePeerId ?? "(absent)", expected: expectedPeer ?? "(unknown)",
9667
+ impact: "a frame arrived on this session's content protocol from a peer that is not its counterparty; it was refused — not ingested, not attributed, not recorded — and the peer was disconnected",
9668
+ });
9669
+ /**
9670
+ * PEER-ENDING, NOT SESSION-ENDING — and the difference is a deliberate deviation from the
9671
+ * DoD clause (review F2).
9672
+ *
9673
+ * The clause says the refusal is session-ending. Applied HERE that would be a worse hole
9674
+ * than the one it closes: a pre-positioned stranger could kill any session on the machine
9675
+ * with a single frame, trading an injection hole for a denial-of-service hole. The
9676
+ * session-ending response belongs where the evidence is about the SESSION's counterparty —
9677
+ * `#freezeOnIdentityFailure`, reached when a party that IS the peer we dialled signs with a
9678
+ * key that is not theirs.
9679
+ *
9680
+ * Here the evidence is about the PEER: they are not party to this session at all. So the
9681
+ * connection goes and the session is untouched. Without this the stranger stayed attached
9682
+ * for the life of the session and the gate re-refused each frame forever — and the eviction
9683
+ * sweep's own fallback ("the frame gate still refuses anything this peer sends") only closes
9684
+ * the loop if the frame gate does something about the connection.
9685
+ *
9686
+ * Fire-and-forget: a hang-up that fails must not turn a successful refusal into a thrown
9687
+ * handler, and the refusal above has already done the load-bearing work.
9688
+ */
9689
+ if (remotePeerId) {
9690
+ const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
9691
+ void entry?.node.hangUp(remotePeerId).catch((err) => {
9692
+ this.#logger.debug("session.content.peer_mismatch.hangup_failed", {
9693
+ sessionId, peerId: remotePeerId, error: extractErrorMessage(err),
9694
+ });
9695
+ });
9696
+ }
9697
+ return;
9698
+ }
9699
+ const claimedSessionId = frame["session_id"];
9700
+ if (typeof claimedSessionId !== "string" || claimedSessionId !== sessionId) {
9701
+ this.#logger.warn("session.content.session_mismatch", {
9702
+ agentName, sessionId, frameType,
9703
+ claimedSessionId: typeof claimedSessionId === "string" ? claimedSessionId : "(absent)",
9704
+ impact: "the frame does not name the session whose stream it arrived on; it was refused rather than routed, because the authenticated stream is the better authority for where content belongs",
9705
+ });
9706
+ return;
9707
+ }
9708
+ // CELLO-M7-MSG-001 (AC-001/AC-002): a `persisted` delivery ACK arriving on the
9709
+ // same /cello/content/1.0.0 protocol resolves the sender's awaiting-ACK timer.
9710
+ // The protocol acts on `persisted` ONLY — any other level leaves the timer armed.
9711
+ if (frame["type"] === "content_delivery_ack") {
9712
+ const ackHash = frame["content_hash"];
7035
9713
  const level = frame["level"];
7036
9714
  if (ackHash instanceof Uint8Array && level === "persisted") {
7037
9715
  this.#resolveAwaitingAck(agentName, sessionId, ackHash);
@@ -7042,36 +9720,41 @@ export class SessionNodeManager {
7042
9720
  // authenticated stream the delivery acknowledgement rides, and AFTER the session-id check
7043
9721
  // below cannot be skipped — the frame names its session and the handler is bound to one.
7044
9722
  if (frame["type"] === "session_abandoned_notice") {
7045
- // PINNED TO THE COUNTERPARTY. This frame ENDS a conversation, so the stream being
7046
- // authenticated is not enough a session node is a promoted standing receiver, and a
7047
- // standing receiver accepts everyone. libp2p's gater runs at connection establishment and
7048
- // does not close connections that already exist, so a peer that dialled this node earlier
7049
- // still holds a live connection after `setAllowedPeer` narrows it. Without this check that
7050
- // peer could hang up a session it is not party to.
7051
- //
7052
- // `remotePeerId` is the Noise-authenticated transport identity, which the handler was
7053
- // throwing away. Absent means we cannot prove who is speaking, and an unprovable claim to
7054
- // end a session is refused.
7055
- const expected = this.#activeNodes.get(this.#k(agentName, sessionId))?.counterpartySessionPeerId;
7056
- if (!remotePeerId || !expected || remotePeerId !== expected) {
7057
- this.#logger.warn("session.content.peer_mismatch", {
7058
- sessionId, frameType: "session_abandoned_notice",
7059
- remotePeerId: remotePeerId ?? "(absent)", expected: expected ?? "(unknown)",
7060
- });
7061
- return;
7062
- }
7063
- // REQUIRED and equal — absence is not a pass. The frame names its session and the handler
7064
- // is bound to one; treating a missing field as agreement is how a guard stops guarding.
7065
- const claimed = frame["session_id"];
7066
- if (typeof claimed !== "string" || claimed !== sessionId) {
7067
- this.#logger.warn("session.content.session_mismatch", {
7068
- sessionId, claimedSessionId: typeof claimed === "string" ? claimed : "(absent)",
7069
- });
7070
- return;
7071
- }
9723
+ // DOD-M15-FRAME-1: the peer and session checks that used to live here now run above, for
9724
+ // EVERY frame type, unchanged in substance this branch was where they were written first
9725
+ // and correctly, and it is the reference the shared gate was lifted from. Its comment is
9726
+ // preserved there, including the reason the transport being authenticated is not enough.
9727
+ // Left as a bare dispatch on purpose: a second copy of a guard is a second thing to keep in
9728
+ // step, and the one that drifts is the one nobody is reading.
7072
9729
  void this.retireOnCounterpartyAbandon(agentName, sessionId, correlationId);
7073
9730
  return;
7074
9731
  }
9732
+ /**
9733
+ * DOD-M15-SEALWIRE-1 bullet 6 (part A) — the salt agreement.
9734
+ *
9735
+ * Placed BELOW the shared peer/session gate deliberately, which is the whole reason that gate
9736
+ * was lifted above the dispatch: a new frame type is protected by construction rather than
9737
+ * having to remember to opt in. A stranger's salt frame is refused before it reaches here, so
9738
+ * nothing about this session's salt can be steered by a peer that is not its counterparty.
9739
+ *
9740
+ * The fields are read defensively into the frame shape rather than cast: an inbound value is
9741
+ * whatever a peer chose to encode, and `onPeerSaltFrame` refuses both-fields and neither-field
9742
+ * by name — so a non-Uint8Array in either slot must arrive at that function as ABSENT, not as
9743
+ * a present-but-wrong value it would then try to use.
9744
+ */
9745
+ if (frame["type"] === "session_salt_agreement") {
9746
+ const contribution = frame["contribution"];
9747
+ const fingerprint = frame["fingerprint"];
9748
+ const adoptionClosed = frame["adoption_closed"];
9749
+ await this.#handleSaltFrame(agentName, sessionId, {
9750
+ ...(contribution instanceof Uint8Array ? { contribution } : {}),
9751
+ ...(fingerprint instanceof Uint8Array ? { fingerprint } : {}),
9752
+ // A non-string stays ABSENT rather than being coerced, exactly like the other two: the
9753
+ // decision function refuses a shape it cannot read, and must never be handed a `"42"`.
9754
+ ...(typeof adoptionClosed === "string" && adoptionClosed.length > 0 ? { adoptionClosed } : {}),
9755
+ }, correlationId);
9756
+ return;
9757
+ }
7075
9758
  if (frame["type"] !== "content_frame") {
7076
9759
  // LOGGED, not silently dropped. This handler is bound to one session, and a frame it does
7077
9760
  // not understand arriving on that stream is either a peer speaking a newer protocol or a
@@ -7083,25 +9766,11 @@ export class SessionNodeManager {
7083
9766
  });
7084
9767
  return;
7085
9768
  }
7086
- // THE FRAME NAMES ITS SESSION, and until now nothing checked it.
7087
- //
7088
- // The stream binding decides where content lands, so the field was decorative which is the
7089
- // problem. A peer holding TWO sessions with us could put content addressed to one on the
7090
- // other's stream and it was ingested, leafed, transcribed and SEALED under the wrong record.
7091
- // The sealed transcript is the artifact this protocol exists to produce; a message in it that
7092
- // its own author addressed elsewhere is exactly the thing it must not contain.
7093
- //
7094
- // Refused rather than re-routed: routing it to the session it names would honour a claim made
7095
- // by the party whose frame arrived in the wrong place, and the stream — which is authenticated
7096
- // — is the better authority. Refusing leaves the sender to redeliver on the right one.
7097
- const framedSessionId = frame["session_id"];
7098
- if (typeof framedSessionId === "string" && framedSessionId !== sessionId) {
7099
- this.#logger.warn("session.content.session_mismatch", {
7100
- sessionId,
7101
- claimedSessionId: framedSessionId,
7102
- });
7103
- return;
7104
- }
9769
+ // DOD-M15-FRAME-1: the session-id check moved to the shared gate above, and its `&&` became
9770
+ // `||` on the way. It read `typeof x === "string" && x !== sessionId` — firing only when the
9771
+ // field was PRESENT and wrong, so omitting it passed. Its own sibling twenty lines up already
9772
+ // refused absence, with a comment saying treating a missing field as agreement is how a guard
9773
+ // stops guarding. Same file, same switch, opposite conclusion.
7105
9774
  const contentBytes = frame["content_bytes"];
7106
9775
  const contentHash = frame["content_hash"];
7107
9776
  if (!(contentBytes instanceof Uint8Array) || !(contentHash instanceof Uint8Array)) {
@@ -7118,17 +9787,70 @@ export class SessionNodeManager {
7118
9787
  // DOD-MSG-4 (self-ordering content frame): if the frame carries the relay's signed ordering
7119
9788
  // record, verify the sender signature and record the canonical sequence FROM THE FRAME, BEFORE
7120
9789
  // ingest — so the strict-in-order gate has the position without waiting on the separate
7121
- // leaf_deliver witness (removes the content-before-witness race). A bad/absent record is
7122
- // non-fatal: the content still ingests, ordered by the witness stream / arrival as before.
9790
+ // leaf_deliver witness (removes the content-before-witness race).
9791
+ //
9792
+ // DOD-M15-FRAME-1 — POSITION MAY BE SOFT; IDENTITY MAY NOT. The old comment here read "A
9793
+ // bad/absent record is non-fatal: the content still ingests", and it was accurate: a
9794
+ // signature that failed to verify, and a signature by a key that is NOT this session's
9795
+ // counterparty, both returned null and the content was ingested and attributed anyway. An
9796
+ // ABSENT record stays soft — that is the documented relay-degraded path and refusing it would
9797
+ // make the relay a precondition for reading mail. A record that is PRESENT and REFUTED is a
9798
+ // different fact, and it is now refused.
7123
9799
  const s1Cbor = frame["structure1_cbor"];
7124
9800
  const s2Cbor = frame["structure2_cbor"];
7125
9801
  let framedSeq = null;
9802
+ /**
9803
+ * DOD-M15-SEALWIRE-1 bullet 5. Set ONLY when the ordering record verified — the signature
9804
+ * checked against the pubkey inside the sender's own signed bytes AND the signer matched this
9805
+ * session's counterparty. It is deliberately NOT set on the two soft paths below (no record
9806
+ * supplied; decode failed), because on those the author is attested by local session state
9807
+ * and the transcript row must say so rather than imply a proof it does not have.
9808
+ */
9809
+ let verifiedAuthorship;
7126
9810
  if (s1Cbor instanceof Uint8Array && s2Cbor instanceof Uint8Array) {
7127
- framedSeq = this.#recordFrameOrdering(agentName, sessionId, s1Cbor, s2Cbor, contentHash, correlationId);
9811
+ const ordering = this.#recordFrameOrdering(agentName, sessionId, s1Cbor, s2Cbor, contentHash, correlationId);
9812
+ if (ordering.fatal) {
9813
+ await this.#freezeOnIdentityFailure(agentName, sessionId, ordering.fatal.reason, correlationId);
9814
+ return;
9815
+ }
9816
+ framedSeq = ordering.seq;
9817
+ if (ordering.senderPubkey !== undefined && ordering.senderSig !== undefined) {
9818
+ verifiedAuthorship = { senderPubkey: ordering.senderPubkey, senderSig: ordering.senderSig };
9819
+ }
9820
+ }
9821
+ else {
9822
+ /**
9823
+ * Review F3 — THE WEAKER GUARANTEE MUST NOT BE INDISTINGUISHABLE FROM THE STRONGER ONE.
9824
+ *
9825
+ * A frame with no ordering record is still ingested, and that is correct: it is the
9826
+ * documented relay-degraded path, and refusing it would make the relay a precondition for
9827
+ * reading mail. But it means the per-message signer check is **opt-in for the sender** — a
9828
+ * party that passed the peer gate and wants to avoid the comparison simply omits the proof.
9829
+ * Silently, until now: nothing recorded that a message arrived unverified, so the log looked
9830
+ * identical to one where every message had been checked.
9831
+ *
9832
+ * Not fatal, and deliberately not: an absent record proves nothing about the signer, and
9833
+ * refusing on an absence would strand every relay-degraded session. What closes the omission
9834
+ * case is relay-side corroboration — `DOD-M15-CORROBORATE-1` — where the relay holds the
9835
+ * sender's signed hash independently and never routes it through this daemon.
9836
+ */
9837
+ this.#logger.info("session.content.ordering.absent", {
9838
+ agentName, sessionId, correlationId,
9839
+ impact: "this frame carried no signed ordering record, so its SIGNER was not verified for this message — it was ingested on the strength of the authenticated transport alone",
9840
+ });
7128
9841
  }
7129
9842
  // AC-001: carry the sender's correlationId from the frame into the receive
7130
9843
  // path so both sides log the same flow id (never re-minted on receipt).
7131
- const ingest = await this.ingestReceivedContent(agentName, sessionId, contentBytes, contentHash, correlationId, framedSeq ?? undefined);
9844
+ /**
9845
+ * DOD-M15-SEALWIRE-1 part B1 — the algorithm the sender named, taken from the FRAME.
9846
+ *
9847
+ * Read as `unknown` and passed through verbatim, deliberately: `resolveContentHashAlg` is the
9848
+ * one place that decides what a value means, and it distinguishes ABSENT (a peer predating
9849
+ * the field — verify as `sha256`) from a non-string or an unreadable name (refuse by name).
9850
+ * Coercing here would collapse that distinction and turn a version skew into a tamper report.
9851
+ */
9852
+ const declaredAlg = frame["content_hash_alg"];
9853
+ const ingest = await this.ingestReceivedContent(agentName, sessionId, contentBytes, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship);
7132
9854
  // AC-001: after the content is durably ingested AND its hash cross-check
7133
9855
  // succeeds, emit an unsigned `persisted` delivery ACK back to the sender. A
7134
9856
  // rejected ingest (tamper / not-active) produces NO ACK, so the sender's TTF
@@ -7789,7 +10511,9 @@ export class SessionNodeManager {
7789
10511
  const sessionId = `standing_receiver_${randomUUID()}`;
7790
10512
  const gater = new SessionConnectionGater({
7791
10513
  sessionId,
7792
- allowedPeerId: null, // open counterparty unknown at creation time
10514
+ // No named peer: admits NOBODY inbound until a session offer names the dialer, while leaving
10515
+ // this node's own outbound errands open (DOD-M15-ASSIGN-1). It does NOT mean "open".
10516
+ allowedPeerId: null,
7793
10517
  logger: this.#logger,
7794
10518
  });
7795
10519
  // DOD-NAT-REACHABILITY-1: reserve with the agent's known relays. The relay
@@ -7882,6 +10606,10 @@ export class SessionNodeManager {
7882
10606
  const reservedRelayPeerId = circuitAddrs > 0
7883
10607
  ? (heldCircuitAddr?.match(CIRCUIT_RELAY_ID)?.[1] ?? reservations.addrs[0]?.match(CIRCUIT_RELAY_ID)?.[1])
7884
10608
  : undefined;
10609
+ // DOD-M15-ASSIGN-1 review N3: the ONE relay this receiver actually reserved with earns the
10610
+ // inbound AutoNAT carve-out — nothing else does. Set only when a reservation genuinely
10611
+ // completed, so a directory that merely NAMES a relay cannot dial in behind it.
10612
+ gater.setReservedRelayPeer(circuitAddrs > 0 && reservedRelayPeerId !== undefined ? reservedRelayPeerId : null);
7885
10613
  this.#standingReceivers.set(agentName, {
7886
10614
  node,
7887
10615
  gater,
@@ -8207,9 +10935,47 @@ export class SessionNodeManager {
8207
10935
  const live = this.#activeNodes.get(key);
8208
10936
  if (live)
8209
10937
  return { ok: true, peerId: live.node.getPeerId() };
10938
+ // PARITY with `acceptSession` — and the parity guard in msg-022 is what caught its absence.
10939
+ // A revived session's offer record has almost always been cleared already (it was cleared when
10940
+ // the session was first accepted), so this is usually a no-op. It is here because "usually a
10941
+ // no-op" is not a reason for establishment and revival to do different things: every divergence
10942
+ // between those two paths in this file has been a defect, and the guard exists because one of
10943
+ // them shipped past a green suite for two days.
10944
+ this.clearOfferedDialer(agentName, sessionId);
8210
10945
  const record = this.getSessionRecord(agentName, sessionId);
8211
10946
  if (!record)
8212
10947
  return { ok: false, reason: "session_not_found" };
10948
+ /**
10949
+ * A REVIVED SESSION GETS ITS SEAL CHANCES BACK — `DOD-M15-SEAL-FAILED-TERMINAL-1` review
10950
+ * MEDIUM-6, and without this a receipt can be lost permanently and silently.
10951
+ *
10952
+ * `restart_seal_gave_up_at` is written when the restart resolver exhausts its attempts, and
10953
+ * NOTHING ever cleared it. Its stated purpose is narrow — *"a machine restarting ~6 times a day
10954
+ * must not re-run five ceremonies against a hopeless session on every boot"* — and a session
10955
+ * being revived is the opposite of hopeless: something is talking to it again.
10956
+ *
10957
+ * The path it closes: resolver gives up → the column is stamped → the session is REVIVED and
10958
+ * carries live traffic → it is closed → the background ceremony dies → the in-memory failure
10959
+ * marker is lost at the next restart → `listRestartOrphanedSessions` excludes the row forever on
10960
+ * this column → and `listExpiredUnrevivableSessions` explicitly INCLUDES
10961
+ * `restart_seal_gave_up_at IS NOT NULL`, so the revival sweep force-abandons it. Receipt gone,
10962
+ * with no surface having ever said so.
10963
+ *
10964
+ * Bounded, because revival is not a boot-loop: it takes a live counterparty or an operator read.
10965
+ */
10966
+ // One statement, gated in SQL rather than on a field: `SessionRecord` does not carry this column
10967
+ // and widening the type to read it once would spread it through every consumer. `changes` tells
10968
+ // us whether it actually cleared, so the log stays a signal instead of firing on every revival.
10969
+ const clearedGaveUp = this.#db
10970
+ ?.prepare("UPDATE sessions SET restart_seal_gave_up_at = NULL, restart_seal_gave_up_reason = NULL " +
10971
+ "WHERE agent_id = ? AND session_id = ? AND restart_seal_gave_up_at IS NOT NULL")
10972
+ .run(this.resolveAgentId(agentName), sessionId);
10973
+ if ((clearedGaveUp?.changes ?? 0) > 0) {
10974
+ this.#logger.info("session.restart_seal.gave_up.cleared", {
10975
+ agentName, sessionId,
10976
+ impact: "this session is eligible for restart-seal recovery again — it is being revived, so it is not hopeless.",
10977
+ });
10978
+ }
8213
10979
  if (record.status === "sealed" || record.status === "abandoned" || record.status === "seal_interrupted_pending") {
8214
10980
  return {
8215
10981
  ok: false,
@@ -8217,6 +10983,39 @@ export class SessionNodeManager {
8217
10983
  guidance: `Session is '${record.status}'. A session that has ended cannot be revived; start a new one.`,
8218
10984
  };
8219
10985
  }
10986
+ /**
10987
+ * DOD-M15-FRAME-1 (review F1) — A DEFENSIVE FREEZE MUST NOT UNDO ITSELF ON THE NEXT READ.
10988
+ *
10989
+ * `#freezeOnIdentityFailure` tears the node down, and a teardown writes status `interrupted`.
10990
+ * `interrupted` is not terminal — it is the *revivable* status — so `reviveIfNeededForRead`
10991
+ * fired on the operator's very next `cello_receive`, rebuilt a node behind a gater allowing the
10992
+ * SAME counterparty peer, flipped the row back to `active`, and logged it as a success.
10993
+ *
10994
+ * The freeze therefore lasted until the next keystroke, while the log line said *"no further
10995
+ * content will be accepted on this session"*. A security decision that silently reverses itself,
10996
+ * with a message asserting the opposite, is a worse defect than the one the freeze was added to
10997
+ * fix — and it is the class this milestone exists to remove, reintroduced by its own fix.
10998
+ *
10999
+ * Checked BEFORE the cap and after the terminal statuses, so the answer names the freeze rather
11000
+ * than whatever else the session would have been refused for.
11001
+ *
11002
+ * In memory, and so lost on a daemon restart — the same bound as `DOD-M15-DIVERGE-DURABLE-1`
11003
+ * and for the same reason. The durable column is `DOD-M15-FREEZE-STATUS-1`; the reversibility
11004
+ * could not wait for it.
11005
+ */
11006
+ const frozen = this.#frozenSessions.get(key);
11007
+ if (frozen) {
11008
+ // The REASON and the GUIDANCE both come from the site that froze it. Hardcoding them here was
11009
+ // correct while an identity failure was the only way in, and became a false accusation the
11010
+ // moment a second one existed — see the note on `#frozenSessions`.
11011
+ return {
11012
+ ok: false,
11013
+ reason: frozen.reason,
11014
+ guidance: `${frozen.guidance} It is not revived automatically, and reading or sending will not clear it. ` +
11015
+ `Your transcript up to the freeze is intact: cello_transcript ${sessionId} reads it. ` +
11016
+ `To end the session and keep what it earned, close it — cello_close_session ${sessionId}. To talk to them again, start a fresh session rather than reviving this one.`,
11017
+ };
11018
+ }
8220
11019
  /**
8221
11020
  * THE CAP APPLIES TO A REVIVAL TOO (review: parity gap). Establishment refuses at
8222
11021
  * `MAX_SESSION_NODES` because each node is a real libp2p instance with listeners, connections
@@ -8767,6 +11566,19 @@ export class SessionNodeManager {
8767
11566
  */
8768
11567
  if (status === "sealed" || status === "abandoned") {
8769
11568
  this.#destroySessionSeed(agentName, sessionId);
11569
+ // DOD-M15-DIVERGE-1: divergence stops being true HERE and only here. It used to be dropped by
11570
+ // `#evictSessionCaches` on every node teardown — including the one that writes `interrupted`,
11571
+ // which is a status the seal gate still acts on, so the fact was forgotten while it was still
11572
+ // load-bearing. A terminal status is the one point at which no future close can be refused,
11573
+ // so the flag has nothing left to protect.
11574
+ this.#diverged.delete(this.#k(agentName, sessionId));
11575
+ // DURABLE too (DOD-M15-DIVERGE-DURABLE-1) — otherwise a sealed session comes back after a
11576
+ // restart still carrying a refusal for a close that can no longer happen.
11577
+ // (agent_id, session_id) — see markSessionDiverged. Unkeyed, one side sealing cleared the
11578
+ // OTHER side's divergence on a loopback session.
11579
+ this.#db
11580
+ ?.prepare("UPDATE sessions SET diverged_at = NULL WHERE agent_id = ? AND session_id = ?")
11581
+ .run(this.#requireAgentId(agentName), sessionId);
8770
11582
  }
8771
11583
  // THE TERMINAL GUARD LIVES HERE, not in one wrapper, because there are three writers of
8772
11584
  // "sealed": markSealed, destroySessionNode, and retireSession on the witnessed-submit path.