@cello-protocol/daemon 0.0.182 → 0.0.184

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 (137) hide show
  1. package/dist/agent-settings-keys.d.ts.map +1 -1
  2. package/dist/agent-settings-keys.js +18 -0
  3. package/dist/agent-settings-keys.js.map +1 -1
  4. package/dist/close-session-handler.d.ts.map +1 -1
  5. package/dist/close-session-handler.js +90 -16
  6. package/dist/close-session-handler.js.map +1 -1
  7. package/dist/consortium-bootstrap.d.ts.map +1 -1
  8. package/dist/consortium-bootstrap.js +117 -2
  9. package/dist/consortium-bootstrap.js.map +1 -1
  10. package/dist/content-encryption-status.d.ts +97 -0
  11. package/dist/content-encryption-status.d.ts.map +1 -0
  12. package/dist/content-encryption-status.js +122 -0
  13. package/dist/content-encryption-status.js.map +1 -0
  14. package/dist/content-park-client.d.ts +10 -0
  15. package/dist/content-park-client.d.ts.map +1 -1
  16. package/dist/content-park-client.js +84 -3
  17. package/dist/content-park-client.js.map +1 -1
  18. package/dist/content-park.d.ts.map +1 -1
  19. package/dist/content-park.js +184 -13
  20. package/dist/content-park.js.map +1 -1
  21. package/dist/daemon.d.ts.map +1 -1
  22. package/dist/daemon.js +671 -67
  23. package/dist/daemon.js.map +1 -1
  24. package/dist/document-handlers.d.ts.map +1 -1
  25. package/dist/document-handlers.js +119 -0
  26. package/dist/document-handlers.js.map +1 -1
  27. package/dist/document-layer.d.ts +0 -2
  28. package/dist/document-layer.d.ts.map +1 -1
  29. package/dist/document-layer.js.map +1 -1
  30. package/dist/frame-values.d.ts.map +1 -1
  31. package/dist/frame-values.js +14 -0
  32. package/dist/frame-values.js.map +1 -1
  33. package/dist/inclusion-proof-handlers.d.ts +43 -0
  34. package/dist/inclusion-proof-handlers.d.ts.map +1 -0
  35. package/dist/inclusion-proof-handlers.js +565 -0
  36. package/dist/inclusion-proof-handlers.js.map +1 -0
  37. package/dist/inclusion-proof.d.ts +151 -0
  38. package/dist/inclusion-proof.d.ts.map +1 -0
  39. package/dist/inclusion-proof.js +228 -0
  40. package/dist/inclusion-proof.js.map +1 -0
  41. package/dist/initiate-session-handler.d.ts.map +1 -1
  42. package/dist/initiate-session-handler.js +34 -3
  43. package/dist/initiate-session-handler.js.map +1 -1
  44. package/dist/manifest-deps.d.ts +0 -11
  45. package/dist/manifest-deps.d.ts.map +1 -1
  46. package/dist/manifest-deps.js +28 -2
  47. package/dist/manifest-deps.js.map +1 -1
  48. package/dist/manifest-validity.d.ts +2 -0
  49. package/dist/manifest-validity.d.ts.map +1 -1
  50. package/dist/manifest-validity.js +1 -1
  51. package/dist/manifest-validity.js.map +1 -1
  52. package/dist/network-directory-node.d.ts +16 -0
  53. package/dist/network-directory-node.d.ts.map +1 -1
  54. package/dist/network-directory-node.js +40 -0
  55. package/dist/network-directory-node.js.map +1 -1
  56. package/dist/notification-handlers.d.ts.map +1 -1
  57. package/dist/notification-handlers.js +71 -2
  58. package/dist/notification-handlers.js.map +1 -1
  59. package/dist/outbound-sessions.d.ts +6 -0
  60. package/dist/outbound-sessions.d.ts.map +1 -1
  61. package/dist/outbound-sessions.js +50 -7
  62. package/dist/outbound-sessions.js.map +1 -1
  63. package/dist/park-envelope.d.ts +23 -0
  64. package/dist/park-envelope.d.ts.map +1 -1
  65. package/dist/park-envelope.js +76 -0
  66. package/dist/park-envelope.js.map +1 -1
  67. package/dist/register-handler.d.ts.map +1 -1
  68. package/dist/register-handler.js +76 -0
  69. package/dist/register-handler.js.map +1 -1
  70. package/dist/relay-only.d.ts +133 -0
  71. package/dist/relay-only.d.ts.map +1 -0
  72. package/dist/relay-only.js +183 -0
  73. package/dist/relay-only.js.map +1 -0
  74. package/dist/retry-queue.d.ts +26 -1
  75. package/dist/retry-queue.d.ts.map +1 -1
  76. package/dist/retry-queue.js +18 -1
  77. package/dist/retry-queue.js.map +1 -1
  78. package/dist/seal-coordinator.d.ts +51 -1
  79. package/dist/seal-coordinator.d.ts.map +1 -1
  80. package/dist/seal-coordinator.js +248 -1
  81. package/dist/seal-coordinator.js.map +1 -1
  82. package/dist/seal-escalation.d.ts.map +1 -1
  83. package/dist/seal-escalation.js +28 -0
  84. package/dist/seal-escalation.js.map +1 -1
  85. package/dist/seal-failure-store.d.ts.map +1 -1
  86. package/dist/seal-failure-store.js +5 -1
  87. package/dist/seal-failure-store.js.map +1 -1
  88. package/dist/sealed-leaf-set.d.ts +92 -0
  89. package/dist/sealed-leaf-set.d.ts.map +1 -0
  90. package/dist/sealed-leaf-set.js +129 -0
  91. package/dist/sealed-leaf-set.js.map +1 -0
  92. package/dist/session-ceremony.d.ts +27 -1
  93. package/dist/session-ceremony.d.ts.map +1 -1
  94. package/dist/session-ceremony.js +109 -7
  95. package/dist/session-ceremony.js.map +1 -1
  96. package/dist/session-content-handlers.d.ts +12 -0
  97. package/dist/session-content-handlers.d.ts.map +1 -1
  98. package/dist/session-content-handlers.js +39 -4
  99. package/dist/session-content-handlers.js.map +1 -1
  100. package/dist/session-node-manager.d.ts +387 -4
  101. package/dist/session-node-manager.d.ts.map +1 -1
  102. package/dist/session-node-manager.js +3529 -279
  103. package/dist/session-node-manager.js.map +1 -1
  104. package/dist/session-read-handlers.d.ts.map +1 -1
  105. package/dist/session-read-handlers.js +16 -1
  106. package/dist/session-read-handlers.js.map +1 -1
  107. package/dist/session-relay-client.d.ts +163 -1
  108. package/dist/session-relay-client.d.ts.map +1 -1
  109. package/dist/session-relay-client.js +540 -6
  110. package/dist/session-relay-client.js.map +1 -1
  111. package/dist/session-salt-agreement.d.ts +58 -4
  112. package/dist/session-salt-agreement.d.ts.map +1 -1
  113. package/dist/session-salt-agreement.js +110 -3
  114. package/dist/session-salt-agreement.js.map +1 -1
  115. package/dist/signal-submission.d.ts +11 -1
  116. package/dist/signal-submission.d.ts.map +1 -1
  117. package/dist/signal-submission.js.map +1 -1
  118. package/dist/signaling-connect.d.ts +18 -1
  119. package/dist/signaling-connect.d.ts.map +1 -1
  120. package/dist/signaling-connect.js +164 -1
  121. package/dist/signaling-connect.js.map +1 -1
  122. package/dist/submission-retry.d.ts +208 -0
  123. package/dist/submission-retry.d.ts.map +1 -0
  124. package/dist/submission-retry.js +505 -0
  125. package/dist/submission-retry.js.map +1 -0
  126. package/dist/types.d.ts +33 -0
  127. package/dist/types.d.ts.map +1 -1
  128. package/dist/types.js.map +1 -1
  129. package/dist/vocabulary.d.ts +13 -3
  130. package/dist/vocabulary.d.ts.map +1 -1
  131. package/dist/vocabulary.js +14 -3
  132. package/dist/vocabulary.js.map +1 -1
  133. package/dist/wire-content-hash.d.ts +15 -3
  134. package/dist/wire-content-hash.d.ts.map +1 -1
  135. package/dist/wire-content-hash.js +15 -3
  136. package/dist/wire-content-hash.js.map +1 -1
  137. package/package.json +5 -5
@@ -22,7 +22,8 @@
22
22
  // named. A direct call here would be a hash computed without asking what the frame said (part B1).
23
23
  import { contentHashFor, resolveContentHashAlg, CONTENT_HASH_ALGS } from "./wire-content-hash.js";
24
24
  import { CAPACITY_REASONS } from "./refusal-reasons.js";
25
- import { onPeerSaltFrame, ownSaltFrame, SALT_ADOPTION_LABELS, SALT_FREEZE_GUIDANCE, } from "./session-salt-agreement.js";
25
+ import { onPeerSaltFrame, ownSaltFrame, SALT_ADOPTION_LABEL_MAX, SALT_ADOPTION_LABELS, SALT_FREEZE_GUIDANCE, } from "./session-salt-agreement.js";
26
+ import { CONTENT_ENCRYPTION_REASONS, CONTENT_ENCRYPTION_GUIDANCE, SESSION_CONTENT_ENCRYPTION_V1, } from "./content-encryption-status.js";
26
27
  import { openEncryptedDatabase, resolveDbKey, dbKeyPathFor, } from "./sqlcipher-db.js";
27
28
  import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
28
29
  import { ensureIdentitySchema } from "./db-identity-store.js";
@@ -31,6 +32,7 @@ import { TIER, normalizeTier, isKnownTierValue, tierBoundsFor, DEFAULT_TIER_BOUN
31
32
  import { migrateCborBlobsToCanonical } from "./cbor-blob-migration.js";
32
33
  import { ensureTrustSignalSchema } from "./trust-signal-store.js";
33
34
  import { boundSettingKey, settableTierName, isValidSettingKey, awayTierSettingKey, AWAY_DEFAULT_KEY } from "./agent-settings-keys.js";
35
+ import { publishableEndpoint, relayOnlyState } from "./relay-only.js";
34
36
  import { randomUUID, createHash, randomBytes } from "node:crypto";
35
37
  import * as lp from "it-length-prefixed";
36
38
  import { decode } from "cbor-x";
@@ -39,9 +41,13 @@ import { MAX_SESSION_NODES, STANDING_RECEIVER_AGENT_NAME } from "./types.js";
39
41
  import { SessionConnectionGater } from "./session-connection-gater.js";
40
42
  import { SessionTree, sessionTreeLeafKindFromDb } from "./session-tree.js";
41
43
  import { CELLO_CONTENT_PROTOCOL_ID, NodeAutoNatService } from "@cello-protocol/transport";
42
- import { verify, buildMerkleTree, merkleRoot, generateSaltContribution, SESSION_SALT_BYTES } from "@cello-protocol/crypto";
44
+ import { verify, buildMerkleTree, merkleRoot, generateSaltContribution, SESSION_SALT_BYTES, generateSessionEphemeral, destroySessionEphemeral, deriveSessionSecrets, signSessionEphemeral, verifySessionEphemeral, sealSessionContent, openSessionContent, } from "@cello-protocol/crypto";
43
45
  import { encodeSealPayload, MONIKER_RE, validateMoniker } from "@cello-protocol/protocol-types";
44
- import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex, ParkEnvelopeError, PARK_ENVELOPE_REASONS } from "./park-envelope.js";
46
+ // `PARK_ENVELOPE_REASONS` is deliberately NOT imported here. The reason codes are compared inside
47
+ // `park-envelope.ts` itself (`parkRefusalGuidance`) and asserted in its own test; this file only ever
48
+ // receives the already-classified `ParkAuthFailure`, so importing the code table here would invite a
49
+ // second, drifting copy of the classification logic.
50
+ import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex, ParkEnvelopeError, parkRefusalGuidance } from "./park-envelope.js";
45
51
  import { isValidMultiaddr } from "@cello-protocol/transport";
46
52
  // `LEAF_KIND_MSG` is no longer imported here: `sendContent`'s `leafKind` stopped defaulting to it
47
53
  // (B2b-1 review F4), so this file no longer names a default — every caller states its own kind.
@@ -49,6 +55,7 @@ import { AgentRelayClient, LEAF_KIND_CTRL, isTerminalRelayRefusal, extractErrorM
49
55
  import { terminalRelayRefusal } from "./session-terminal-refusal.js";
50
56
  import { RelayReceiptStore } from "./relay-receipt-store.js";
51
57
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
58
+ import { certifiedLeafSetFrom } from "./sealed-leaf-set.js";
52
59
  import { addColumnIfMissing } from "./column-birth.js";
53
60
  import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
54
61
  /** SEC-1 / review M4: cap on the refused-parked-entry memo (remote-fed → must be bounded). */
@@ -85,6 +92,15 @@ const MAX_UNREADABLE_ALG_FRAMES = 64;
85
92
  * park-only session never starts one and never waits (constraint 5).
86
93
  */
87
94
  const SALT_AGREEMENT_WAIT_MS = 5_000;
95
+ /**
96
+ * How many times this side re-attempts its session-key announce, and the base delay between them.
97
+ *
98
+ * Bounded on purpose (review F5): the announce rides `onPeerConnect`, so a connection that stays up
99
+ * after one failed attempt would never produce another — encryption off for the life of the session,
100
+ * with guidance pointing at a reconnect that never comes.
101
+ */
102
+ const SESSION_KEY_ANNOUNCE_RETRIES = 4;
103
+ const SESSION_KEY_ANNOUNCE_RETRY_MS = 250;
88
104
  /**
89
105
  * WHY a session is hashing unsalted — review Finding 1, and this exists because one sentence was
90
106
  * carrying five different situations.
@@ -106,8 +122,35 @@ const UNSALTED_REASONS = {
106
122
  NO_AGREEMENT_STARTED: "no_agreement_started",
107
123
  /** We announced and they did not answer inside the bound. */
108
124
  AGREEMENT_TIMED_OUT: "agreement_timed_out",
109
- /** They answered, terminally: they have already hashed content and can never adopt. */
125
+ /**
126
+ * They answered, terminally: they have already hashed content and can never adopt.
127
+ *
128
+ * ⚠️ THIS IS ONE OF FOUR THINGS THE PEER CAN SAY, AND IT USED TO BE ALL OF THEM — 006-CRYPTO
129
+ * finding 2. The wire frame carries WHICH reason, and `SaltAgreementFrame.adoptionClosed` is a
130
+ * label rather than a boolean precisely so a caller cannot say `closed` without saying why. That
131
+ * distinction reached the log and was then dropped one call before the operator, who was told
132
+ * "they had already hashed messages" no matter which of the four it was.
133
+ */
110
134
  PEER_CLOSED_ADOPTION: "peer_closed_adoption",
135
+ /**
136
+ * They answered terminally because their side could NOT READ its own frontier — local storage
137
+ * trouble on their machine, not a conversation that started early.
138
+ *
139
+ * Kept apart from `PEER_CLOSED_ADOPTION` because the remedies are opposites: a new session fixes
140
+ * the already-hashing case and does nothing at all for this one.
141
+ */
142
+ PEER_FRONTIER_UNREADABLE: "peer_frontier_unreadable",
143
+ /** They answered terminally because the two sides could not converge — 006-CRYPTO finding 1. */
144
+ PEER_EXCHANGE_STALLED: "peer_exchange_stalled",
145
+ /**
146
+ * They closed adoption naming a reason THIS build does not recognise.
147
+ *
148
+ * Deliberately non-asserting. The peer chooses this string, so the safe rendering states what we
149
+ * know — they declined, and the label is in the log line above — and asserts nothing about why.
150
+ * Guessing here is how an operator ends up asking a counterparty to change something that was
151
+ * never the problem.
152
+ */
153
+ PEER_CLOSED_UNSPECIFIED: "peer_closed_unspecified",
111
154
  /** The session was torn down while the first send was still waiting. */
112
155
  SESSION_TORN_DOWN: "session_torn_down",
113
156
  /** This side already hashed, leafed, held or has in flight — adoption closed here. */
@@ -137,6 +180,9 @@ const UNSALTED_GUIDANCE = {
137
180
  [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
181
  [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
182
  [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.",
183
+ [UNSALTED_REASONS.PEER_FRONTIER_UNREADABLE]: "Your counterparty declined the salt because their machine could not read its own record of this conversation — their local storage is not answering. Nothing is wrong with your machine or with either build, and they did not do anything wrong. STARTING A NEW SESSION WILL NOT HELP: the next one will decline the same way until their storage is working. Ask them, out of band, to look for session.salt.adoption.refused on their side; it names the read that failed.",
184
+ [UNSALTED_REASONS.PEER_EXCHANGE_STALLED]: "Your counterparty holds a salt for this session and this side never managed to store one, so the two of you could not converge and both agreed to stop rather than trade messages about it forever. Nobody is at fault and no message was lost. Look for session.salt.persist.failed on this side — if it is there, a write to local storage failed and that is the whole cause. Start a new session; it will agree a salt normally.",
185
+ [UNSALTED_REASONS.PEER_CLOSED_UNSPECIFIED]: "Your counterparty declined the salt for a reason this build does not recognise — most likely they are on a newer build that names a case this one predates. Both sides agree there is no salt, so nothing is broken and no message was lost. The exact reason they gave is in the session.salt.adoption.closed line just above. Start a new session once you both know why.",
140
186
  [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
187
  [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
188
  [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.",
@@ -216,6 +262,15 @@ const REDIAL_COOLDOWN_MS = 15_000;
216
262
  * span about 2.5 hours.
217
263
  */
218
264
  export const SR_RESERVATION_MAX_RETRIES = 5;
265
+ /**
266
+ * DOD-M15-RELAYSLOTS-1 — how long an agent skips a relay that refused it for a relay-side fault.
267
+ *
268
+ * Ten minutes: long enough that the agent is not re-asking a relay that cannot serve it every time
269
+ * its receiver rebuilds, short enough that when someone fixes that relay the agent finds it again
270
+ * without needing its own restart. The fault is on somebody else's machine and nobody tells us when
271
+ * it is fixed, so this has to expire on its own.
272
+ */
273
+ export const RELAY_QUARANTINE_MS = 10 * 60 * 1000;
219
274
  export const CAP_INTERRUPTED_TTL_MS = Number(process.env["CELLO_CAP_INTERRUPTED_TTL_MS"]) || 2 * 60 * 60 * 1000;
220
275
  /**
221
276
  * DOD-CAP-SELF-HEAL-1 — what counts against a per-sender acceptance bound.
@@ -302,6 +357,43 @@ export const REVIVE_RESERVATION_CANDIDATES = 2;
302
357
  * the healthy direct latency and far below anything a person would notice.
303
358
  */
304
359
  export const LEAF_FETCH_GRACE_MS = 2_000;
360
+ /**
361
+ * The relay's peer id out of a circuit listen address, or `null` if the address does not name one.
362
+ *
363
+ * Returns null rather than throwing or guessing: an unreadable address means we cannot tell which
364
+ * relay this candidate was for, and every caller has a real thing to do with that answer.
365
+ */
366
+ function relayPeerIdOf(circuitAddr) {
367
+ return /\/p2p\/([^/]+)\/p2p-circuit/.exec(circuitAddr)?.[1] ?? null;
368
+ }
369
+ /**
370
+ * The Merkle leaf inputs for a seal carry: each leaf's `content_hash`, read out of the bytes its
371
+ * SENDER SIGNED (`structure1_cbor`), never out of an envelope field somebody else filled in.
372
+ *
373
+ * `null` when any leaf is unreadable — the caller must then answer "I cannot judge", never "we
374
+ * disagree". A decode failure is this daemon's limitation, not evidence against anyone.
375
+ *
376
+ * Canonical Structure 1 is
377
+ * `[protocol_version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp]`, and the
378
+ * content hash is used AS the leaf hash (RFC 6962 §2.1 "hash" leaves are taken as-is), which is the
379
+ * domain the certified root lives in.
380
+ */
381
+ function carryContentHashInputs(carry) {
382
+ const inputs = [];
383
+ for (const leaf of carry) {
384
+ let contentHash;
385
+ try {
386
+ contentHash = decode(leaf.structure1Cbor)[1];
387
+ }
388
+ catch {
389
+ return null;
390
+ }
391
+ if (!(contentHash instanceof Uint8Array) || contentHash.length !== 32)
392
+ return null;
393
+ inputs.push({ kind: "hash", data: contentHash });
394
+ }
395
+ return inputs;
396
+ }
305
397
  export class SessionNodeManager {
306
398
  #factory;
307
399
  #logger;
@@ -335,6 +427,13 @@ export class SessionNodeManager {
335
427
  * manager deliberately does not hold. Only consulted on the detached seal path — an ACTIVE session
336
428
  * always uses its own registered client.
337
429
  */
430
+ /**
431
+ * DOD-M15-RELAYSLOTS-1: `onlineToken` is REQUIRED on the dependency bag, not optional. Every
432
+ * relay client needs the directory's token or the relay refuses it a reservation slot, and the
433
+ * failure is invisible from the client side — the agent comes up, reports itself online, and is
434
+ * reachable by nobody. Making it required means a call site that forgets to pass it is a type
435
+ * error rather than an agent that quietly stops being dialable.
436
+ */
338
437
  #detachedRelayClientBuilder = null;
339
438
  setDetachedRelayClientBuilder(fn) {
340
439
  this.#detachedRelayClientBuilder = fn;
@@ -390,6 +489,8 @@ export class SessionNodeManager {
390
489
  client = this.#detachedRelayClientBuilder?.(agentName, ep.relayPeerId, [...ep.relayAddrs], {
391
490
  receiptStore: this.#relayReceiptStore ?? undefined,
392
491
  sealLeafStore: this.#sealLeafStore ?? undefined,
492
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
493
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
393
494
  });
394
495
  if (!client)
395
496
  return { error: "relay_client_unavailable" };
@@ -405,8 +506,353 @@ export class SessionNodeManager {
405
506
  // relay_session_gone` branch — which exists to tell "the relay never had it" apart from "the
406
507
  // relay swept or sealed it" — can never fire, so the operator is handed a first-message-race
407
508
  // label for a swept session.
408
- client.registerSession(sessionId, node);
409
- return { node, relayClient: client, relaySessionIdBytes: new Uint8Array(Buffer.from(sessionId, "hex")) };
509
+ /**
510
+ * DOD-M15-RELAYLEAK-1 **RE-REGISTERING A SESSION THAT IS ALREADY REGISTERED BLINDS IT.**
511
+ *
512
+ * `registerSession` REPLACES the entry's `onLeafDeliver` with `onLeafDeliver ?? (() => {})`, and
513
+ * unlike `assignment` / `recorded` it does NOT carry the existing handler forward. **This path
514
+ * passes no handler.** So a detached seal on a session that still holds a live registration
515
+ * would swap that session's inbound leaf delivery for a no-op: the counterparty's leaves keep
516
+ * arriving at the relay client and are dropped on the floor, while the operator sees a healthy
517
+ * session that has simply gone quiet.
518
+ *
519
+ * A call that finds the session already registered is therefore a PASSENGER: it uses the client
520
+ * and touches nothing. It also does not release — ownership is never inferred, only claimed.
521
+ *
522
+ * ⚠️ **This is NOT justified by a concurrency race, and an earlier version of this comment said
523
+ * it was.** Review checked: `#resolveSealTransport` and everything above the first `await` are
524
+ * synchronous, and a second caller plants/hits `#responderSealSubmitted` and returns
525
+ * `responder_seal_already_submitted` before ever reaching `submitLeaf`. Two callers cannot be in
526
+ * the client at once through the only caller, so "the second one closes the client the first is
527
+ * awaiting" **cannot happen**. The wrong reason mattered: it invites a future reader to delete
528
+ * this guard as defensive clutter once they notice the race is impossible — taking the
529
+ * handler-clobbering protection with it.
530
+ */
531
+ const claimedRegistration = !client.hasSession(sessionId);
532
+ if (claimedRegistration) {
533
+ client.registerSession(sessionId, node);
534
+ }
535
+ else {
536
+ /**
537
+ * Review MEDIUM-1 — **A PATH THAT DECLINES TO FIX A LEAK MUST SAY SO.**
538
+ *
539
+ * Reaching here means the session is registered on this client while `#activeNodes` holds no
540
+ * entry for it — and the ways that happens are all orphans: a previous `releaseDetached()`
541
+ * that threw (loud once, then silent forever after), or a revival that registered and then
542
+ * failed to hang the client on an entry. Declining to unregister is right — we cannot prove
543
+ * no live session owns it — but without this line the orphan is invisible, which is the exact
544
+ * shape this whole line exists to remove.
545
+ */
546
+ this.#logger.info("session.seal.transport.registration_shared", {
547
+ agentName,
548
+ sessionId,
549
+ impact: "this seal is using a relay registration it did not create, so it will not release it. If " +
550
+ "no live session owns that registration the client is held until process exit.",
551
+ });
552
+ }
553
+ /**
554
+ * DOD-M15-RELAYLEAK-1 — **`releaseOnDone` IS THE RELEASE SIGNAL, and its absence was the leak.**
555
+ *
556
+ * This branch (no `#activeNodes` entry) CACHES a relay client and registers a session on it, and
557
+ * nothing ever unregistered. `#detachSessionRelay` closes a client only when
558
+ * `!client.hasSessions()`, so a registration that is never removed keeps that predicate false
559
+ * **forever** — the client, its authenticated stream and its reader survive for the process
560
+ * lifetime. The LIVE branch above registers nothing here, so only this one needs releasing, and
561
+ * marking it is what lets the caller tell them apart without guessing.
562
+ */
563
+ return {
564
+ node,
565
+ relayClient: client,
566
+ relaySessionIdBytes: new Uint8Array(Buffer.from(sessionId, "hex")),
567
+ // Not `true` unconditionally: a passenger call must not release a registration it did not
568
+ // claim — see the note above `claimedRegistration`.
569
+ ...(claimedRegistration ? { releaseOnDone: true } : {}),
570
+ };
571
+ }
572
+ /**
573
+ * DOD-M15-RELAYAUTH-1: authenticate a fresh standing receiver to its reservation relay over the
574
+ * CELLO relay protocol — proof of K_local key possession, not a session. Reuses the SAME
575
+ * `#relayClients` cache `#connectSessionRelay`/`#resolveSealTransport` read from, keyed
576
+ * identically (`${agentName}::${relayPeerId}`), so a session created moments later on this same
577
+ * relay finds an already-authenticated client instead of dialing and authenticating twice.
578
+ *
579
+ * The manager holds no K_local (M12-P15's own rationale for `#detachedRelayClientBuilder`) —
580
+ * without a builder wired (a narrow startup race, or a test harness that never wires one), this
581
+ * is a no-op and the relay's own grace-window revoke is the backstop, not a defect in this path.
582
+ */
583
+ async #authenticateStandingReceiver(agentName, node, relayPeerId, heldCircuitAddr, correlationId) {
584
+ const clientKey = `${agentName}::${relayPeerId}`;
585
+ let client = this.#relayClients.get(clientKey);
586
+ if (!client) {
587
+ if (!this.#relayReceiptStore && this.#db)
588
+ this.#relayReceiptStore = new RelayReceiptStore(this.#db, this.#logger);
589
+ if (!this.#sealLeafStore && this.#db)
590
+ this.#sealLeafStore = new SessionSealLeafStore(this.#db, this.#logger);
591
+ /**
592
+ * ⚠️ SPLIT, NOT AN ANCHORED STRIP. The held address is
593
+ * `/ip4/…/tcp/…/p2p/<relay>/p2p-circuit/p2p/<self>` — the `/p2p-circuit` marker is in the
594
+ * MIDDLE, not at the end, so a `/\/p2p-circuit$/` replace matches nothing and silently
595
+ * hands the relay client a circuit address as its DIAL address. Measured, not assumed:
596
+ * that first version failed this file's own test because the client could not dial.
597
+ */
598
+ const baseRelayAddr = heldCircuitAddr.split("/p2p-circuit")[0] ?? heldCircuitAddr;
599
+ client = this.#detachedRelayClientBuilder?.(agentName, relayPeerId, [baseRelayAddr], {
600
+ receiptStore: this.#relayReceiptStore ?? undefined,
601
+ sealLeafStore: this.#sealLeafStore ?? undefined,
602
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
603
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
604
+ });
605
+ if (!client) {
606
+ /**
607
+ * Review M5: this was a `debug` line, and it is not a debug-level event.
608
+ *
609
+ * If no builder is wired we return without proving key possession, the relay revokes this
610
+ * receiver's reservation about fifteen seconds later, and the agent stops being reachable
611
+ * from behind NAT — while reporting itself perfectly healthy. Calling that "a narrow startup
612
+ * race" in a comment concedes it happens in production, and a system that is unreachable
613
+ * must not be quieter about it than a system that is merely slow.
614
+ */
615
+ this.#logger.warn("session.standing_receiver.relay_auth.no_builder", {
616
+ agentName,
617
+ relayPeerId,
618
+ correlationId,
619
+ impact: "this receiver cannot prove key possession, so the relay will revoke its reservation and " +
620
+ "the agent becomes unreachable from behind NAT — nobody can start a session with it — " +
621
+ "even though it still reports itself online.",
622
+ });
623
+ return;
624
+ }
625
+ this.#relayClients.set(clientKey, client);
626
+ }
627
+ /**
628
+ * ⚠️ `proveReservation`, NOT `connect`. Review HIGH-1: `connect()` short-circuits on the
629
+ * client's cached stream, which belongs to whichever node connected FIRST — so every
630
+ * REPLACEMENT standing receiver (the one built behind each new session) sent nothing, the relay
631
+ * never saw its transport identity, and its reservation was revoked ~15s later. The agent then
632
+ * churned reserve→revoke→rebuild for the life of the conversation, holding no usable circuit
633
+ * address, so while you were talking to one person nobody else could reach you.
634
+ *
635
+ * `proveReservation` always opens its own stream from THIS node, and marks it so the relay
636
+ * proves possession without rebinding the agent's delivery target away from the live session.
637
+ */
638
+ const proven = await client.proveReservation(node);
639
+ /**
640
+ * DOD-M15-RELAYSLOTS-1: keep the relay's refusal where the OPERATOR can reach it.
641
+ *
642
+ * The log line below is a good log line and it is not an answer to "why is my agent
643
+ * unreachable?" — nobody opens the file. The relay now refuses for reasons a person can act on
644
+ * (no token yet, too many sessions still open, this relay is misconfigured), each with its own
645
+ * next step, and every one of them is useless if it stops at a log.
646
+ */
647
+ if (!proven) {
648
+ const refusal = client.getLastAuthRefusal();
649
+ /**
650
+ * Review L2: the `else` is not symmetry for its own sake. `proveReservation` also fails for
651
+ * transport reasons, which leave `getLastAuthRefusal()` null — and without this branch the
652
+ * PREVIOUS refusal stayed in the map, so `cello_status` went on showing a cause and an
653
+ * affordance for something that was no longer what was wrong.
654
+ */
655
+ if (refusal)
656
+ this.#srRelayRefusal.set(agentName, { ...this.#withDirectoryCause(agentName, refusal), relayPeerId });
657
+ else
658
+ this.#srRelayRefusal.delete(agentName);
659
+ /**
660
+ * DOD-M15-RELAYSLOTS-1 clause 9 — **ACT on the classification, do not merely record it.**
661
+ * A relay-side fault means a different relay will work now, so quarantine this one and
662
+ * rebuild the receiver against the rest of the pool. Everything else stays put: a token
663
+ * problem reproduces on every relay, and walking the fleet would turn one client fault into
664
+ * what looks like a fleet-wide outage.
665
+ */
666
+ if (refusal?.tryAnotherRelay && !this.#shuttingDown) {
667
+ this.#quarantineRelay(agentName, relayPeerId, refusal.reason);
668
+ void this.#rebuildStandingReceiver(agentName);
669
+ }
670
+ }
671
+ else {
672
+ this.#srRelayRefusal.delete(agentName);
673
+ }
674
+ this.#logger.info("session.standing_receiver.relay_auth.result", {
675
+ agentName,
676
+ relayPeerId,
677
+ // The node that actually proved itself — without this, HIGH-1 was invisible in the logs: the
678
+ // line said `connected: true` for a receiver that had sent nothing.
679
+ nodePeerId: node.getPeerId(),
680
+ proven,
681
+ ...(proven ? {} : {
682
+ refusalReason: client.getLastAuthRefusal()?.reason ?? "no_relay_verdict",
683
+ tryAnotherRelay: client.getLastAuthRefusal()?.tryAnotherRelay ?? false,
684
+ }),
685
+ correlationId,
686
+ });
687
+ }
688
+ /**
689
+ * DOD-M15-CORROBORATE-1: witness alerts this agent's relays have reported, oldest first, capped.
690
+ *
691
+ * ⚠️ **IN MEMORY, AND A DAEMON RESTART LOSES THEM** — review F4. This comment used to say a relay
692
+ * that still holds the observation re-delivers it on the next connection. It does not:
693
+ * `drainWitnessAlerts` splices, so once an alert has been delivered the relay no longer holds it.
694
+ * Nothing here is a durable record of anything; the relay operator's own log is, and so is the
695
+ * signature on the alert, which the operator can keep. Said plainly rather than left as an implied
696
+ * guarantee.
697
+ *
698
+ * Nothing clears the list before that restart, and that is deliberate: an alert an operator can
699
+ * silence is one an attacker can wait out.
700
+ */
701
+ #witnessAlerts = new Map();
702
+ /**
703
+ * ⚠️ **THE CAP KEEPS THE FIRST, NOT THE LAST** — review F1, and the direction is the whole point.
704
+ *
705
+ * The relay's own queue drops the NEWEST when full, precisely so a flood cannot push the first
706
+ * real observation out. This list did the opposite (`slice(-20)`), which handed the mute button
707
+ * straight back one layer up: at the relay's 120-submits-per-minute limit, about ten seconds of
708
+ * fabricated alerts evicted the genuine one before any operator read it. Repeats of one event also
709
+ * collapse — see the dedupe in `recordRelayWitnessAlert` — so a flood cannot even fill it.
710
+ */
711
+ static #WITNESS_ALERT_CAP = 20;
712
+ /**
713
+ * Review F7: relays that sent a witness alert this build could not read or verify, per agent.
714
+ * Peer id → cause + count. NO session and NO party: it reports that our witness layer is not
715
+ * working, never anything about a participant.
716
+ */
717
+ #witnessUnreadable = new Map();
718
+ /**
719
+ * Agents whose alert list hit the cap — fallback-finder LOW 2. Without this the inbox renders a
720
+ * full list that looks complete, and "twenty alerts" is indistinguishable from "twenty of some
721
+ * larger number". A marker the operator can see costs one boolean.
722
+ */
723
+ #witnessTruncated = new Set();
724
+ /**
725
+ * Record what one relay says it saw on one of this agent's sessions, for the operator to read.
726
+ *
727
+ * ⚠️ **IT DOES NOT FREEZE THE SESSION, AND THAT IS THE DESIGN.** A client freezing on its OWN
728
+ * verification is safe: it limits only what that client trusts. Freezing on a REMOTE party's
729
+ * say-so hands any single relay the power to end any conversation it carries, and to write an
730
+ * accusatory record about a counterparty who did nothing. The identity freeze stays where it is —
731
+ * on this daemon's own check of an inbound frame — and this surfaces a second, independent
732
+ * observation next to it. One witness reports; it does not rule.
733
+ */
734
+ recordRelayWitnessAlert(agentName, alert) {
735
+ const list = this.#witnessAlerts.get(agentName) ?? [];
736
+ /**
737
+ * ONE ROW PER (WITNESS, SESSION) — review F1's second half. A relay reports every refused
738
+ * submission, and a determined submitter can produce a great many; twenty rows saying the same
739
+ * thing is not twenty facts, it is one fact and nineteen ways to push another one off the list.
740
+ * The repeat updates the count and the last-seen time and leaves the row where it is.
741
+ */
742
+ /**
743
+ * Keyed on the relay's PEER ID, not on `relayId` — fallback-finder LOW 1. `relayId` is absent
744
+ * for any relay that could not sign, so two DIFFERENT such relays reporting on one session
745
+ * collapsed into a single row and the operator read one witness where there were two. The peer
746
+ * id is the transport identity this client is actually talking to and is always known.
747
+ */
748
+ const key = `${alert.witnessPeerId}::${alert.sessionIdHex}`;
749
+ const existing = list.find((n) => n.key === key);
750
+ if (existing) {
751
+ existing.occurrences += 1;
752
+ existing.lastObservedAt = alert.observedAt;
753
+ // A later repeat that IS verifiable upgrades the row: the operator should end up holding the
754
+ // strongest form of the claim they were sent, never the weakest one that arrived first.
755
+ existing.alert = alert.verifiable ? alert : existing.alert;
756
+ // `firstObservedAt` is deliberately untouched — see its note on the type.
757
+ }
758
+ else if (list.length >= SessionNodeManager.#WITNESS_ALERT_CAP) {
759
+ // Keep the first. See the cap's own note for why this direction is load-bearing.
760
+ this.#witnessTruncated.add(agentName);
761
+ this.#logger.warn("session.witness.alert.list_full", {
762
+ agentName, held: list.length,
763
+ impact: "this alert was not recorded; the earlier ones are kept and still shown, and the " +
764
+ "inbox now says the list is incomplete rather than looking whole",
765
+ });
766
+ return;
767
+ }
768
+ else {
769
+ list.push({ key, alert, occurrences: 1, firstObservedAt: alert.observedAt, lastObservedAt: alert.observedAt });
770
+ }
771
+ this.#witnessAlerts.set(agentName, list);
772
+ this.#logger.error("session.witness.alert.recorded", {
773
+ agentName,
774
+ sessionId: alert.sessionIdHex,
775
+ relayId: alert.relayId ?? "(unnamed)",
776
+ submitterIsCounterparty: alert.submitterIsCounterparty,
777
+ verifiable: alert.verifiable,
778
+ impact: "surfaced to the operator on the next cello_inbox; the session is NOT frozen by it",
779
+ });
780
+ }
781
+ /** The witness alerts an agent has been told about, oldest first, one row per witness+session. */
782
+ getWitnessAlerts(agentName) {
783
+ return this.#witnessAlerts.get(agentName) ?? [];
784
+ }
785
+ /** Whether this agent's alert list hit its cap, so the inbox can say the list is incomplete. */
786
+ witnessAlertsTruncated(agentName) {
787
+ return this.#witnessTruncated.has(agentName);
788
+ }
789
+ /**
790
+ * Review F7: a relay sent a witness alert this build could not read or could not verify.
791
+ *
792
+ * Recorded so a version skew that silently kills the witness layer is visible to the operator
793
+ * instead of living only in a log file. Carries no session and no party by construction.
794
+ */
795
+ recordRelayWitnessUnreadable(agentName, relayPeerId, why) {
796
+ const byRelay = this.#witnessUnreadable.get(agentName) ?? new Map();
797
+ const prior = byRelay.get(relayPeerId);
798
+ byRelay.set(relayPeerId, { why, count: (prior?.count ?? 0) + 1 });
799
+ this.#witnessUnreadable.set(agentName, byRelay);
800
+ this.#logger.error("session.witness.unreadable.recorded", {
801
+ agentName, relayPeerId, why,
802
+ impact: "this agent's witness layer is not working against that relay — no observation it " +
803
+ "sends can be read, and nothing has been concluded about any participant",
804
+ });
805
+ }
806
+ /** Relays whose witness alerts this build could not read, for the agent's inbox. */
807
+ getWitnessUnreadable(agentName) {
808
+ return [...(this.#witnessUnreadable.get(agentName) ?? new Map()).entries()]
809
+ .map(([relayPeerId, v]) => ({ relayPeerId, why: v.why, count: v.count }));
810
+ }
811
+ /**
812
+ * DOD-M15-RELAYSLOTS-1: the last relay refusal per agent, with the advice that goes with it.
813
+ * Written where the refusal is actually known; read by whatever tells the operator.
814
+ */
815
+ #srRelayRefusal = new Map();
816
+ /**
817
+ * Why this agent's standing receiver could not hold a reservation, in words the person running it
818
+ * can act on — or null when the last attempt succeeded or none has been made.
819
+ *
820
+ * This is the surface DoD clause 7 is about: the assertion that matters is what the CLIENT can
821
+ * show someone, not what the relay wrote in its own log.
822
+ */
823
+ getStandingReceiverRefusal(agentName) {
824
+ return this.#srRelayRefusal.get(agentName) ?? null;
825
+ }
826
+ /**
827
+ * DOD-M15-RELAYSLOTS-1 review M1 — **replace the relay's guess with the directory's fact.**
828
+ *
829
+ * The relay can only say "no token was presented"; the DIRECTORY knows why there was none, and
830
+ * the two most useful answers point somewhere the relay's own advice does not. Left alone,
831
+ * `online_token_required` tells the operator to check that their agent is reaching a directory —
832
+ * which, in the `not_registered_here` case, it plainly is.
833
+ */
834
+ #withDirectoryCause(agentName, refusal) {
835
+ if (refusal.reason !== "online_token_required")
836
+ return refusal;
837
+ const absent = this.#directoryOnlineTokenAbsent.get(agentName);
838
+ if (absent === "not_registered_here") {
839
+ return {
840
+ ...refusal,
841
+ advice: "The directory this agent is connected to holds no profile for its key, so it " +
842
+ "issued no token and no relay will grant a reservation. The directory connection itself " +
843
+ "is fine — either this agent registered against a different sovereign node and its " +
844
+ "profile has not replicated here yet, or it is not registered at all.",
845
+ };
846
+ }
847
+ if (absent === "issue_failed") {
848
+ return {
849
+ ...refusal,
850
+ advice: "The directory could not issue this agent a token — its own lookup or signing " +
851
+ "failed. That is a fault on the directory, not on this agent or on any relay; it usually " +
852
+ "clears on the next connection.",
853
+ };
854
+ }
855
+ return refusal;
410
856
  }
411
857
  // DOD-LOOP-1: the standing receiver is PER-AGENT, not per-daemon. A daemon hosting two agents
412
858
  // (the loopback case) needs each agent to have its OWN inbound receiver node — otherwise the
@@ -645,6 +1091,89 @@ export class SessionNodeManager {
645
1091
  * `alreadyRepairedAgainstPeerHalf`.
646
1092
  */
647
1093
  #saltRepairedAgainst = new Map();
1094
+ /**
1095
+ * THE MIRROR OF THE ABOVE — the peer FINGERPRINT we last answered with our half, hex.
1096
+ *
1097
+ * 006-CRYPTO finding 1. `#saltRepairedAgainst` terminates the salt-HOLDER's direction only. A side
1098
+ * holding no salt answered every fingerprint with its contribution, and a latched holder answers
1099
+ * every contribution with its fingerprint — so after one failed persist plus a reconnect, two
1100
+ * healthy daemons repair at each other for the life of the session, one new stream and one INFO
1101
+ * line each per round trip. Keyed on the peer's fingerprint BYTES for the same reason the other
1102
+ * map is keyed on its half: a genuinely NEW fingerprint is new information and must still be
1103
+ * answered; only an identical re-offer is the loop.
1104
+ */
1105
+ #saltRepairedAgainstFingerprint = new Map();
1106
+ /**
1107
+ * THE LABEL THE PEER GAVE when it closed adoption — 006-CRYPTO finding 2.
1108
+ *
1109
+ * The wire carries WHY, `session-salt-agreement.ts` makes it a union so a caller cannot close
1110
+ * without saying why, and the agreement's `detail` puts it in the log. It was going no further:
1111
+ * `#settleSaltPending(..., "closed")` recorded only that it was closed, so every one of the four
1112
+ * reasons arrived at the operator as "they had already hashed messages".
1113
+ *
1114
+ * Stored raw and rendered through `#peerClosedReason`, which maps anything outside the known set
1115
+ * to a non-asserting reason — the peer chooses these bytes.
1116
+ */
1117
+ #saltPeerClosedLabel = new Map();
1118
+ /**
1119
+ * THIS SESSION'S THROWAWAY KEYPAIR — `DOD-M15-KEYAGREE-1`, the lifecycle half (006-CRYPTO).
1120
+ *
1121
+ * **A `Map`, never a field on the session row, and that is the whole point.** The secret is held
1122
+ * in memory and nowhere else: not in SQLite, not in a backup, not in an export. Forward secrecy is
1123
+ * not a property of minting a fresh key — it is a property of the old one being GONE — so anything
1124
+ * that made this durable would void it permanently and silently.
1125
+ *
1126
+ * Minted ONCE per session, at the moment the session becomes active, and destroyed at every site
1127
+ * that drops the `#activeNodes` entry — see `#destroySessionEphemeralFor`, which explains why it
1128
+ * is keyed to the entry rather than to the cache eviction. A revived session therefore mints a
1129
+ * FRESH one and re-keys, which is Decisions Carried #5; that is only true because the interrupt
1130
+ * path destroys, and an earlier version of this comment asserted it while the interrupt path
1131
+ * silently kept the old key for hours.
1132
+ *
1133
+ * ⚠️ NOTHING SENDS THE PUBLIC HALF YET. The exchange, the signature over it, and encrypting
1134
+ * content with the agreed secret are `007-CRYPTO`, and they are one wire format that ships
1135
+ * together. Until that lands, this keypair is minted, held and destroyed correctly and no message
1136
+ * is encrypted with it — which `#contentEncryptionStatus` states on the session itself rather than
1137
+ * leaving a reader to assume.
1138
+ */
1139
+ #sessionEphemerals = new Map();
1140
+ /**
1141
+ * THE AGREED CONTENT KEY — `DOD-M15-EPHEMERAL-AUTH-1` (007-CRYPTO).
1142
+ *
1143
+ * Present only once the peer's SIGNED ephemeral has been verified against the counterparty
1144
+ * identity this session is with. Absent means every message body on this session goes out under
1145
+ * the transport's protection alone, and `#contentEncryptionState` says which of the reasons that
1146
+ * is — never silence.
1147
+ *
1148
+ * In memory only, and destroyed with the ephemeral it came from. It is the same secret one step
1149
+ * on, so persisting it would void forward secrecy exactly as persisting the ephemeral would.
1150
+ */
1151
+ #sessionContentKeys = new Map();
1152
+ /**
1153
+ * WHICH peer ephemeral produced the key we hold, hex — 007-CRYPTO, review F1.
1154
+ *
1155
+ * The idempotence guard keys on THIS rather than on "a key exists", because a re-keying peer sends
1156
+ * a DIFFERENT half and must be adopted, while the same half re-announced on every connect must
1157
+ * not churn. Keying on presence meant the side that never restarted kept a stale key and every
1158
+ * message failed to decrypt — reported to its operator as possible tampering.
1159
+ */
1160
+ #sessionContentKeyPeerHalf = new Map();
1161
+ /**
1162
+ * WHY this session has no content key, when it has none. A closed reason, never a free string.
1163
+ */
1164
+ #contentEncryptionReasons = new Map();
1165
+ /**
1166
+ * Resolve an agent's long-term identity signer.
1167
+ *
1168
+ * Injected after construction, like `setParkedDrainHook`, because the daemon builds its agent key
1169
+ * providers after this manager exists. Absent resolver, or an agent it does not know, means this
1170
+ * side cannot sign its ephemeral — reported as `NO_LOCAL_IDENTITY` rather than quietly skipping
1171
+ * the exchange, because "we could not" and "they would not" send the operator to opposite
1172
+ * machines.
1173
+ */
1174
+ #keyProviderResolver = null;
1175
+ /** Test-only observer of decoded inbound content frames — see `observeInboundContentFramesForTest`. */
1176
+ #inboundFrameObserver = null;
648
1177
  /**
649
1178
  * ─── B2b-2 state: what the SEND path needs that the row cannot answer ─────────────────────────
650
1179
  *
@@ -681,6 +1210,60 @@ export class SessionNodeManager {
681
1210
  */
682
1211
  #saltLastOutcome = new Map();
683
1212
  #hashedWithoutSalt = new Map();
1213
+ /**
1214
+ * `#hashedWithSalt` — how many content hashes this session has computed UNDER its salt and not yet
1215
+ * landed anywhere a count can see (`DOD-M15-SALTSPLIT-1`, review HIGH-2).
1216
+ *
1217
+ * The mirror of `#hashedWithoutSalt`, and it exists for the same window: a hash is computed, then a
1218
+ * relay round trip happens, and only afterwards does the message appear as a leaf, a hold or an
1219
+ * awaiting-ack entry. In between, every count reads zero.
1220
+ *
1221
+ * It is read by `#discardUnspentSalt` alone. "Unspent" must mean *nothing has been hashed under
1222
+ * it*, and without this the answer is *nothing has FINISHED being hashed under it* — which is the
1223
+ * question nobody asked, answered destructively.
1224
+ *
1225
+ * Never decremented on success: a salted hash that reaches the wire is spent forever, and unlike
1226
+ * the unsalted counter there is no `abandonUnsaltedHash` equivalent to undo. It is cleared only
1227
+ * with the rest of the session's caches. **For a discard decision, erring toward "spent" is the
1228
+ * safe direction** — a salt kept is recoverable, a salt erased is not.
1229
+ */
1230
+ #hashedWithSalt = new Map();
1231
+ /**
1232
+ * `#saltSuspended` — the peer has told us it can never hold a salt, so ours must not be USED. The
1233
+ * bytes stay on disk (`DOD-M15-SALTSPLIT-1`, the other lane's authorization argument).
1234
+ *
1235
+ * ⚠️ THIS REPLACED AN IMMEDIATE, IRREVERSIBLE ERASE, AND THE REFRAMING IS THE WHOLE POINT.
1236
+ *
1237
+ * I defended the erase as a compatibility question — a legacy peer might send the misleading frame,
1238
+ * we are pre-launch, do not carry weight for a state nobody is in. All true, and it does not reach
1239
+ * the question. **It is an AUTHORIZATION question:** the receiver performed an irreversible
1240
+ * destruction of durable key material on a peer's bare assertion with nothing to check it against.
1241
+ * Re-derived against an empty database — *would I let one side erase the other's key material on an
1242
+ * unauthenticated claim carrying no evidence?* No. My own empty-database rule argued FOR a guard,
1243
+ * not against one.
1244
+ *
1245
+ * And my own trigger was the proof I walked past: `frontier_unreadable` is not a legacy peer, it is
1246
+ * a **healthy current peer having one bad second**. Fixing the producer made our side stop emitting
1247
+ * it wrongly and left the receiver built to obey it — *one side of that exchange correct by
1248
+ * construction, the other still correct by luck.*
1249
+ *
1250
+ * A salt that cannot be used is inert. The destruction is what turned a transient disagreement into
1251
+ * a permanent one, so **nothing irreversible hangs on the claim any more** and proving the claim
1252
+ * stops being load-bearing.
1253
+ *
1254
+ * ⚠️ IN MEMORY ON PURPOSE, AND THE ERASE IS DEFERRED RATHER THAN CANCELLED. A durable mark needs a
1255
+ * column, and this milestone has lost data twice in the rebuild DDL. In-memory alone would split
1256
+ * the transcript at the next restart — unsalted now, salted after a reboot — so the salt IS erased,
1257
+ * at the first unsalted hash, which is the moment erasing becomes both harmless (nothing was hashed
1258
+ * under it) and REQUIRED (keeping it would re-salt after a restart). Before that moment a corrected
1259
+ * announce carrying a matching fingerprint un-suspends and the session recovers fully salted, which
1260
+ * erasure makes impossible even in principle: the far side cannot re-derive without both halves.
1261
+ *
1262
+ * A restart before either outcome loses the mark, we are salted again, the peer refuses one message,
1263
+ * and the announce re-runs and re-suspends. **One refused message, then convergence** — against a
1264
+ * dead session.
1265
+ */
1266
+ #saltSuspended = new Set();
684
1267
  #unsaltedAnnounced = new Set();
685
1268
  // DOD-M12B-ACK-1: pending linger-resets for inbound content streams the peer has not closed.
686
1269
  // Held so shutdown can drop them rather than leave timers pointing at a torn-down node.
@@ -726,6 +1309,15 @@ export class SessionNodeManager {
726
1309
  // of being appended out of order. Once the missing in-between sequence(s) land (recovered from the
727
1310
  // relay mailbox), #releaseHeld drains the held entries in canonical order. content is plaintext in
728
1311
  // memory only — evicted on teardown, same as #receivedContent.
1312
+ /**
1313
+ * DOD-M15-SEALWIRE-1 bullet 5 — `authorship` rides the held entry.
1314
+ *
1315
+ * A SENT message that lands ahead of our tree tail is held here and its transcript row is written
1316
+ * later, on release. The hold happens AFTER the submit, so it was signed exactly like an unheld
1317
+ * one — without carrying the proof through, a message that happened to queue behind a gap became
1318
+ * permanently less provable than the identical message that did not, for a reason with nothing to
1319
+ * do with authorship.
1320
+ */
729
1321
  #heldContent = new Map();
730
1322
  // DOD-MSG-4: the relay's high-water canonical sequence for this session — the largest sequence the
731
1323
  // relay has witnessed (max over leaf_deliver). Keyed #k(agent,session). EXPOSED for the next
@@ -777,6 +1369,25 @@ export class SessionNodeManager {
777
1369
  * signature. That is unavoidable: deciding whether bytes are a document frame requires the bytes.
778
1370
  * The router it calls never logs them.
779
1371
  */
1372
+ /**
1373
+ * ⚠️ THE RETURN TYPE USED TO DECLARE `ok?: boolean; reason?: string`, AND THE PRODUCER CANNOT
1374
+ * SUPPLY EITHER. Narrowed so reading them is a compile error rather than a silent `undefined`.
1375
+ *
1376
+ * The implementation is `DocumentFrameRouter.routeSync`, whose own return type
1377
+ * (`FrameClassification`) is exactly `{consumed:false} | {consumed:true; kind}` — it has no such
1378
+ * fields at all. The wider shape here was a promise only this declaration made, and it was
1379
+ * assignable precisely because the extra members were optional.
1380
+ *
1381
+ * It is not an oversight in the router: `routeSync` dispatches with `void this.#enqueue(...)`, so
1382
+ * when it returns, the frame has been classified and queued and **no verdict exists yet**. A
1383
+ * synchronous caller cannot be told an asynchronous outcome.
1384
+ *
1385
+ * **The cost of the lie was a wrong lead.** `j-stale-session`'s investigation read those fields'
1386
+ * absence from every log line as "the router returned neither" and filed it as the next thread to
1387
+ * pull. There was no thread: a JSON logger omits `undefined`, so a field that can never be set is
1388
+ * indistinguishable from one that was set to nothing. The verdict lives on
1389
+ * `document.frame.refused`, joined by `correlationId`.
1390
+ */
780
1391
  #onDocumentFrame = null;
781
1392
  /**
782
1393
  * DOD-DOC-SCREEN-CLASSIFY-1: the classify-only half of the hook above — is this a document
@@ -1078,7 +1689,11 @@ export class SessionNodeManager {
1078
1689
  reason: result.reason,
1079
1690
  cause: result.cause,
1080
1691
  });
1081
- return { outcome: "refused", cause: result.cause ?? result.reason };
1692
+ return {
1693
+ outcome: "refused",
1694
+ cause: result.cause ?? result.reason,
1695
+ ...(result.retryAfterMs !== undefined ? { retryAfterMs: result.retryAfterMs } : {}),
1696
+ };
1082
1697
  }
1083
1698
  return { outcome: "parked" };
1084
1699
  }
@@ -1338,6 +1953,52 @@ export class SessionNodeManager {
1338
1953
  -- DOD-LOOP-1: composite key so each agent's end has its own append-ordered tree.
1339
1954
  PRIMARY KEY (agent_id, session_id, leaf_index)
1340
1955
  )
1956
+ `);
1957
+ // DOD-M15-INCLUSION-1: the leaf set the DIRECTORY certified — a different tree from the one
1958
+ // above, and the distinction is the whole reason this table exists.
1959
+ //
1960
+ // `session_tree_leaves` holds this agent's CONTENT leaves. The certified root covers every leaf
1961
+ // the relay ordered, CONTROL leaves included, and nothing appends a ctrl leaf to the local tree
1962
+ // (`submitSealLeaf` computes its root without mutating it). So an audit path built from
1963
+ // `session_tree_leaves` lands on a root no certificate names — it proves this machine agrees
1964
+ // with itself, which is worth nothing to the third party a proof is FOR.
1965
+ //
1966
+ // Rows land only after the Merkle root over them reproduces the FROST-signed `sealed_root`
1967
+ // (`certifiedLeafSetFrom`), so what is stored here is the consortium's leaf set and not the
1968
+ // directory's word for it. Written once at seal time; read only by the inclusion-proof surface,
1969
+ // ORDER BY leaf_index.
1970
+ this.#db.exec(`
1971
+ CREATE TABLE IF NOT EXISTS session_certified_leaves (
1972
+ agent_id TEXT NOT NULL,
1973
+ session_id TEXT NOT NULL,
1974
+ leaf_index INTEGER NOT NULL,
1975
+ content_hash_hex TEXT NOT NULL,
1976
+ recorded_at INTEGER NOT NULL,
1977
+ -- DOD-LOOP-1: composite key so each agent's end of a loopback session keeps its own set.
1978
+ PRIMARY KEY (agent_id, session_id, leaf_index)
1979
+ )
1980
+ `);
1981
+ // WHY a session has no certified leaf set — fallback-finder finding 1, and the reason it is a
1982
+ // TABLE rather than a log line.
1983
+ //
1984
+ // `getCertifiedLeafSet` returns null for four different situations: no seal frame ever carried
1985
+ // the leaves, the directory shipped a set that does not reproduce the root it signed, a leaf was
1986
+ // malformed, or the write failed. The worst of those — a directory contradicting its own FROST
1987
+ // signature — is the strongest misbehaviour signal this client can produce, and it was going to
1988
+ // one ERROR line while the operator was told the most benign of the four: "normal for the party
1989
+ // that was absent at seal time." A detection whose only consumer is a log is not a control.
1990
+ //
1991
+ // One row per session, replaced on every attempt, so the state is the LAST thing that happened
1992
+ // rather than a history. Read only by the inclusion-proof surface, to name the cause.
1993
+ this.#db.exec(`
1994
+ CREATE TABLE IF NOT EXISTS session_certified_leaves_state (
1995
+ agent_id TEXT NOT NULL,
1996
+ session_id TEXT NOT NULL,
1997
+ state TEXT NOT NULL,
1998
+ detail TEXT,
1999
+ recorded_at INTEGER NOT NULL,
2000
+ PRIMARY KEY (agent_id, session_id)
2001
+ )
1341
2002
  `);
1342
2003
  // DOD-M12B-STRAND-1 — content we RECEIVED and VERIFIED but cannot append yet.
1343
2004
  //
@@ -1422,9 +2083,22 @@ export class SessionNodeManager {
1422
2083
  -- transcript is only ever read by its owner, and worthless the moment it is shown to anyone
1423
2084
  -- else — which is the whole point of a notarized record.
1424
2085
  --
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.
2086
+ -- sender_sig holds one of TWO things, and which one is told by direction:
2087
+ -- RECEIVED row -> the Structure-2 signature, stored ONLY after the receiver verified it
2088
+ -- against the pubkey inside the sender's own signed bytes
2089
+ -- (#recordFrameOrdering). Verified, never claimed.
2090
+ -- SENT row -> OUR OWN signature over the Structure-1 bytes we put on the wire, taken
2091
+ -- from the submit result. Produced, not verified — there was no
2092
+ -- counterparty in the act, so it must NEVER be labelled verified_signature.
2093
+ --
2094
+ -- ⚠️ self_authored COVERS TWO PROVENANCES, and sender_sig IS NOT NULL is the discriminator.
2095
+ -- Named here because it is the same shape this column exists to prevent, one level up: a
2096
+ -- provable sent row and an unprovable one share a label, so a reader keying on attribution
2097
+ -- alone cannot tell them apart. An unprovable sent row is legitimate — an UNWITNESSED send
2098
+ -- never put a Structure 1 on the wire, so there is nothing signed to store — but the reader
2099
+ -- has to be told where the distinction lives, or it will be rediscovered as a bug.
2100
+ -- self_authored + sender_sig NOT NULL -> we wrote it and can prove we did
2101
+ -- self_authored + sender_sig NULL -> we wrote it; the relay never witnessed it
1428
2102
  --
1429
2103
  -- attribution is NOT NULL ON PURPOSE, and it is the load-bearing column. There is a soft
1430
2104
  -- path — session.content.ordering.decode_failed falls back to hash-dedup — that ingests a
@@ -1663,6 +2337,59 @@ export class SessionNodeManager {
1663
2337
  }
1664
2338
  return this.#db;
1665
2339
  }
2340
+ /**
2341
+ * DOD-M15-RELAYONLY-1: is the settings store readable RIGHT NOW?
2342
+ *
2343
+ * ⚠️ Exists because `getSetting` cannot answer it. That method returns `null` for BOTH "the key is
2344
+ * unset" and "there is no database", and a security setting must tell those apart: unset-means-off
2345
+ * is correct, db-gone-means-off publishes the operator's real address during the shutdown window.
2346
+ * `getDb()` cannot stand in either — it THROWS when there is no database, which on a catch-less
2347
+ * ceremony path is worse than the wrong answer.
2348
+ */
2349
+ hasDatabase() {
2350
+ return this.#db !== null;
2351
+ }
2352
+ /**
2353
+ * DOD-M15-RELAYONLY-1: build a transport node for THIS AGENT, with its privacy posture applied.
2354
+ *
2355
+ * ⚠️ THE CHOKE POINT FOR NODE CREATION, and it exists for the same reason as the one around
2356
+ * `getStandingReceiverInfo`. Five call sites construct nodes; passing `relayOnly` at each would be
2357
+ * a hand-kept list, and the SIXTH — added next month by someone who has never read this line —
2358
+ * would build a node that hole-punches its way to a direct connection for an operator who asked
2359
+ * never to be directly reachable. Here, a new caller inherits the posture instead of being told.
2360
+ *
2361
+ * `unknown` counts as ON, matching the publish and dial halves: a node that declines to hole-punch
2362
+ * is reachable over the relay, while a disclosed address cannot be recalled.
2363
+ */
2364
+ // ⚠️ NOT `async`. This wrapper sits in the standing-receiver startup path, and making it async
2365
+ // added ONE extra microtask hop before the receiver was installed in `#standingReceivers` — which
2366
+ // was enough for `createSessionNode` to run first and answer `standing_receiver_unavailable`. Two
2367
+ // tests in `msg-021-session-seed` caught it. Returning the factory's promise directly keeps the
2368
+ // await count identical to the call it replaced. **This is a real fragility in the install path,
2369
+ // not a quirk of the tests:** anything that adds a tick here re-breaks it.
2370
+ #createAgentNode(agentName, config) {
2371
+ // ⚠️ THE POSTURE READ MUST NEVER COST US A NODE. This sits in the standing-receiver startup
2372
+ // path, whose caller treats a throw as "no receiver" and leaves the agent deaf to all inbound —
2373
+ // surfacing to the operator as `standing_receiver_unavailable`, which names the transport for a
2374
+ // fault in a settings lookup. `relayOnlyState` already absorbs a throwing GETTER; this absorbs
2375
+ // everything else, including a resolution failure for an agent row that is not there yet.
2376
+ //
2377
+ // The fallback is ON, not off: an agent whose posture we cannot read gets the private-but-
2378
+ // reachable node, because a node that declines to hole-punch still works over the relay while a
2379
+ // disclosed address cannot be recalled.
2380
+ let relayOnly = true;
2381
+ try {
2382
+ relayOnly = relayOnlyState((key) => this.getSetting(agentName, key), this.#db !== null) !== "off";
2383
+ }
2384
+ catch (err) {
2385
+ this.#logger.warn("settings.relay_only.unreadable", {
2386
+ agentName,
2387
+ reason: err instanceof Error ? err.message : String(err),
2388
+ impact: "could not read this agent's relay-only posture, so the node is built WITHOUT the hole-punch",
2389
+ });
2390
+ }
2391
+ return this.#factory.createNode({ ...config, relayOnly });
2392
+ }
1666
2393
  /**
1667
2394
  * RELAYSIG-1: the durably-stored, signature-verified relay ordering-record receipts for an agent
1668
2395
  * (optionally a single session). Empty when no receipts have been recorded yet. Read-only.
@@ -1735,6 +2462,35 @@ export class SessionNodeManager {
1735
2462
  const contiguousFromOne = sequences.every((n, i) => n === i + 1);
1736
2463
  const ctrlSenders = new Set(carry.filter((l) => l.leafKind === LEAF_KIND_CTRL).map((l) => l.senderPubkeyHex));
1737
2464
  const selfEvidentlyComplete = contiguousFromOne && ctrlSenders.size === 2;
2465
+ /**
2466
+ * 🚨 THE CERTIFICATE MAY COVER EXACTLY WHAT THIS SIDE HOLDS — ASK THAT FIRST.
2467
+ *
2468
+ * `DOD-M15-UNILATERAL-1`. The completeness predicate below describes a BILATERAL leaf set: two
2469
+ * SEAL ctrl leaves, from two distinct senders. **A solo seal can never satisfy it**, because the
2470
+ * counterparty is gone and never posts one — that is the entire premise. So on the solo path
2471
+ * this returned `cannot_judge` every time, `session-ceremony.ts` refuses to co-sign on anything
2472
+ * that is not `match`, and **the sealing party refused to co-sign its own unilateral seal.** The
2473
+ * FROST ceremony never reached threshold, the directory never completed, and the close came back
2474
+ * `seal_unilateral_timeout` — the label that names our own wait. Measured against the real
2475
+ * binaries: `j-unilateral` failed on exactly this, with the directory having already verified the
2476
+ * chain and recorded the counterparty ABSENT.
2477
+ *
2478
+ * Completeness was only ever needed to tell TWO KINDS OF DISAGREEMENT apart — "the roots differ
2479
+ * because my carry is behind" (cannot judge) from "the roots differ because the directory
2480
+ * certified something else" (mismatch). It answers nothing when the roots AGREE: a certificate
2481
+ * whose root and leaf count are exactly what this daemon holds is, by construction, over this
2482
+ * daemon's own leaves. Nothing is taken on trust — both values are recomputed here from the
2483
+ * carry, and an adversary who could satisfy them would have to have produced this leaf set.
2484
+ *
2485
+ * Deliberately BOTH values. A count that disagreed while the root matched would be a certificate
2486
+ * contradicting itself, and this is not the place to wave that through.
2487
+ */
2488
+ const carryInputs = carryContentHashInputs(carry);
2489
+ if (carryInputs !== null &&
2490
+ carry.length === certifiedLeafCount &&
2491
+ Buffer.compare(Buffer.from(merkleRoot(buildMerkleTree(carryInputs))), Buffer.from(certifiedRoot)) === 0) {
2492
+ return { verdict: "match" };
2493
+ }
1738
2494
  if (!selfEvidentlyComplete) {
1739
2495
  return {
1740
2496
  verdict: "cannot_judge",
@@ -1752,26 +2508,62 @@ export class SessionNodeManager {
1752
2508
  detail: `leaf_count_disagrees: this daemon holds a provably complete ${carry.length}-leaf set, the certificate claims ${certifiedLeafCount}`,
1753
2509
  };
1754
2510
  }
1755
- const inputs = [];
2511
+ if (carryInputs === null) {
2512
+ // A leaf this daemon cannot decode is a leaf it cannot judge. Never an accusation.
2513
+ return { verdict: "cannot_judge", reason: "structure1_content_hash_unreadable" };
2514
+ }
2515
+ const ownRoot = merkleRoot(buildMerkleTree(carryInputs));
2516
+ const ownRootHex = Buffer.from(ownRoot).toString("hex");
2517
+ return Buffer.compare(Buffer.from(ownRoot), Buffer.from(certifiedRoot)) === 0
2518
+ ? { verdict: "match" }
2519
+ : { verdict: "mismatch", ownRootHex, detail: "root_disagrees: same leaf count, different leaves or different order" };
2520
+ }
2521
+ /**
2522
+ * WHERE THE MUTUALLY-SIGNED PREFIX ENDS, DERIVED FROM THIS DAEMON'S OWN LEAVES —
2523
+ * `DOD-M15-UNILATERAL-1`, review F2.
2524
+ *
2525
+ * ⚠️ **THE FIRST VERSION COMPUTED THIS FROM THE CERTIFICATE'S OWN PARTICIPANT LIST, AND CALLED
2526
+ * THAT "recomputed, cannot be steered".** It could be steered. On the SOLO path the certificate's
2527
+ * TBS binds no legibility at all, and the client verifies only the *live* party's frontier — so
2528
+ * the absent party's `content_frontier_seq` and every `last_authored_seq` arrived unchecked. One
2529
+ * directory node could publish the absent party's frontier as 3 and the receipt would say
2530
+ * "mutually signed through 3" over a transcript that party never signed for. That is the precise
2531
+ * conflation this field exists to prevent, reintroduced by the field itself.
2532
+ *
2533
+ * The carry answers it without trusting anybody. This daemon holds the counterparty's own leaves,
2534
+ * each carrying, inside the bytes THEY signed, both the sequence they authored and the
2535
+ * `last_seen_seq` they acknowledged. So a party's commitment reaches
2536
+ * `max(highest sequence they authored, highest sequence they acknowledged)`, and the transcript is
2537
+ * mutually signed only as far as the LEAST-committed party reaches.
2538
+ *
2539
+ * Fewer than two distinct authors ⇒ `0`: nobody countersigned anything, which is the honest floor
2540
+ * for a conversation where the other side only ever received. `null` when the carry is empty or
2541
+ * unreadable — the caller must then publish NO boundary rather than fall back to a number
2542
+ * somebody else supplied.
2543
+ */
2544
+ countersignedThroughSeqFromCarry(agentPubkeyHex, sessionIdHex) {
2545
+ const carry = this.getSealCarry(agentPubkeyHex, sessionIdHex);
2546
+ if (carry.length === 0)
2547
+ return null;
2548
+ const reach = new Map();
1756
2549
  for (const leaf of carry) {
1757
- let contentHash;
2550
+ let signedLastSeen = 0;
1758
2551
  try {
1759
- // Canonical Structure 1 is [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp].
1760
- contentHash = decode(leaf.structure1Cbor)[1];
2552
+ // Structure 1 = [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp].
2553
+ const raw = decode(leaf.structure1Cbor)[4];
2554
+ const n = typeof raw === "bigint" ? Number(raw) : raw;
2555
+ if (typeof n === "number" && Number.isFinite(n))
2556
+ signedLastSeen = n;
1761
2557
  }
1762
2558
  catch {
1763
- return { verdict: "cannot_judge", reason: "structure1_decode_failed" };
2559
+ return null; // unreadable: publish no boundary rather than a half-derived one
1764
2560
  }
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 });
2561
+ const prior = reach.get(leaf.senderPubkeyHex) ?? 0;
2562
+ reach.set(leaf.senderPubkeyHex, Math.max(prior, leaf.sequenceNumber, signedLastSeen));
1769
2563
  }
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" };
2564
+ if (reach.size < 2)
2565
+ return 0;
2566
+ return Math.min(...reach.values());
1775
2567
  }
1776
2568
  getSealCarry(agentPubkeyHex, sessionIdHex) {
1777
2569
  if (!this.#sealLeafStore && this.#db) {
@@ -1823,7 +2615,21 @@ export class SessionNodeManager {
1823
2615
  *
1824
2616
  * No plumbing needed: `direction` already carries the answer at write time.
1825
2617
  */
1826
- authorship ? "verified_signature" : direction === "sent" ? "self_authored" : "local_session_state");
2618
+ /**
2619
+ * DIRECTION FIRST — DOD-M15-SEALWIRE-1 bullet 5, sent half.
2620
+ *
2621
+ * This used to read `authorship ? "verified_signature" : …`, which was right while only
2622
+ * RECEIVED rows could carry a signature. Now a SENT row carries one too — our own, over
2623
+ * the Structure-1 bytes we put on the wire — and labelling that `verified_signature` would
2624
+ * be false in the way this column exists to prevent: **we did not verify it, we produced
2625
+ * it.** Nobody checked a counterparty's key; there was no counterparty in the act.
2626
+ *
2627
+ * So the three values keep meaning three different things:
2628
+ * `self_authored` — this agent wrote it. Now PROVABLE when a signature is stored.
2629
+ * `verified_signature` — someone else wrote it and we checked their key against it.
2630
+ * `local_session_state`— someone else wrote it and nobody checked anything.
2631
+ */
2632
+ direction === "sent" ? "self_authored" : authorship ? "verified_signature" : "local_session_state");
1827
2633
  this.#logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
1828
2634
  return true;
1829
2635
  }
@@ -2657,7 +3463,42 @@ export class SessionNodeManager {
2657
3463
  const sr = this.#standingReceivers.get(agentName);
2658
3464
  if (!sr)
2659
3465
  return null;
2660
- return { peerId: sr.node.getPeerId(), addrs: sr.node.listenAddresses() };
3466
+ // DOD-M15-RELAYONLY-1: THE CHOKE POINT. Every path that publishes this agent's session
3467
+ // addresses draws from here — `initiator_session_addrs` on the way out, and
3468
+ // `counterparty_session_addrs` when answering an offer — and this method has no other kind of
3469
+ // consumer: its whole purpose is to be advertised, as the docstring above says.
3470
+ //
3471
+ // The suppression lives HERE rather than at those call sites deliberately. Call-site gating
3472
+ // would be a hand-kept list, and a fourth publish path added later would leak the operator's IP
3473
+ // while every test stayed green. At the choke point a new caller inherits the protection
3474
+ // instead of having to be told about it.
3475
+ const endpoint = { peerId: sr.node.getPeerId(), addrs: sr.node.listenAddresses() };
3476
+ // ⚠️ TRI-STATE, not a boolean, and the third state is the one that matters. `getSetting` answers
3477
+ // `null` both for "unset" and for "there is no database", and reading the second as OFF fails
3478
+ // TOWARD DISCLOSURE: the standing receiver outlives the DB during shutdown, so an offer arriving
3479
+ // in that window would publish the operator's real addresses with relay-only switched on.
3480
+ // `relayOnlyState` also absorbs a THROW — `#requireAgentId` throws for a retired agent, and this
3481
+ // method is called from the offer ceremony inside a floating async with no catch, where the
3482
+ // throw becomes an unhandled rejection and the offer vanishes with no local log.
3483
+ // ⚠️ `!== null`, NOT `!== undefined`. The field is declared `DaemonDatabase | null` and is only
3484
+ // ever assigned on open or set to `null` on close — **it is never `undefined` at any point in
3485
+ // its lifetime**, so the first version of this line was a compile-time-constant `true` that
3486
+ // TypeScript had no reason to complain about, and the whole `"unknown"` branch was unreachable
3487
+ // dead code. The fix for the disclosure window silently did nothing, which is worse than not
3488
+ // having written it: the DoD said the window was closed and it was wide open.
3489
+ const state = relayOnlyState((key) => this.getSetting(agentName, key), this.#db !== null);
3490
+ if (state === "unknown") {
3491
+ this.#logger.warn("settings.relay_only.unreadable", {
3492
+ agentName,
3493
+ impact: "cannot tell whether relay-only is on, so ONLY this agent's relay-circuit addresses are " +
3494
+ "published — never a direct one. Publishing a real address is irreversible and a narrowed " +
3495
+ "route is not, so this errs toward reachability loss rather than disclosure",
3496
+ });
3497
+ }
3498
+ // ONE filter, not two. The `unknown` branch used to build its own filtered object inline, which
3499
+ // put a second implementation inside the very method whose design rationale is that there is
3500
+ // exactly one — and the bypass guard could not see it.
3501
+ return publishableEndpoint(endpoint, state !== "off");
2661
3502
  }
2662
3503
  /**
2663
3504
  * DOD-M15-ASSIGN-1 — name the one peer allowed to dial this agent's standing receiver, at the
@@ -2963,7 +3804,7 @@ export class SessionNodeManager {
2963
3804
  });
2964
3805
  try {
2965
3806
  seed = randomBytes(32);
2966
- node = await this.#factory.createNode({ sessionId, connectionGater: gater, nodeType: "session", transportPrivateKey: seed });
3807
+ node = await this.#createAgentNode(agentName, { sessionId, connectionGater: gater, nodeType: "session", transportPrivateKey: seed });
2967
3808
  await node.start();
2968
3809
  }
2969
3810
  catch (err) {
@@ -3040,6 +3881,9 @@ export class SessionNodeManager {
3040
3881
  correlationId,
3041
3882
  });
3042
3883
  // Add to active map (keyed by (agentName, sessionId) — DOD-LOOP-1)
3884
+ // 006-CRYPTO: the session's throwaway keypair is minted here, with the node, so "a session is
3885
+ // active" and "a session has a key" are the same moment. All THREE activation paths mint.
3886
+ this.#mintSessionEphemeral(agentName, sessionId);
3043
3887
  this.#activeNodes.set(this.#k(agentName, sessionId), {
3044
3888
  node,
3045
3889
  agentName,
@@ -3157,6 +4001,12 @@ export class SessionNodeManager {
3157
4001
  logger: this.#logger,
3158
4002
  receiptStore: this.#relayReceiptStore ?? undefined,
3159
4003
  sealLeafStore: this.#sealLeafStore ?? undefined,
4004
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
4005
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
4006
+ // DOD-M15-CORROBORATE-1: a relay's witness alert reaches the operator's inbox from here.
4007
+ // The DETACHED clients get the same callback from the builder in daemon.ts.
4008
+ onWitnessAlert: (alert) => { this.recordRelayWitnessAlert(agentName, alert); },
4009
+ onWitnessUnreadable: (relayPeerId, why) => { this.recordRelayWitnessUnreadable(agentName, relayPeerId, why); },
3160
4010
  });
3161
4011
  this.#relayClients.set(clientKey, client);
3162
4012
  }
@@ -3170,6 +4020,8 @@ export class SessionNodeManager {
3170
4020
  // 2b: remember the relay endpoint so the content-park backstop deposits to the SAME relay.
3171
4021
  entry.relayPeerId = relay.relayPeerId;
3172
4022
  entry.relayAddrs = relay.relayAddrs;
4023
+ // Review H1: the dial path needs the credential in hand, not just the endpoint.
4024
+ entry.relayAssignment = relay.assignment;
3173
4025
  // MSG-2 startup-flush: also PERSIST it, so a restart's crash-backstop flush (which runs
3174
4026
  // before the in-memory entry exists) can deposit un-acked content to the same relay.
3175
4027
  try {
@@ -3196,6 +4048,24 @@ export class SessionNodeManager {
3196
4048
  // Proactively connect so the relay has this agent's stream to deliver leaves to
3197
4049
  // (the RECEIVER must be connected before the counterparty submits). Best-effort.
3198
4050
  await client.connect(node);
4051
+ // DOD-M15-RELAYAUTH-1 review HIGH-2 — see below. Best-effort and non-blocking: a session must
4052
+ // never fail to come up because a SECOND relay could not be told about it.
4053
+ //
4054
+ // Review M1: `.catch()` is not decoration. This is an unawaited promise, so a throw it does not
4055
+ // handle is an unhandled rejection — and this file already carries a comment elsewhere about an
4056
+ // absent `await` that became a remote process kill. The method's own try/catch does not cover
4057
+ // its prologue, so the catch here is the only thing standing between a torn-down node and the
4058
+ // daemon dying.
4059
+ void this.#presentAssignmentToReservationRelay(agentName, node, relay, sessionIdHexForRelay, correlationId, entry)
4060
+ .catch((err) => {
4061
+ this.#logger.warn("session.relay.assignment.reservation_relay_failed", {
4062
+ agentName,
4063
+ sessionId: sessionIdHexForRelay.slice(0, 16),
4064
+ error: extractErrorMessage(err),
4065
+ impact: "inbound relayed dials to this node may be refused by its reservation relay; the session still works over the direct path and the park backstop",
4066
+ correlationId,
4067
+ });
4068
+ });
3199
4069
  }
3200
4070
  catch (err) {
3201
4071
  this.#logger.warn("session.relay.connect.error", {
@@ -3205,6 +4075,89 @@ export class SessionNodeManager {
3205
4075
  });
3206
4076
  }
3207
4077
  }
4078
+ /**
4079
+ * DOD-M15-RELAYAUTH-1 review HIGH-2 — **THE RELAY THAT GATES THE DIAL IS NOT ALWAYS THE RELAY
4080
+ * THAT HOLDS THE ASSIGNMENT, AND THE GATE DENIES WHEN THEY DIFFER.**
4081
+ *
4082
+ * Two relays are in play for one session, chosen by unrelated rules:
4083
+ * - the WITNESS relay, `assignment.relay_endpoint`, picked by the directory. Both parties
4084
+ * present `client_record_assignment` to it and to nowhere else.
4085
+ * - the RESERVATION relay, whichever one this node's circuit address is held on — the first
4086
+ * candidate that granted when it was a standing receiver.
4087
+ *
4088
+ * The counterparty dials our CIRCUIT address, so it is the RESERVATION relay whose gater is asked
4089
+ * `denyOutboundRelayedConnection(them, us)`. With no assignment recorded there it finds no binding
4090
+ * and refuses a completely legitimate dial. Two relays run in production and the client's own
4091
+ * logs show the fall-through to the second candidate is frequent, so this is the ordinary case,
4092
+ * not a corner: the session still opens, but every message falls to the store-and-forward park
4093
+ * path, and the only trace is a denial on a relay nobody is tailing.
4094
+ *
4095
+ * So the node that will be DIALLED presents the same assignment to the relay that will be asked
4096
+ * to allow it. Safe by construction: the assignment is self-authenticating (a per-node directory
4097
+ * signature the relay verifies against its consortium set), so presenting it more widely grants
4098
+ * nothing that forging it would not already require. No directory change, no new frame.
4099
+ */
4100
+ async #presentAssignmentToReservationRelay(agentName, node, relay, sessionIdHex, correlationId, entry) {
4101
+ // Review M1: the prologue below lives INSIDE the try. `listenAddresses()` on a node torn down
4102
+ // while we were wiring is exactly the case handled 30 lines above at the caller, and out here it
4103
+ // would have escaped both this method's catch and (before the caller's `.catch`) the process.
4104
+ let reservationRelayPeerId;
4105
+ try {
4106
+ if (!relay.assignment)
4107
+ return; // direct/legacy/persisted-reconnect: nothing to present anywhere
4108
+ const heldCircuitAddr = node.listenAddresses().find((a) => a.includes("/p2p-circuit"));
4109
+ if (!heldCircuitAddr)
4110
+ return; // no reservation held → nobody will gate a dial to us
4111
+ reservationRelayPeerId = /\/p2p\/([^/]+)\/p2p-circuit/.exec(heldCircuitAddr)?.[1];
4112
+ if (!reservationRelayPeerId || reservationRelayPeerId === relay.relayPeerId)
4113
+ return; // same relay — already recorded
4114
+ const clientKey = `${agentName}::${reservationRelayPeerId}`;
4115
+ let client = this.#relayClients.get(clientKey);
4116
+ if (!client) {
4117
+ if (!this.#relayReceiptStore && this.#db)
4118
+ this.#relayReceiptStore = new RelayReceiptStore(this.#db, this.#logger);
4119
+ if (!this.#sealLeafStore && this.#db)
4120
+ this.#sealLeafStore = new SessionSealLeafStore(this.#db, this.#logger);
4121
+ // Split on the marker, not an anchored strip: the held address is
4122
+ // `…/p2p/<relay>/p2p-circuit/p2p/<self>`, so the marker is in the MIDDLE.
4123
+ const baseRelayAddr = heldCircuitAddr.split("/p2p-circuit")[0] ?? heldCircuitAddr;
4124
+ client = this.#detachedRelayClientBuilder?.(agentName, reservationRelayPeerId, [baseRelayAddr], {
4125
+ receiptStore: this.#relayReceiptStore ?? undefined,
4126
+ sealLeafStore: this.#sealLeafStore ?? undefined,
4127
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
4128
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
4129
+ });
4130
+ if (!client)
4131
+ return;
4132
+ this.#relayClients.set(clientKey, client);
4133
+ // Review M4: record the key so teardown releases this client too — `relayClientKey` names
4134
+ // only the witness relay, so before this the client and its session registration leaked.
4135
+ if (entry)
4136
+ entry.extraRelayClientKeys = [...(entry.extraRelayClientKeys ?? []), clientKey];
4137
+ }
4138
+ // registerSession presents the assignment eagerly (see its own comment). No leaf handler: this
4139
+ // relay is not witnessing the session, it only needs the binding that authorizes the dial.
4140
+ client.registerSession(sessionIdHex, node, undefined, relay.assignment);
4141
+ this.#logger.info("session.relay.assignment.presented_to_reservation_relay", {
4142
+ agentName,
4143
+ sessionId: sessionIdHex.slice(0, 16),
4144
+ witnessRelayPeerId: relay.relayPeerId,
4145
+ reservationRelayPeerId,
4146
+ impact: "the relay that will be asked to allow inbound circuit dials to this node now holds the assignment authorizing them",
4147
+ correlationId,
4148
+ });
4149
+ }
4150
+ catch (err) {
4151
+ this.#logger.warn("session.relay.assignment.reservation_relay_failed", {
4152
+ agentName,
4153
+ sessionId: sessionIdHex.slice(0, 16),
4154
+ reservationRelayPeerId,
4155
+ error: extractErrorMessage(err),
4156
+ impact: "inbound relayed dials to this node may be refused by its reservation relay; the session still works over the direct path and the park backstop",
4157
+ correlationId,
4158
+ });
4159
+ }
4160
+ }
3208
4161
  /**
3209
4162
  * M7 DOD-SPINE-6 / MSG-001-3b: detach a session from its (agent, relay) client and
3210
4163
  * close the client when it has no remaining sessions. Idempotent and identity-guarded:
@@ -3214,11 +4167,32 @@ export class SessionNodeManager {
3214
4167
  #detachSessionRelay(entry) {
3215
4168
  const client = entry.relayClient;
3216
4169
  const key = entry.relayClientKey;
3217
- if (!client || !entry.relaySessionIdBytes)
4170
+ if (!entry.relaySessionIdBytes)
4171
+ return;
4172
+ const sidHex = Buffer.from(entry.relaySessionIdBytes).toString("hex");
4173
+ /**
4174
+ * Review M4: release the EXTRA relay clients first — the ones opened to relays that gate circuit
4175
+ * dials. `relayClientKey` above names only the witness relay, so these were registered and never
4176
+ * unregistered: an authenticated relay stream and a `#sessions` entry leaked per session, and
4177
+ * relay-side the dial-through binding they hold is then cleared only by the idle timer, which is
4178
+ * now 24h. Runs before the early return below so it happens even for a session that never got a
4179
+ * witness client.
4180
+ */
4181
+ for (const extraKey of entry.extraRelayClientKeys ?? []) {
4182
+ const extra = this.#relayClients.get(extraKey);
4183
+ if (!extra)
4184
+ continue;
4185
+ extra.unregisterSession(sidHex);
4186
+ if (!extra.hasSessions()) {
4187
+ extra.close();
4188
+ this.#relayClients.delete(extraKey);
4189
+ }
4190
+ }
4191
+ entry.extraRelayClientKeys = undefined;
4192
+ if (!client)
3218
4193
  return;
3219
4194
  // Idempotent: clear the entry's reference so a second teardown of the same entry no-ops.
3220
4195
  entry.relayClient = undefined;
3221
- const sidHex = Buffer.from(entry.relaySessionIdBytes).toString("hex");
3222
4196
  client.unregisterSession(sidHex);
3223
4197
  if (!client.hasSessions() && key && this.#relayClients.get(key) === client) {
3224
4198
  client.close();
@@ -3257,9 +4231,12 @@ export class SessionNodeManager {
3257
4231
  const entry = this.#activeNodes.get(key);
3258
4232
  return entry?.relayPeerId !== peerId;
3259
4233
  };
3260
- node.onPeerConnect((peerId) => {
3261
- if (!isCounterparty(peerId))
3262
- return;
4234
+ /**
4235
+ * Named rather than inline so the already-attached sweep below can invoke **this exact function**
4236
+ * instead of a second copy of it. A copy is what would drift: the two would have to be kept in
4237
+ * step by whoever edits either, and the failure would be silent.
4238
+ */
4239
+ const onCounterpartyAttached = (peerId) => {
3263
4240
  /**
3264
4241
  * DOD-M12B-RESPONDER-ADDR-1 (review MEDIUM-4) — LEARN THE ADDRESS HERE, where it cannot race.
3265
4242
  *
@@ -3307,7 +4284,63 @@ export class SessionNodeManager {
3307
4284
  * single lost frame does not strand the agreement.
3308
4285
  */
3309
4286
  void this.#sendSaltFrame(agentName, sessionId, correlationId);
3310
- });
4287
+ // 007-CRYPTO: the signed ephemeral rides the SAME moment as the salt half — one connect, one
4288
+ // round trip, the same peer-to-peer content stream. Fire-and-forget for the same reason: a
4289
+ // failed announcement must not reject a peer-connect handler, and the next connect re-announces.
4290
+ void this.#sendEphemeralFrame(agentName, sessionId, correlationId);
4291
+ };
4292
+ node.onPeerConnect(onCounterpartyAttached);
4293
+ /**
4294
+ * ⚠️ THE CONNECT THAT ALREADY HAPPENED — `DOD-M15-SALTANNOUNCE-LATE-1`.
4295
+ *
4296
+ * `onPeerConnect` above is `addEventListener("peer:connect", …)` (`core/transport/src/node.ts`),
4297
+ * and **an event listener cannot fire for a connection that predates it.** On the
4298
+ * `reuseStandingReceiver` path this session does not build a node — it TAKES the standing
4299
+ * receiver's, which has been listening all along. So the ordinary sequence is:
4300
+ *
4301
+ * 1. `#tryCreateStandingReceiver` starts the node listening. It never calls this method, so
4302
+ * there is no handler yet.
4303
+ * 2. The counterparty connects. `peer:connect` fires into nothing.
4304
+ * 3. The session promotes that same node and registers the handler — one step too late.
4305
+ *
4306
+ * The handler then never runs, and everything hanging off it is silently skipped: **the salt is
4307
+ * never announced** (`no_agreement_started`, the sender salts, the receiver holds none, and every
4308
+ * message between them is refused) **and the counterparty's address is never learned or
4309
+ * refreshed.** Measured live: `j-documents` 7 of 12 red, every failure a document update that
4310
+ * never arrived, with no error shown to either operator.
4311
+ *
4312
+ * ⚠️ THE COMMENT ON THE HANDLER ABOVE NAMES THE OPPOSITE HAZARD, AND IT IS ALSO RIGHT: *"a send
4313
+ * placed at `createSessionNode` would be an announcement to a peer that is not attached yet."*
4314
+ * Both are real, which is why this is a SWEEP AFTER REGISTERING rather than a move. Too-early
4315
+ * stays impossible — the sweep runs at the same point the handler is armed — and too-late stops
4316
+ * being invisible, because an already-open connection is now looked at instead of waited for.
4317
+ *
4318
+ * Idempotent by construction: it invokes the SAME handler the event would have, so a peer that
4319
+ * connects normally is unaffected, and a peer seen twice re-announces — which the announce path
4320
+ * already tolerates (*"we re-announce on every reconnect"*).
4321
+ */
4322
+ try {
4323
+ // `getConnections()` returns CONNECTIONS, and one peer can hold several — dedupe, or a peer
4324
+ // with two open connections would run the attach path twice for no reason.
4325
+ const attachedPeers = new Set(node.getConnections().map((c) => c.peerId));
4326
+ for (const peerId of attachedPeers) {
4327
+ if (!isCounterparty(peerId))
4328
+ continue;
4329
+ this.#logger.info("session.liveness.peer_already_attached", {
4330
+ agentName, sessionId, peerId, correlationId,
4331
+ impact: "this counterparty connected BEFORE the session's liveness handler was registered — on the standing-receiver promotion path that is the ordinary case, not a rare one. Running the attach path for it now: without this the salt is never announced (every message from the peer is then refused) and the counterparty's address is never learned.",
4332
+ });
4333
+ onCounterpartyAttached(peerId);
4334
+ }
4335
+ }
4336
+ catch (err) {
4337
+ // Never let the sweep cost the caller its session: the handler is already armed, so a failure
4338
+ // here degrades to exactly the behaviour that shipped before this fix.
4339
+ this.#logger.warn("session.liveness.attached_sweep.failed", {
4340
+ agentName, sessionId, correlationId, error: extractErrorMessage(err),
4341
+ impact: "could not check for an already-attached counterparty. If one is attached, this session may never announce its salt and will refuse that peer's messages — the pre-fix behaviour.",
4342
+ });
4343
+ }
3311
4344
  node.onPeerDisconnect((peerId) => {
3312
4345
  if (!isCounterparty(peerId)) {
3313
4346
  // Not silence: a relay link dropping is a real event, it is simply not a
@@ -3606,6 +4639,9 @@ export class SessionNodeManager {
3606
4639
  // Remove this agent's standing receiver from the slot and add to active map. The handed-off
3607
4640
  // node keeps its AutoNAT service (it continues to surface dialability).
3608
4641
  this.#standingReceivers.delete(agentName);
4642
+ // 006-CRYPTO: the hand-off path. A session promoted out of the standing receiver is as new as
4643
+ // one opened outbound, so it mints here too.
4644
+ this.#mintSessionEphemeral(agentName, sessionId);
3609
4645
  this.#activeNodes.set(this.#k(agentName, sessionId), {
3610
4646
  node,
3611
4647
  agentName,
@@ -3845,6 +4881,13 @@ export class SessionNodeManager {
3845
4881
  this.#saltContributions.delete(key);
3846
4882
  this.#sessionSalts.delete(key);
3847
4883
  this.#saltRepairedAgainst.delete(key);
4884
+ this.#saltRepairedAgainstFingerprint.delete(key);
4885
+ this.#saltPeerClosedLabel.delete(key);
4886
+ // The throwaway secret is destroyed where the `#activeNodes` entry is DROPPED, not here — see
4887
+ // `#destroySessionEphemeralFor`. This call is the belt to that braces: both teardown paths that
4888
+ // evict have already dropped the entry, so it is a no-op for them, and it is what catches any
4889
+ // future path that evicts without going through one of those.
4890
+ this.#destroySessionEphemeralFor(agentName, sessionId);
3848
4891
  /**
3849
4892
  * B2b-2's three, and the pending one is SETTLED rather than dropped.
3850
4893
  *
@@ -3861,6 +4904,10 @@ export class SessionNodeManager {
3861
4904
  */
3862
4905
  this.#settleSaltPending(agentName, sessionId, "closed");
3863
4906
  this.#hashedWithoutSalt.delete(key);
4907
+ // DOD-M15-SALTSPLIT-1 HIGH-2: goes with its mirror. The session is being torn down, so there is
4908
+ // no discard decision left for it to protect.
4909
+ this.#hashedWithSalt.delete(key);
4910
+ this.#saltSuspended.delete(key);
3864
4911
  this.#unsaltedAnnounced.delete(key);
3865
4912
  this.#saltLastOutcome.delete(key);
3866
4913
  // HELD CONTENT IS LOST HERE, AND IT MUST SAY SO.
@@ -4017,6 +5064,35 @@ export class SessionNodeManager {
4017
5064
  for (const timer of this.#lingeringStreams)
4018
5065
  clearTimeout(timer);
4019
5066
  this.#lingeringStreams.clear();
5067
+ /**
5068
+ * DOD-M15-RELAYLEAK-1 — **CLOSE THE RELAY CLIENTS. Shutdown never did.**
5069
+ *
5070
+ * This method stops every session NODE and left `#relayClients` untouched — verified by reading:
5071
+ * the whole of `gracefulShutdown` referenced `relayClient` zero times. Each cached client holds
5072
+ * an authenticated libp2p stream to a relay and a reader loop, so a `cello logout` left them
5073
+ * open until the process itself exited.
5074
+ *
5075
+ * That is a real cost rather than untidiness: the relay counts a reservation per client and its
5076
+ * slots are finite, so a daemon that restarts repeatedly consumes them faster than they are
5077
+ * released, which is the "agents cannot get a reservation" failure the relay's own limits note
5078
+ * describes from the other side.
5079
+ *
5080
+ * Best-effort and individually caught: teardown must not be the thing that throws. One client
5081
+ * that refuses to close must not prevent the next from being released.
5082
+ */
5083
+ for (const [key, client] of this.#relayClients) {
5084
+ try {
5085
+ client.close();
5086
+ }
5087
+ catch (err) {
5088
+ this.#logger.warn("session.relay_client.close_failed", {
5089
+ relayClientKey: key,
5090
+ reason: err instanceof Error ? err.message : String(err),
5091
+ impact: "one cached relay client did not close cleanly on shutdown; the rest are still released",
5092
+ });
5093
+ }
5094
+ }
5095
+ this.#relayClients.clear();
4020
5096
  // Cancel every armed awaiting-ACK timer so an un-acked send (e.g. a rejected /
4021
5097
  // tampered frame that never produced a `persisted` ACK) does not leave a 20s
4022
5098
  // timer pinning the content + this manager in memory past teardown (review M1).
@@ -4124,6 +5200,14 @@ export class SessionNodeManager {
4124
5200
  for (const identity of this.#sessionSeeds.values())
4125
5201
  identity.seed.fill(0);
4126
5202
  this.#sessionSeeds.clear();
5203
+ // 006-CRYPTO: the per-session throwaway secrets belong in the same sentence, for the same
5204
+ // reason and with the same measured cause — shutdown marks rows `interrupted` by direct SQL, so
5205
+ // no per-session teardown fires for them, and this process is known to linger (a `cello logout`
5206
+ // was still alive 30+ seconds later). Without this, every live session's key survives the
5207
+ // shutdown in memory for as long as it lingers.
5208
+ for (const ephemeral of this.#sessionEphemerals.values())
5209
+ destroySessionEphemeral(ephemeral);
5210
+ this.#sessionEphemerals.clear();
4127
5211
  // Stop ALL per-agent standing receivers (DOD-LOOP-1). In PARALLEL and BOUNDED: this was a
4128
5212
  // sequential await per agent with no deadline, so five agents meant five chances for one stuck
4129
5213
  // libp2p teardown to hold the exit — and it sits between the operator being told the daemon is
@@ -4520,11 +5604,39 @@ export class SessionNodeManager {
4520
5604
  * BLOB to a listing surface that has no use for it; the boolean is the whole question a reader of
4521
5605
  * this list is asking, and shipping key material to answer a yes/no is not a trade worth making.
4522
5606
  */
4523
- #saltStatusOf(row) {
5607
+ /**
5608
+ * ⚠️ THE STORED COLUMN IS NOT THE ANSWER ON ITS OWN — 006-CRYPTO finding 3.
5609
+ *
5610
+ * A SUSPENDED salt keeps its bytes on disk deliberately (`DOD-M15-SALTSPLIT-1`: a salt kept is
5611
+ * recoverable, a salt erased is not), while `#saltForHashing` returns null for it and every
5612
+ * message goes out `sha256`. Reading the column alone therefore reported `true` at the exact
5613
+ * moment the session had STOPPED salting — and because the field is emitted only when `false`,
5614
+ * the agent saw nothing at all, which reads as "not unsalted".
5615
+ *
5616
+ * That is precisely the case this field was added for. Its own note above says it exists to tell
5617
+ * *"unsalted because this build predates the feature"* from *"unsalted because adoption was
5618
+ * refused"*, and the refused case was the one it could not report.
5619
+ */
5620
+ #saltStatusOf(row, agentName) {
4524
5621
  const { content_salt, ...rest } = row;
5622
+ const stored = content_salt != null && content_salt.length > 0;
5623
+ const suspended = agentName !== null && this.#saltSuspended.has(this.#k(agentName, String(row.session_id)));
5624
+ /**
5625
+ * 007-CRYPTO: the REAL state, not a constant. In 006 this was hardcoded `false` with a single
5626
+ * reason, because nothing exchanged keys and saying so was the honest answer. It exchanges now,
5627
+ * so a hardcode would be the stale claim that unit existed to remove.
5628
+ *
5629
+ * A row with no live agent name cannot be looked up in memory — an orphaned session, whose key
5630
+ * (if it ever had one) died with the process. Reported as not-agreed rather than guessed at.
5631
+ */
5632
+ const enc = agentName === null
5633
+ ? { key: null, reason: CONTENT_ENCRYPTION_REASONS.NOT_YET_AGREED }
5634
+ : this.#contentEncryptionState(agentName, String(row.session_id));
4525
5635
  return {
4526
5636
  ...rest,
4527
- content_hashes_salted: content_salt != null && content_salt.length > 0,
5637
+ content_hashes_salted: stored && !suspended,
5638
+ content_encrypted: enc.key !== null,
5639
+ ...(enc.key === null ? { content_encryption_reason: enc.reason } : {}),
4528
5640
  };
4529
5641
  }
4530
5642
  getSessionsForAgent(agentName) {
@@ -4536,7 +5648,7 @@ export class SessionNodeManager {
4536
5648
  const rows = this.#db
4537
5649
  .prepare("SELECT * FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC")
4538
5650
  .all(this.#requireAgentId(agentName));
4539
- return rows.map((r) => ({ ...this.#saltStatusOf(r), agent_name: agentName }));
5651
+ return rows.map((r) => ({ ...this.#saltStatusOf(r, agentName), agent_name: agentName }));
4540
5652
  }
4541
5653
  /**
4542
5654
  * Every persisted session across ALL agents, most-recently-updated first. Backs the daemon-wide
@@ -4554,7 +5666,12 @@ export class SessionNodeManager {
4554
5666
  FROM sessions s LEFT JOIN agents a ON a.agent_id = s.agent_id
4555
5667
  ORDER BY s.updated_at DESC`)
4556
5668
  .all()
4557
- .map((r) => this.#saltStatusOf(r));
5669
+ // The joined display name is what `#saltSuspended` is keyed on. NULL only where the agent row
5670
+ // is missing — an orphaned session, which has no live in-memory state to be suspended in.
5671
+ .map((r) => {
5672
+ const row = r;
5673
+ return this.#saltStatusOf(row, row.agent_name ?? null);
5674
+ });
4558
5675
  }
4559
5676
  /**
4560
5677
  * M7-SESSION-004 (AC-005): persist the seal certificate's legibility object with the
@@ -4685,6 +5802,162 @@ export class SessionNodeManager {
4685
5802
  }
4686
5803
  return { sealed_root: row.sealed_root_hex, legibility };
4687
5804
  }
5805
+ /**
5806
+ * DOD-M15-INCLUSION-1: keep the leaf set the certificate is signed over, so one message can later
5807
+ * be proved to sit under it.
5808
+ *
5809
+ * REFUSES unless the hashes reproduce `sealedRootHex` — `certifiedLeafSetFrom` does that check and
5810
+ * this method never bypasses it. That is what separates "the leaves the directory sent" from "the
5811
+ * leaves the consortium signed", and only the second is worth storing: a proof built on the first
5812
+ * would inherit whatever the directory chose to say.
5813
+ *
5814
+ * Idempotent (INSERT OR REPLACE keyed on leaf_index) so a re-delivered seal frame, or a unilateral
5815
+ * seal later upgraded to bilateral, rewrites the same rows instead of failing or doubling them.
5816
+ *
5817
+ * @returns whether the set was accepted and stored.
5818
+ */
5819
+ recordCertifiedLeafSet(agentName, sessionId, signedLeaves, sealedRootHex, correlationId) {
5820
+ if (!this.#db)
5821
+ return false;
5822
+ const resolved = certifiedLeafSetFrom(signedLeaves, sealedRootHex);
5823
+ if (!resolved.ok) {
5824
+ // The CAUSE is written where the proof surface can read it — fallback-finder finding 1. Without
5825
+ // this row, `sealed_leaves_root_disagrees` (a directory contradicting its own FROST signature)
5826
+ // and "this side was simply absent" are the same `null` downstream, and the operator is told
5827
+ // the second.
5828
+ this.#noteCertifiedLeafState(agentName, sessionId, resolved.reason, resolved.detail);
5829
+ // LOUD, and it names which of the two it is. `sealed_leaves_root_disagrees` in particular is
5830
+ // the directory shipping a leaf set that is not the one it signed — the receipt still stands
5831
+ // (its own signature is checked elsewhere), but nothing in this session can be proved at
5832
+ // message granularity until a set that reproduces the root arrives.
5833
+ this.#logger.error("seal.certified_leaves.refused", {
5834
+ agentName,
5835
+ sessionId,
5836
+ reason: resolved.reason,
5837
+ detail: resolved.detail,
5838
+ correlationId,
5839
+ impact: "the leaf set shipped with this seal is not the one the certificate is signed over, so no " +
5840
+ "inclusion proof can be issued for this session; the sealed receipt itself is unaffected",
5841
+ guidance: "cello_get_inclusion_proof will refuse this session by name (certified_leaves_unavailable). " +
5842
+ "Nothing local repairs it — the set has to arrive with a seal frame that reproduces the " +
5843
+ "signed root.",
5844
+ });
5845
+ return false;
5846
+ }
5847
+ const now = Date.now();
5848
+ try {
5849
+ const agentId = this.#requireAgentId(agentName);
5850
+ /**
5851
+ * DELETE THEN INSERT, INSIDE A TRANSACTION — fallback-finder finding 5.
5852
+ *
5853
+ * `INSERT OR REPLACE` alone is idempotent only for a set of the SAME length: a shorter
5854
+ * re-delivery overwrites 0..k-1 and leaves stale rows at k..n-1, and an un-transacted loop that
5855
+ * throws halfway leaves a truncated set that `getCertifiedLeafSet` still returns (it tests
5856
+ * `rows.length > 0`, not completeness). Both produce a set that no longer hashes to the
5857
+ * certified root — caught on read, but reported to the operator as *"the local copy has
5858
+ * changed since the seal"*, which points at tampering for a write that never finished.
5859
+ */
5860
+ // `BEGIN` / `COMMIT` / `ROLLBACK` via exec — this file's and `db-identity-store.ts`'s idiom.
5861
+ // `DaemonDatabase` has no `transaction()` helper (node:sqlite's handle does not provide one),
5862
+ // and reaching for better-sqlite3's would compile against the adapter and fail on the other.
5863
+ this.#db.exec("BEGIN");
5864
+ try {
5865
+ this.#db.prepare("DELETE FROM session_certified_leaves WHERE agent_id = ? AND session_id = ?")
5866
+ .run(agentId, sessionId);
5867
+ const stmt = this.#db.prepare(`INSERT INTO session_certified_leaves
5868
+ (agent_id, session_id, leaf_index, content_hash_hex, recorded_at)
5869
+ VALUES (?, ?, ?, ?, ?)`);
5870
+ for (let i = 0; i < resolved.leafHashes.length; i++) {
5871
+ stmt.run(agentId, sessionId, i, resolved.leafHashes[i], now);
5872
+ }
5873
+ this.#db.exec("COMMIT");
5874
+ }
5875
+ catch (err) {
5876
+ try {
5877
+ this.#db.exec("ROLLBACK");
5878
+ }
5879
+ catch { /* the failing statement may have aborted it already */ }
5880
+ throw err;
5881
+ }
5882
+ }
5883
+ catch (err) {
5884
+ this.#noteCertifiedLeafState(agentName, sessionId, "persist_failed", extractErrorMessage(err));
5885
+ this.#logger.error("seal.certified_leaves.persist.failed", {
5886
+ agentName,
5887
+ sessionId,
5888
+ reason: extractErrorMessage(err),
5889
+ correlationId,
5890
+ impact: "this session's certified leaf set was verified but not written, so cello_get_inclusion_proof " +
5891
+ "will refuse it by name until a later seal frame re-delivers the set",
5892
+ });
5893
+ return false;
5894
+ }
5895
+ this.#noteCertifiedLeafState(agentName, sessionId, "stored", null);
5896
+ this.#logger.info("seal.certified_leaves.recorded", {
5897
+ agentName,
5898
+ sessionId,
5899
+ leafCount: resolved.leafHashes.length,
5900
+ sealedRoot: sealedRootHex,
5901
+ correlationId,
5902
+ });
5903
+ return true;
5904
+ }
5905
+ /**
5906
+ * Record WHY this session does or does not have a certified leaf set.
5907
+ *
5908
+ * Public for the one case the manager cannot see: a seal frame that carried no signed leaves at
5909
+ * all never reaches `recordCertifiedLeafSet`, and that absence is a permanent fact about the
5910
+ * session for the party that observed it.
5911
+ */
5912
+ noteCertifiedLeafSetUnavailable(agentName, sessionId, state, detail) {
5913
+ this.#noteCertifiedLeafState(agentName, sessionId, state, detail);
5914
+ }
5915
+ /**
5916
+ * The last thing that happened to this session's certified leaf set, or null if nothing has.
5917
+ *
5918
+ * Null here and a null from `getCertifiedLeafSet` together mean "no seal has been processed on
5919
+ * this side yet" — which is a different sentence again from any of the recorded states.
5920
+ */
5921
+ getCertifiedLeafSetState(agentName, sessionId) {
5922
+ if (!this.#db)
5923
+ return null;
5924
+ const row = this.#db
5925
+ .prepare("SELECT state, detail FROM session_certified_leaves_state WHERE agent_id = ? AND session_id = ?")
5926
+ .get(this.#requireAgentId(agentName), sessionId);
5927
+ return row ? { state: row.state, detail: row.detail } : null;
5928
+ }
5929
+ /** Best-effort: a failure to record WHY must never be the thing that breaks a seal. */
5930
+ #noteCertifiedLeafState(agentName, sessionId, state, detail) {
5931
+ if (!this.#db)
5932
+ return;
5933
+ try {
5934
+ this.#db
5935
+ .prepare(`INSERT OR REPLACE INTO session_certified_leaves_state
5936
+ (agent_id, session_id, state, detail, recorded_at)
5937
+ VALUES (?, ?, ?, ?, ?)`)
5938
+ .run(this.#requireAgentId(agentName), sessionId, state, detail, Date.now());
5939
+ }
5940
+ catch (err) {
5941
+ this.#logger.warn("seal.certified_leaves.state.write.failed", {
5942
+ agentName, sessionId, state, reason: extractErrorMessage(err),
5943
+ impact: "the inclusion-proof surface will not be able to name WHY this session has no certified leaf set; it still refuses rather than proving anything",
5944
+ });
5945
+ }
5946
+ }
5947
+ /**
5948
+ * The certified leaf set, in order, or null when none was stored for this session.
5949
+ *
5950
+ * Null is a REFUSAL upstream, never a fallback to the local tree: the two cover different leaves
5951
+ * and substituting one for the other is how a proof comes to land on a root nobody signed.
5952
+ */
5953
+ getCertifiedLeafSet(agentName, sessionId) {
5954
+ if (!this.#db)
5955
+ return null;
5956
+ const rows = this.#db
5957
+ .prepare("SELECT content_hash_hex FROM session_certified_leaves WHERE agent_id = ? AND session_id = ? ORDER BY leaf_index ASC")
5958
+ .all(this.#requireAgentId(agentName), sessionId);
5959
+ return rows.length > 0 ? rows.map((r) => r.content_hash_hex) : null;
5960
+ }
4688
5961
  /**
4689
5962
  * M7-SESSION-001: Mark a session as interrupted with message count and timestamp.
4690
5963
  * Called when a relay session_interrupted frame arrives or a relay stream closes.
@@ -4694,9 +5967,17 @@ export class SessionNodeManager {
4694
5967
  * @param messageCount Number of message leaves at interruption
4695
5968
  * @param source 'relay_frame' | 'stream_close'
4696
5969
  */
4697
- async markInterruptedWithDetails(agentName, sessionId, messageCount, source) {
5970
+ async markInterruptedWithDetails(agentName, sessionId, messageCount,
5971
+ /**
5972
+ * WHAT ACTUALLY HAPPENED, and it is written to the row — review F3.
5973
+ *
5974
+ * `key_refused` is its own source rather than a borrowed `stream_close`, because the row's
5975
+ * `interrupted_by` is what an operator reads days later: labelling a key-authentication refusal
5976
+ * `relay_stream_close` sends them to the relay fleet for a fault in the payload.
5977
+ */
5978
+ source) {
4698
5979
  if (!this.#db)
4699
- return;
5980
+ return false;
4700
5981
  // H-3 SECURITY: only an 'active' session may transition to 'interrupted'.
4701
5982
  // A late or forged relay frame must NOT revert a 'sealed', 'seal_interrupted_pending',
4702
5983
  // or already-'interrupted' session back to 'interrupted'. This mirrors the
@@ -4709,7 +5990,8 @@ export class SessionNodeManager {
4709
5990
  currentStatus: existing?.status ?? "absent",
4710
5991
  reason: "session_not_active",
4711
5992
  });
4712
- return;
5993
+ // FALSE, not void — the caller needs to know nothing was torn down (review F11).
5994
+ return false;
4713
5995
  }
4714
5996
  const now = Date.now();
4715
5997
  const interruptedAt = new Date(now).toISOString();
@@ -4739,7 +6021,7 @@ export class SessionNodeManager {
4739
6021
  // `relay_stream_close` is its own label and STILL COUNTS (the bound excuses only 'local'),
4740
6022
  // because an attacker who can disturb our relay link must not get a free cap reset. It is
4741
6023
  // recorded honestly rather than blamed on the wrong party.
4742
- `UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ?, interrupted_by = '${source === "relay_frame" ? "counterparty" : "relay_stream_close"}' WHERE agent_id = ? AND session_id = ? AND status = 'active'`)
6024
+ `UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ?, interrupted_by = '${source === "relay_frame" ? "counterparty" : source === "key_refused" ? "key_refused" : "relay_stream_close"}' WHERE agent_id = ? AND session_id = ? AND status = 'active'`)
4743
6025
  .run(now, authoritativeCount, interruptedAt, this.#requireAgentId(agentName), sessionId);
4744
6026
  }
4745
6027
  catch (err) {
@@ -4767,6 +6049,21 @@ export class SessionNodeManager {
4767
6049
  // Fall through — still remove from active map
4768
6050
  }
4769
6051
  this.#activeNodes.delete(this.#k(agentName, sessionId));
6052
+ /**
6053
+ * THE SECRET GOES WITH THE ENTRY — 006-CRYPTO, review pass 2 finding 2.
6054
+ *
6055
+ * This is the path an interrupted session actually takes, and it is the ORDINARY way a
6056
+ * session ends badly: a relay blip, a closed stream, a sleeping laptop. Because it does not
6057
+ * evict (see below) the secret used to survive here, and when the session later sealed
6058
+ * `destroySessionNode` returned at its `if (!entry) return` without evicting either — so the
6059
+ * receipt landed, the session was over, and the key stayed resident until the process exited.
6060
+ *
6061
+ * The reasons below for KEEPING the other caches do not transfer to key material: buffered
6062
+ * plaintext must stay drainable and TTF timers must stay armed, whereas a secret nothing
6063
+ * reads must not stay alive. A revived session mints a fresh one and re-keys, which is
6064
+ * Decisions Carried #5 and is only true because of this line.
6065
+ */
6066
+ this.#destroySessionEphemeralFor(agentName, sessionId, entry.correlationId);
4770
6067
  this.#logger.info("session.node.destroyed", {
4771
6068
  sessionId,
4772
6069
  agentName,
@@ -4801,6 +6098,7 @@ export class SessionNodeManager {
4801
6098
  error: err instanceof Error ? err.message : String(err),
4802
6099
  });
4803
6100
  }
6101
+ return true;
4804
6102
  }
4805
6103
  /**
4806
6104
  * M7-SESSION-001 (H-1): persist a verified bilateral SEAL-INTERRUPTED
@@ -5125,6 +6423,10 @@ export class SessionNodeManager {
5125
6423
  if (viaRelay)
5126
6424
  entry.gater.setAllowedOutboundPeer(viaRelay[1]);
5127
6425
  }
6426
+ // DOD-M15-RELAYAUTH-1 review H1: the RELAY's gater must also admit this dial, and it only does
6427
+ // so once it holds the assignment. Await that here — see the method's own comment for why the
6428
+ // counterparty presenting it cannot be relied on.
6429
+ await this.#authorizeCircuitDialsToCounterparty(agentName, sessionId, entry, addrs);
5128
6430
  let lastError = "";
5129
6431
  for (const addr of addrs) {
5130
6432
  try {
@@ -5155,6 +6457,112 @@ export class SessionNodeManager {
5155
6457
  });
5156
6458
  return { ok: false, reason: "counterparty_dial_failed", error: lastError };
5157
6459
  }
6460
+ /**
6461
+ * DOD-M15-RELAYAUTH-1 review H1 — **THE GATE WAS DENYING THE LEGITIMATE DIAL, AND USUALLY.**
6462
+ *
6463
+ * With the gater installed, a relay refuses a circuit dial unless it already holds a
6464
+ * directory-signed assignment naming both transport peer ids. Both parties get that assignment
6465
+ * from the directory independently, and until this method existed, each only presented it to
6466
+ * relays IT had chosen — so whether a dial was allowed came down to which of two independent
6467
+ * network races finished first:
6468
+ *
6469
+ * 1. WE connect to the witness relay, then dial the counterparty's circuit address. ~2 RTT.
6470
+ * 2. THEY connect to their witness relay, then — unawaited, on a fresh dial + auth + record —
6471
+ * tell their RESERVATION relay about the session. ~3–4 RTT.
6472
+ *
6473
+ * Nothing sequenced (1) against (2), and (1) is shorter, so we usually arrived first and were
6474
+ * refused. The session still opened and reported `transportMode: "relay"`, so the failure was
6475
+ * invisible: every message for the life of that conversation quietly took the store-and-forward
6476
+ * park path, and the only trace was a denial logged on a third machine nobody tails.
6477
+ *
6478
+ * The fix is to stop racing. Whoever is about to dial presents the assignment to the relay that
6479
+ * will gate that dial, and WAITS for the relay to confirm it recorded it. The ordering becomes
6480
+ * local to one thread of execution, so there is nothing left to lose.
6481
+ *
6482
+ * Safe by construction: we are a participant the assignment names, so the relay's own participant
6483
+ * check passes; and the assignment is self-authenticating (a directory signature the relay
6484
+ * verifies against its consortium set), so presenting it more widely grants nothing that forging
6485
+ * it would not already require.
6486
+ *
6487
+ * Best-effort by design — a relay we cannot reach must not stop us from dialling. If the record
6488
+ * fails we dial anyway: a dial that might be refused is strictly better than no dial.
6489
+ */
6490
+ async #authorizeCircuitDialsToCounterparty(agentName, sessionId, entry, addrs) {
6491
+ const assignment = entry.relayAssignment;
6492
+ if (!assignment || !entry.relaySessionIdBytes)
6493
+ return; // direct/legacy/persisted: no credential to present
6494
+ const sessionIdHex = Buffer.from(entry.relaySessionIdBytes).toString("hex");
6495
+ // One presentation per distinct relay, not per address: a counterparty commonly advertises
6496
+ // several circuit addresses on the SAME relay.
6497
+ const seen = new Set();
6498
+ for (const addr of addrs) {
6499
+ const relayPeerId = /\/p2p\/([^/]+)\/p2p-circuit/.exec(addr)?.[1];
6500
+ if (!relayPeerId || seen.has(relayPeerId))
6501
+ continue;
6502
+ seen.add(relayPeerId);
6503
+ // The witness relay already has it — registerSession presented it when the session was wired.
6504
+ if (relayPeerId === entry.relayPeerId)
6505
+ continue;
6506
+ const baseRelayAddr = addr.split("/p2p-circuit")[0] ?? addr;
6507
+ try {
6508
+ const clientKey = `${agentName}::${relayPeerId}`;
6509
+ let client = this.#relayClients.get(clientKey);
6510
+ if (!client) {
6511
+ if (!this.#relayReceiptStore && this.#db)
6512
+ this.#relayReceiptStore = new RelayReceiptStore(this.#db, this.#logger);
6513
+ if (!this.#sealLeafStore && this.#db)
6514
+ this.#sealLeafStore = new SessionSealLeafStore(this.#db, this.#logger);
6515
+ client = this.#detachedRelayClientBuilder?.(agentName, relayPeerId, [baseRelayAddr], {
6516
+ receiptStore: this.#relayReceiptStore ?? undefined,
6517
+ sealLeafStore: this.#sealLeafStore ?? undefined,
6518
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
6519
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
6520
+ });
6521
+ if (!client) {
6522
+ this.#logger.warn("session.transport.dial_authorization.no_builder", {
6523
+ sessionId,
6524
+ relayPeerId,
6525
+ impact: "cannot present the assignment to the relay that gates this dial; if the counterparty has not presented it either, the dial will be refused and every message will fall to the park path",
6526
+ correlationId: entry.correlationId,
6527
+ });
6528
+ continue;
6529
+ }
6530
+ this.#relayClients.set(clientKey, client);
6531
+ // Review M4: remember it so teardown releases it — this is a SECOND client for the
6532
+ // session, and detach only knows about the witness one.
6533
+ entry.extraRelayClientKeys = [...(entry.extraRelayClientKeys ?? []), clientKey];
6534
+ }
6535
+ // No leaf handler: this relay is not witnessing the session, it only needs the binding.
6536
+ client.registerSession(sessionIdHex, entry.node, undefined, assignment);
6537
+ const recorded = await client.recordAssignmentAndWait(entry.node, sessionIdHex);
6538
+ if (recorded) {
6539
+ this.#logger.info("session.transport.dial_authorized", {
6540
+ sessionId,
6541
+ relayPeerId,
6542
+ impact: "the relay that gates this circuit dial now holds the assignment authorizing it",
6543
+ correlationId: entry.correlationId,
6544
+ });
6545
+ }
6546
+ else {
6547
+ this.#logger.warn("session.transport.dial_authorization.not_recorded", {
6548
+ sessionId,
6549
+ relayPeerId,
6550
+ impact: "the relay did not confirm the assignment; the dial below may be refused and messages would fall to the park path",
6551
+ correlationId: entry.correlationId,
6552
+ });
6553
+ }
6554
+ }
6555
+ catch (err) {
6556
+ this.#logger.warn("session.transport.dial_authorization.failed", {
6557
+ sessionId,
6558
+ relayPeerId,
6559
+ error: extractErrorMessage(err),
6560
+ impact: "could not tell the relay that gates this dial about the session; the dial is attempted anyway",
6561
+ correlationId: entry.correlationId,
6562
+ });
6563
+ }
6564
+ }
6565
+ }
5158
6566
  /**
5159
6567
  * Send content over the session node's direct P2P content stream.
5160
6568
  * On a dead/missing stream this returns a NAMED, diagnosable failure — never a silent success
@@ -5235,6 +6643,15 @@ export class SessionNodeManager {
5235
6643
  // the leaf_deliver witness stream / arrival order.
5236
6644
  let orderingS1;
5237
6645
  let orderingS2;
6646
+ /**
6647
+ * DOD-M15-SEALWIRE-1 bullet 5, SENT half. Our own Ed25519 signature over `orderingS1`.
6648
+ *
6649
+ * The submit path already computes this — `keyProvider.sign(structure1)` — and puts it on the
6650
+ * wire as `sender_signature`. It was simply never handed back, which is the whole of the defect:
6651
+ * a RECEIVED row could prove its author to a third party and a SENT row could not, so half the
6652
+ * transcript was provable and half was assertion.
6653
+ */
6654
+ let sentAuthorship;
5238
6655
  // DOD-M12B-INDEX-1: the relay's answer to "where does this message go", carried to the caller.
5239
6656
  let assignedSeq;
5240
6657
  // DOD-MP-SESSION-RETIRE-1 — the relay's answer SURVIVES to the caller even when the direct send
@@ -5254,6 +6671,101 @@ export class SessionNodeManager {
5254
6671
  if (witnessed.ok) {
5255
6672
  orderingS1 = witnessed.structure1_cbor;
5256
6673
  orderingS2 = witnessed.structure2_cbor;
6674
+ /**
6675
+ * PAIRED WITH THE BYTES IT SIGNS, in one place, so the two can never be assigned apart.
6676
+ *
6677
+ * ⚠️ THE PUBKEY COMES FROM INSIDE `structure1_cbor`, NOT FROM AN AGENT LOOKUP — the same
6678
+ * source the RECEIVED half uses. A verifier checks the signature against the key in the
6679
+ * signed bytes; storing any other key would produce a row that looks checkable and fails,
6680
+ * and a lookup could drift from what was actually signed (a rotated identity, the wrong
6681
+ * agent resolved by name). Taking it from the signed bytes makes that class impossible.
6682
+ */
6683
+ if (witnessed.structure1_cbor && witnessed.sender_signature) {
6684
+ /**
6685
+ * ⚠️ THE DROP IS SOFT, BUT IT MUST NOT BE SILENT — review pass 1, F2.
6686
+ *
6687
+ * My comment here claimed the resulting row is "distinguishable from one that carries a
6688
+ * signature". True, and it misses the comparison that matters: **it is byte-identical to
6689
+ * an UNWITNESSED send** — `self_authored`, both proof columns NULL. So the record cannot
6690
+ * tell "the relay never witnessed this" from "we witnessed it, held the proof, and
6691
+ * dropped it decoding our own bytes."
6692
+ *
6693
+ * And the asymmetry with the received half is the argument. `#recordFrameOrdering` is
6694
+ * soft because the COUNTERPARTY supplied those bytes — an absence we cannot resolve.
6695
+ * Here **we produced them**, in `session-relay-client.ts`, moments earlier. A failure
6696
+ * means our own encoder and decoder disagree: an internal invariant break that would
6697
+ * strip authorship from every sent row for the life of the process. Soft is still right
6698
+ * — throwing would lose a delivered message over a missing attestation — but soft and
6699
+ * unannounced is the silent-fallback pattern this milestone exists to find.
6700
+ */
6701
+ const dropAuthorship = (reason, error, extra) => {
6702
+ this.#logger.warn("session.sent.authorship.unavailable", {
6703
+ sessionId,
6704
+ agentName,
6705
+ reason,
6706
+ // WHICH ROW lost its proof — review pass 2, M2. Without the sequence an operator knows
6707
+ // a message is unproven and not which one, in a transcript of hundreds.
6708
+ ...(witnessed.ok ? { relaySequence: witnessed.sequence_number } : {}),
6709
+ ...(extra ?? {}),
6710
+ ...(error === undefined ? {} : { error: error instanceof Error ? error.message : String(error) }),
6711
+ impact: "this sent message is recorded with attribution 'self_authored' and NO signature, so the row " +
6712
+ "asserts its author rather than proving one. It is indistinguishable in the database from a " +
6713
+ "send the relay never witnessed — this log line is the only thing that tells them apart.",
6714
+ guidance: "We produced these bytes ourselves, so a decode or shape failure here means this daemon's own " +
6715
+ "encoder and decoder disagree. Treat it as an internal invariant break, not a peer problem.",
6716
+ correlationId,
6717
+ });
6718
+ };
6719
+ try {
6720
+ // Structure 1 = [1, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp]
6721
+ // — the same decode `#recordFrameOrdering` does for the received half, index 2.
6722
+ const s1 = decode(witnessed.structure1_cbor);
6723
+ const pk = s1[2];
6724
+ // The SIGNATURE is length-checked too (review F2): the guard checked the pubkey's 32
6725
+ // bytes and only truthiness on the signature, so a zero-length one would have stored an
6726
+ // uncheckable BLOB. Not reachable today — `sign()` returns 64 — and the asymmetry is
6727
+ // the kind that stops being unreachable quietly.
6728
+ /**
6729
+ * ⚠️ VERIFIED BEFORE IT IS STORED, NOT SHAPE-CHECKED — review pass 2, H2, and this is
6730
+ * worth more than any test of it.
6731
+ *
6732
+ * This used to accept the pair on 32 bytes and 64 bytes. A shape check cannot tell a
6733
+ * real proof from 96 bytes that resemble one, and **nothing downstream ever checks
6734
+ * either**: not at write time, and not at read time, because no production reader of
6735
+ * these columns exists yet. So a wrong Structure-1 index, a wrong key, or a pair from
6736
+ * two different submits would all have been persisted as a row that **looks checkable
6737
+ * to an auditor and fails** — strictly worse than the honest unproven row it replaced.
6738
+ *
6739
+ * The received half has always done this (`#recordFrameOrdering` verifies before
6740
+ * storing and treats a failure as fatal). The sent half did not, and every ingredient
6741
+ * was already in scope on this line.
6742
+ *
6743
+ * What it buys over a test: a wrong index becomes **impossible to persist**. The row
6744
+ * gets NULL, the warn below fires, and it happens in production on the machine that
6745
+ * caused it — not in a suite someone has to remember to write.
6746
+ *
6747
+ * NOT fatal, unlike the received half, and the asymmetry is deliberate: there the
6748
+ * failure means a COUNTERPARTY sent something that does not verify, which is an
6749
+ * identity problem. Here it means our own encoder and decoder disagree — bad, but it
6750
+ * must not cost the operator a delivered message.
6751
+ */
6752
+ if (!(pk instanceof Uint8Array) || pk.length !== 32) {
6753
+ dropAuthorship("pubkey_shape", undefined, { pubkeyLen: pk instanceof Uint8Array ? pk.length : -1 });
6754
+ }
6755
+ else if (witnessed.sender_signature.length !== 64) {
6756
+ dropAuthorship("signature_shape", undefined, { sigLen: witnessed.sender_signature.length });
6757
+ }
6758
+ else if (!verify(pk, witnessed.structure1_cbor, witnessed.sender_signature)) {
6759
+ dropAuthorship("pair_does_not_verify");
6760
+ }
6761
+ else {
6762
+ sentAuthorship = { senderPubkey: pk, senderSig: witnessed.sender_signature };
6763
+ }
6764
+ }
6765
+ catch (err) {
6766
+ dropAuthorship("structure1_decode_failed", err);
6767
+ }
6768
+ }
5257
6769
  // 1-BASED → 0-BASED. The relay numbers the first leaf of a session 1
5258
6770
  // (`relay-node.ts`: `const seq = state.seq_counter + 1`), and this tree is 0-indexed.
5259
6771
  // Every RECEIVE path in this file normalises with -1 and says so; the send path took the
@@ -5385,6 +6897,22 @@ export class SessionNodeManager {
5385
6897
  // connection). See the note on #handleContentStream's finally.
5386
6898
  let sendStream;
5387
6899
  try {
6900
+ /**
6901
+ * 🚨 NO KEY, NO DIRECT SEND — `DOD-M15-EPHEMERAL-AUTH-1`, and there is no plaintext fallback.
6902
+ *
6903
+ * Throwing here rather than sending in the open, because the catch below PARKS the content —
6904
+ * and the mailbox copy is sealed to the counterparty's long-term identity key, so the message
6905
+ * still travels encrypted and still arrives. The failure mode is a delay, never an exposure.
6906
+ *
6907
+ * A fallback to plaintext would be a thing an attacker steers a session into: strip the key
6908
+ * frame and a system that "carries on, degraded" gives up the body while the operator reads a
6909
+ * warning they have learned to scroll past. That is why this is a throw and not a warning.
6910
+ */
6911
+ const encState = this.#contentEncryptionState(agentName, sessionId);
6912
+ if (encState.key === null) {
6913
+ throw new Error(`content_not_encryptable: ${encState.reason} — ${CONTENT_ENCRYPTION_GUIDANCE[encState.reason]}`);
6914
+ }
6915
+ const sessionKey = encState.key;
5388
6916
  const stream = await this.#openContentStream(agentName, sessionId, entry, correlationId);
5389
6917
  sendStream = stream;
5390
6918
  // AC-001/AC-003: arm the TTF tracking BEFORE the frame goes on the wire. The
@@ -5395,12 +6923,32 @@ export class SessionNodeManager {
5395
6923
  // resolves it (content.delivery.acked) and TTF expiry hands it to the park
5396
6924
  // backstop. The correlationId rides in the frame so the receiver's
5397
6925
  // session.content.received shares ONE flow id with the sender.
6926
+ /**
6927
+ * ⚠️ THE TRACKER KEEPS THE PLAINTEXT, AND THAT IS DELIBERATE — 007-CRYPTO.
6928
+ *
6929
+ * When live delivery times out, THIS is the copy the park backstop seals into the relay
6930
+ * mailbox, under `sealToRecipient` to the counterparty's long-term identity key. Encrypting
6931
+ * `content` in place would put a body locked under a session key — one about to be destroyed —
6932
+ * inside the mailbox envelope, and the recipient would open the outer seal onto bytes nothing
6933
+ * can read. It passes every live-delivery test and fails only for messages that park.
6934
+ *
6935
+ * So the session key encrypts the copy that goes ON THE WIRE, below, and nothing else.
6936
+ */
5398
6937
  this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, orderingS1, orderingS2, contentHashAlg);
6938
+ /**
6939
+ * THE WIRE COPY. `content_hash` above was computed over the PLAINTEXT and stays that way: the
6940
+ * transcript, the seal and the salted hash all depend on it meaning what it means today, and
6941
+ * the receiver decrypts before it verifies.
6942
+ */
6943
+ const wireBody = sealSessionContent(sessionKey, content);
5399
6944
  const frame = encodeCbor({
5400
6945
  type: "content_frame",
5401
6946
  session_id: sessionId,
5402
6947
  content_hash: contentHash,
5403
- content_bytes: content,
6948
+ content_bytes: wireBody,
6949
+ // WHICH scheme the body is under. Present because the receiver must not have to guess from
6950
+ // a length, and absent is not a valid state — a frame without it is refused, not read raw.
6951
+ content_encryption: SESSION_CONTENT_ENCRYPTION_V1,
5404
6952
  correlation_id: correlationId,
5405
6953
  // DOD-MSG-4 (self-ordering): the relay's signed ordering record, so the receiver verifies +
5406
6954
  // orders from the frame ALONE (no dependence on the separate leaf_deliver witness timing).
@@ -5436,7 +6984,7 @@ export class SessionNodeManager {
5436
6984
  // on the content hash. A false delivered costs the message.
5437
6985
  await stream.close();
5438
6986
  this.#clearSessionImpairment(agentName, sessionId, "direct_send", correlationId);
5439
- return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
6987
+ return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
5440
6988
  }
5441
6989
  catch (err) {
5442
6990
  this.#markSessionImpaired(agentName, sessionId, { cause: "direct_send", error: err instanceof Error ? err.message : String(err), correlationId });
@@ -5483,7 +7031,7 @@ export class SessionNodeManager {
5483
7031
  const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2, contentHashAlg);
5484
7032
  if (attempt.outcome === "parked") {
5485
7033
  this.#noteImpairmentRetention(agentName, sessionId, "parked");
5486
- return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
7034
+ return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
5487
7035
  }
5488
7036
  // M12-P12: the deposit was refused, and #untrackAwaitingAck above already dropped the
5489
7037
  // in-memory entry — so without this, NOTHING holds the content and the TTF timer that would
@@ -5575,6 +7123,8 @@ export class SessionNodeManager {
5575
7123
  // Carried on the failure path too: a DURABLY QUEUED message still owns the position the
5576
7124
  // relay witnessed for it before delivery was attempted, and its leaf must go there.
5577
7125
  ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }),
7126
+ // …and so does its PROOF, for the same reason. It was signed before the hand-off failed.
7127
+ ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }),
5578
7128
  // M12-P13: the machine-readable half of the distinction below. M12-P12 shipped it in the
5579
7129
  // guidance SENTENCE only, so the callers that have to ACT on it — commit the leaf for a
5580
7130
  // queued message, never for a lost one — would have had to substring-match English. None
@@ -5599,11 +7149,7 @@ export class SessionNodeManager {
5599
7149
  * This is the reason `cause` had to become a code first: the distinction is unbranchable
5600
7150
  * while the field holds an English paragraph.
5601
7151
  */
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.",
7152
+ guidance: parkRefusalGuidance(attempt.cause, durable, attempt.retryAfterMs),
5607
7153
  };
5608
7154
  }
5609
7155
  }
@@ -5624,110 +7170,153 @@ export class SessionNodeManager {
5624
7170
  if ("error" in transport)
5625
7171
  return { ok: false, reason: transport.error };
5626
7172
  const entry = transport;
5627
- // M7-UPGRADE-002 idempotency: this party submits its responder SEAL leaf AT MOST ONCE per
5628
- // session. BOTH cello_close_session and the auto-acknowledge path call here; the first to reach
5629
- // this point wins, the second short-circuits. The check+set is SYNCHRONOUS (before any await) so
5630
- // two near-simultaneous triggers (e.g. B's own close racing A's delivered SEAL ctrl leaf) cannot
5631
- // both submit. Cleared below on a relay submit failure so a genuine retry can proceed.
5632
- // DOD-M12B-INTERRUPTED-ESCALATE-1 THE MARK MUST SURVIVE A RESTART, or one automatic retry
5633
- // permanently forfeits the receipt.
5634
- //
5635
- // `#responderSealSubmitted` is in memory. A session whose close was in flight when the daemon
5636
- // stopped already has our SEAL ctrl leaf in the relay log — and on the next boot the mark is
5637
- // empty, so the restart-seal resolver's automatic close would submit a SECOND one. The
5638
- // directory requires exactly one ctrl leaf (`ctrlLeaves.length !== 1 → unilateral_seal_leaf_invalid`)
5639
- // and the carry is durable, so every future attempt would carry both and be refused forever.
5640
- //
5641
- // The durable evidence already exists and was simply not consulted: our own ctrl leaf is in
5642
- // `session_seal_leaves`. Recover the escalation values from it instead of submitting again.
5643
- if (!this.#responderSealSubmitted.has(sealKey)) {
5644
- const durable = this.#recoverOwnSealCtrlLeaf(agentName, sessionId);
5645
- if (durable === "unknown") {
5646
- // REFUSE, do not submit. A second ctrl leaf makes the session unsealable forever, and the
5647
- // question "is one already there?" just failed to answer. Refusing costs this close; a
5648
- // second leaf costs the receipt permanently.
5649
- return { ok: false, reason: "seal_leaf_recovery_unavailable" };
5650
- }
5651
- if (durable !== "none") {
5652
- this.#logger.info("session.seal.leaf.already_submitted.recovered", {
5653
- sessionId, agentName, sequenceNumber: durable.sequenceNumber,
5654
- impact: "our SEAL ctrl leaf is already in the relay log from a previous run; submitting a second would make this session unsealable forever",
7173
+ /**
7174
+ * DOD-M15-RELAYLEAK-1 release a DETACHED seal transport when this submission is done.
7175
+ *
7176
+ * Only the detached branch registers a session here, so this can never remove a live one. The
7177
+ * client is closed only when it has no sessions left and the cache still holds THIS client —
7178
+ * the same two guards `#detachSessionRelay` uses, and for the same reason: a racing teardown
7179
+ * must not close a freshly-built replacement for the same key.
7180
+ */
7181
+ const releaseDetached = () => {
7182
+ if (transport.releaseOnDone !== true)
7183
+ return;
7184
+ try {
7185
+ entry.relayClient.unregisterSession(sessionId);
7186
+ if (!entry.relayClient.hasSessions()) {
7187
+ for (const [key, cached] of this.#relayClients) {
7188
+ if (cached === entry.relayClient) {
7189
+ cached.close();
7190
+ this.#relayClients.delete(key);
7191
+ break;
7192
+ }
7193
+ }
7194
+ }
7195
+ }
7196
+ catch (err) {
7197
+ this.#logger.warn("session.seal.transport.release_failed", {
7198
+ agentName,
7199
+ sessionId,
7200
+ reason: err instanceof Error ? err.message : String(err),
7201
+ impact: "a detached seal relay client could not be released; it is held until process exit",
5655
7202
  });
5656
- this.#responderSealSubmitted.set(sealKey, durable);
5657
7203
  }
5658
- }
5659
- if (this.#responderSealSubmitted.has(sealKey)) {
5660
- // M8B FINDING-1: carry the FIRST submit's reported root/sequence so a retry close can
5661
- // still escalate to a unilateral seal. A null value means that submit is still in
5662
- // flight return the bare reason and let the caller fall back to the pending path.
5663
- const prior = this.#responderSealSubmitted.get(sealKey);
5664
- return prior
5665
- ? {
5666
- ok: false,
5667
- reason: "responder_seal_already_submitted",
5668
- reportedRootHex: prior.reportedRootHex,
5669
- sequenceNumber: prior.sequenceNumber,
5670
- }
5671
- : { ok: false, reason: "responder_seal_already_submitted" };
5672
- }
5673
- this.#responderSealSubmitted.set(sealKey, null);
5674
- // A throw anywhere before the mark is finalized would strand the null in-flight marker
5675
- // and lock every future close out of escalation (a FINDING-1-shaped deadlock via a
5676
- // different trigger) — clear the mark on any unexpected exception.
7204
+ };
7205
+ /**
7206
+ * ⚠️ try/finally, NOT a call at each return. There are three exits today and adding a release
7207
+ * to each would be a hand-kept list the shape this milestone has been bitten by repeatedly,
7208
+ * where the FOURTH exit added later quietly leaks. Here the release cannot be bypassed by a new
7209
+ * return, and it runs on the throw path too, which is where a leak matters most.
7210
+ */
5677
7211
  try {
5678
- const finalRootHex = this.getSessionTreeRootHex(agentName, sessionId);
5679
- const sealPayload = encodeSealPayload({
5680
- session_id: entry.relaySessionIdBytes,
5681
- final_root: new Uint8Array(Buffer.from(finalRootHex, "hex")),
5682
- close_timestamp: Date.now(),
5683
- attestation: "PENDING",
5684
- });
5685
- // content_hash = SHA-256(0x02 || seal_payload) — the ctrl leaf kind byte is 0x02.
5686
- const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(sealPayload).digest());
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);
5703
- if (!result.ok) {
5704
- // Clear the idempotency mark so a genuine retry (agent close / reconnect) can proceed (DB-001).
7212
+ // M7-UPGRADE-002 idempotency: this party submits its responder SEAL leaf AT MOST ONCE per
7213
+ // session. BOTH cello_close_session and the auto-acknowledge path call here; the first to reach
7214
+ // this point wins, the second short-circuits. The check+set is SYNCHRONOUS (before any await) so
7215
+ // two near-simultaneous triggers (e.g. B's own close racing A's delivered SEAL ctrl leaf) cannot
7216
+ // both submit. Cleared below on a relay submit failure so a genuine retry can proceed.
7217
+ // DOD-M12B-INTERRUPTED-ESCALATE-1 — THE MARK MUST SURVIVE A RESTART, or one automatic retry
7218
+ // permanently forfeits the receipt.
7219
+ //
7220
+ // `#responderSealSubmitted` is in memory. A session whose close was in flight when the daemon
7221
+ // stopped already has our SEAL ctrl leaf in the relay log — and on the next boot the mark is
7222
+ // empty, so the restart-seal resolver's automatic close would submit a SECOND one. The
7223
+ // directory requires exactly one ctrl leaf (`ctrlLeaves.length !== 1 → unilateral_seal_leaf_invalid`)
7224
+ // and the carry is durable, so every future attempt would carry both and be refused forever.
7225
+ //
7226
+ // The durable evidence already exists and was simply not consulted: our own ctrl leaf is in
7227
+ // `session_seal_leaves`. Recover the escalation values from it instead of submitting again.
7228
+ if (!this.#responderSealSubmitted.has(sealKey)) {
7229
+ const durable = this.#recoverOwnSealCtrlLeaf(agentName, sessionId);
7230
+ if (durable === "unknown") {
7231
+ // REFUSE, do not submit. A second ctrl leaf makes the session unsealable forever, and the
7232
+ // question "is one already there?" just failed to answer. Refusing costs this close; a
7233
+ // second leaf costs the receipt permanently.
7234
+ return { ok: false, reason: "seal_leaf_recovery_unavailable" };
7235
+ }
7236
+ if (durable !== "none") {
7237
+ this.#logger.info("session.seal.leaf.already_submitted.recovered", {
7238
+ sessionId, agentName, sequenceNumber: durable.sequenceNumber,
7239
+ impact: "our SEAL ctrl leaf is already in the relay log from a previous run; submitting a second would make this session unsealable forever",
7240
+ });
7241
+ this.#responderSealSubmitted.set(sealKey, durable);
7242
+ }
7243
+ }
7244
+ if (this.#responderSealSubmitted.has(sealKey)) {
7245
+ // M8B FINDING-1: carry the FIRST submit's reported root/sequence so a retry close can
7246
+ // still escalate to a unilateral seal. A null value means that submit is still in
7247
+ // flight — return the bare reason and let the caller fall back to the pending path.
7248
+ const prior = this.#responderSealSubmitted.get(sealKey);
7249
+ return prior
7250
+ ? {
7251
+ ok: false,
7252
+ reason: "responder_seal_already_submitted",
7253
+ reportedRootHex: prior.reportedRootHex,
7254
+ sequenceNumber: prior.sequenceNumber,
7255
+ }
7256
+ : { ok: false, reason: "responder_seal_already_submitted" };
7257
+ }
7258
+ this.#responderSealSubmitted.set(sealKey, null);
7259
+ // A throw anywhere before the mark is finalized would strand the null in-flight marker
7260
+ // and lock every future close out of escalation (a FINDING-1-shaped deadlock via a
7261
+ // different trigger) — clear the mark on any unexpected exception.
7262
+ try {
7263
+ const finalRootHex = this.getSessionTreeRootHex(agentName, sessionId);
7264
+ const sealPayload = encodeSealPayload({
7265
+ session_id: entry.relaySessionIdBytes,
7266
+ final_root: new Uint8Array(Buffer.from(finalRootHex, "hex")),
7267
+ close_timestamp: Date.now(),
7268
+ attestation: "PENDING",
7269
+ });
7270
+ // content_hash = SHA-256(0x02 || seal_payload) — the ctrl leaf kind byte is 0x02.
7271
+ const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(sealPayload).digest());
7272
+ /**
7273
+ * ⚠️ `sealPayload` IS PASSED, AND ITS ABSENCE WAS THE WHOLE DEFECT — `DOD-M15-SEALWIRE-1`
7274
+ * bullets 3+4, review pass 1, F1.
7275
+ *
7276
+ * These exact bytes were computed two lines above, hashed, and then dropped: `submitLeaf` had
7277
+ * no parameter for them. So the directory received a SHA-256 pre-image nobody transmitted, and
7278
+ * the client's SIGNED `final_root` — the one value in the seal the relay cannot produce — was
7279
+ * unrecoverable. Four legs of this line shipped and were reviewed green while the head of the
7280
+ * chain did not exist.
7281
+ *
7282
+ * The payload and the hash MUST come from the same derivation. If they ever diverge the
7283
+ * directory reports `seal_payload_unbound`, whose guidance says *"someone between them and here
7284
+ * altered or fabricated the payload — the relay is the only party on that path"* — a correct
7285
+ * relay accused by name, in an error written to sound like an attack, for a mismatch made here.
7286
+ */
7287
+ const result = await entry.relayClient.submitLeaf(entry.node, entry.relaySessionIdBytes, contentHash, LEAF_KIND_CTRL, sealPayload);
7288
+ if (!result.ok) {
7289
+ // Clear the idempotency mark so a genuine retry (agent close / reconnect) can proceed (DB-001).
7290
+ this.#responderSealSubmitted.delete(sealKey);
7291
+ this.#logger.warn("session.seal.leaf.submit.failed", { sessionId, reason: result.reason, correlationId });
7292
+ return { ok: false, reason: result.reason };
7293
+ }
7294
+ // SESSION-002: the reported_root for a unilateral seal is the content-hash root the
7295
+ // local tree WOULD have with this SEAL ctrl leaf appended — the same root the directory
7296
+ // rebuilds from the relay's content-hash chain (the relay records the identical
7297
+ // content_hash for this ctrl leaf). Computed without mutating the durable tree /
7298
+ // message_count, so the bilateral + interrupted seal paths are unaffected.
7299
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
7300
+ const reportedRootHex = this.getSessionTree(agentName, sessionId).rootWithAppendedHex(contentHashHex);
7301
+ // M8B FINDING-1: durably associate the submit's escalation values with the idempotency
7302
+ // mark, so any LATER close call can retrieve them via the already-submitted result.
7303
+ this.#responderSealSubmitted.set(sealKey, { reportedRootHex, sequenceNumber: result.sequence_number });
7304
+ this.#logger.info("session.seal.leaf.submitted", {
7305
+ sessionId,
7306
+ sequenceNumber: result.sequence_number,
7307
+ correlationId,
7308
+ });
7309
+ // M7-UPGRADE-002: #responderSealSubmitted was set synchronously at the top of this method —
7310
+ // the guard now blocks any second submit (auto-ack OR a redelivered counterparty SEAL ctrl leaf).
7311
+ return { ok: true, sequenceNumber: result.sequence_number, reportedRootHex };
7312
+ }
7313
+ catch (err) {
5705
7314
  this.#responderSealSubmitted.delete(sealKey);
5706
- this.#logger.warn("session.seal.leaf.submit.failed", { sessionId, reason: result.reason, correlationId });
5707
- return { ok: false, reason: result.reason };
5708
- }
5709
- // SESSION-002: the reported_root for a unilateral seal is the content-hash root the
5710
- // local tree WOULD have with this SEAL ctrl leaf appended — the same root the directory
5711
- // rebuilds from the relay's content-hash chain (the relay records the identical
5712
- // content_hash for this ctrl leaf). Computed without mutating the durable tree /
5713
- // message_count, so the bilateral + interrupted seal paths are unaffected.
5714
- const contentHashHex = Buffer.from(contentHash).toString("hex");
5715
- const reportedRootHex = this.getSessionTree(agentName, sessionId).rootWithAppendedHex(contentHashHex);
5716
- // M8B FINDING-1: durably associate the submit's escalation values with the idempotency
5717
- // mark, so any LATER close call can retrieve them via the already-submitted result.
5718
- this.#responderSealSubmitted.set(sealKey, { reportedRootHex, sequenceNumber: result.sequence_number });
5719
- this.#logger.info("session.seal.leaf.submitted", {
5720
- sessionId,
5721
- sequenceNumber: result.sequence_number,
5722
- correlationId,
5723
- });
5724
- // M7-UPGRADE-002: #responderSealSubmitted was set synchronously at the top of this method —
5725
- // the guard now blocks any second submit (auto-ack OR a redelivered counterparty SEAL ctrl leaf).
5726
- return { ok: true, sequenceNumber: result.sequence_number, reportedRootHex };
7315
+ throw err;
7316
+ }
5727
7317
  }
5728
- catch (err) {
5729
- this.#responderSealSubmitted.delete(sealKey);
5730
- throw err;
7318
+ finally {
7319
+ releaseDetached();
5731
7320
  }
5732
7321
  }
5733
7322
  /**
@@ -6240,7 +7829,12 @@ export class SessionNodeManager {
6240
7829
  // one that does not repair: this side declined the salt permanently for this session, so
6241
7830
  // waiting for a reconnect is exactly the wrong advice. Leaving it out of this list would
6242
7831
  // 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.",
7832
+ // DOD-M15-SALTSPLIT-1 review MEDIUM-3: `session.salt.discarded` is the FIFTH cause, and it
7833
+ // was added by the discard without appearing in this tree. Without it an operator whose salt
7834
+ // was deliberately dropped is sent to look for three events that will not be there and then
7835
+ // told a fifth thing that is false — the agreement DID complete here, and was then undone on
7836
+ // purpose.
7837
+ guidance: "Look for session.salt.discarded first: if it is there, this side dropped its salt because the counterparty said it could never hold one, the agreement did complete and was deliberately undone, and a new session is the repair. Otherwise look for session.salt.adoption.refused: 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 four 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
7838
  });
6245
7839
  // DOD-M15-REFUSED-INBOUND-SILENT-1 — and this branch needed it MORE than the two that had it.
6246
7840
  //
@@ -6258,7 +7852,7 @@ export class SessionNodeManager {
6258
7852
  // copy is a second thing to keep true, and the log's version is the one that gets maintained.
6259
7853
  this.noteContentRefusal(agentName, sessionId, "content_hash_salt_unavailable", {
6260
7854
  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.",
7855
+ guidance: "If session.salt.discarded is present, this side dropped its salt on purpose because the counterparty said it could never hold one — a new session is the repair. 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.discarded and session.salt.adoption.refused in the log to tell which. The transcript up to here is intact either way.",
6262
7856
  });
6263
7857
  return { ok: false, reason: "content_hash_salt_unavailable" };
6264
7858
  }
@@ -7128,14 +8722,39 @@ export class SessionNodeManager {
7128
8722
  // Two correct changes, each fine alone, that break where they meet. Caught by running the
7129
8723
  // live enforcers straight after merging main rather than trusting a green unit suite.
7130
8724
  this.#witnessedSeq.get(this.#k(agentName, sessionId))?.delete(contentHashHex);
8725
+ /**
8726
+ * ⚠️ THIS LINE USED TO LOG `ok: routed.ok` AND `reason: routed.reason`, AND NEITHER CAN EVER
8727
+ * BE PRESENT HERE. Removed rather than left, because their absence was read as evidence.
8728
+ *
8729
+ * The producer is `DocumentFrameRouter.routeSync`, and it has four returns — `unshaped`,
8730
+ * `undecodable`, `owner_unresolved`, and the normal path — **none of which sets either
8731
+ * field.** It cannot: the normal path is `void this.#enqueue(...)`, fire-and-forget, so at the
8732
+ * instant this line is written the frame has been CLASSIFIED and QUEUED and nothing has yet
8733
+ * decided whether it will be accepted. The verdict is genuinely not knowable here.
8734
+ *
8735
+ * **What that cost:** `j-stale-session` reported `framesReceived=3 inbound=0`, and the
8736
+ * investigation recorded that `ok` and `reason` were "ABSENT from every line in the run — so
8737
+ * the router returned neither, which is itself the next thread to pull: a routing result that
8738
+ * reports no outcome cannot say whether it accepted or dropped the frame." That thread leads
8739
+ * nowhere. The router did not fail to report an outcome; **it has no outcome to report at this
8740
+ * point in the flow**, and a JSON logger omits an `undefined` field, so a structural absence
8741
+ * looked exactly like a fault. A field that can never be populated is worse than no field.
8742
+ *
8743
+ * **Where the verdict actually lands**, named here so the next reader does not have to find it
8744
+ * the hard way: a refusal is `document.frame.refused` (warn, carrying `kind` + `reason`,
8745
+ * emitted from `#enqueue`'s continuation under the same `correlationId`). Acceptance is
8746
+ * silent on this event. So "was this frame ingested?" is answered by joining on
8747
+ * `correlationId`, never by reading this line alone.
8748
+ */
7131
8749
  this.#logger.info("session.document.received", {
7132
8750
  sessionId,
7133
8751
  senderPubkey,
7134
8752
  contentHashHex,
7135
8753
  sequenceNumber: leafIndex,
7136
8754
  kind: routed.kind,
7137
- ok: routed.ok,
7138
- reason: routed.reason,
8755
+ // The verdict is asynchronous. Stated positively so absence is not mistaken for silence.
8756
+ dispatch: "queued",
8757
+ verdictEvent: "document.frame.refused",
7139
8758
  correlationId,
7140
8759
  });
7141
8760
  return { leafIndex };
@@ -7481,7 +9100,35 @@ export class SessionNodeManager {
7481
9100
  * and it appends in arrival order as before: with no position there is no discipline to enforce,
7482
9101
  * and refusing would take messaging down whenever the relay is unreachable.
7483
9102
  */
7484
- placeOwnLeaf(agentName, sessionId, contentHashHex, sentBytes, assignedSeq, correlationId, kind = "msg") {
9103
+ placeOwnLeaf(agentName, sessionId, contentHashHex, sentBytes, assignedSeq, correlationId,
9104
+ /**
9105
+ * ⚠️ NO DEFAULT, for the same reason `authorship` has none.
9106
+ *
9107
+ * `kind = "msg"` meant a caller writing a `doc` or a `ctrl` leaf got a `msg` leaf by saying
9108
+ * nothing, and the tree recorded a leaf kind the author never chose. TypeScript also forbids a
9109
+ * required parameter after a defaulted one, so leaving the default here would have forced
9110
+ * `authorship` back to optional — which is the defect above. Every one of the seven call sites
9111
+ * already passed a kind or wanted "msg"; making it explicit cost nothing and removes a second
9112
+ * silent answer from the same signature.
9113
+ */
9114
+ kind,
9115
+ /**
9116
+ * DOD-M15-SEALWIRE-1 bullet 5 — the proof for THIS send, so a held row keeps it.
9117
+ *
9118
+ * ⚠️ REQUIRED, AND `undefined` IS A VALID ANSWER — the two are not the same thing.
9119
+ *
9120
+ * This was `authorship?:` for exactly one review cycle, and in that cycle THREE of the seven
9121
+ * call sites omitted it: `daemon.ts` 1440, 1671, 1685 — the away-reply path, which is the
9122
+ * highest-traffic sent-writer in the daemon and the one with no human watching it. All three
9123
+ * had the proof **already in a local variable one line below**, handed to
9124
+ * `recordTranscriptMessage` and not to this method. Nothing went red, because an optional
9125
+ * parameter's whole behaviour on omission is to look deliberate.
9126
+ *
9127
+ * An unwitnessed send genuinely has no proof, so absence must stay expressible. Requiring the
9128
+ * parameter keeps that while making the caller SAY it: omission is now a type error, and
9129
+ * `undefined` is a claim the author made rather than one the signature made for them.
9130
+ */
9131
+ authorship) {
7485
9132
  // Hydrate before reading the frontier: a durable hold this process has not read back yet would
7486
9133
  // make the tree look further along than it is.
7487
9134
  this.#ensureHeldRestored(agentName, sessionId);
@@ -7551,7 +9198,7 @@ export class SessionNodeManager {
7551
9198
  held = new Map();
7552
9199
  this.#heldContent.set(key, held);
7553
9200
  }
7554
- held.set(assignedSeq, { content: sentBytes, contentHashHex, correlationId, origin: "sent", kind });
9201
+ held.set(assignedSeq, { content: sentBytes, contentHashHex, correlationId, origin: "sent", kind, ...(authorship ? { authorship } : {}) });
7555
9202
  this.#persistHeldContent(agentName, sessionId, assignedSeq, sentBytes, sentBytes, contentHashHex, false, correlationId, "sent", kind);
7556
9203
  this.#logger.info("session.content.held", {
7557
9204
  sessionId, canonicalSeq: assignedSeq, nextExpected, gap: assignedSeq - nextExpected,
@@ -7731,6 +9378,24 @@ export class SessionNodeManager {
7731
9378
  ...(row.screened_out ? { screenedOut: true } : {}),
7732
9379
  ...(row.origin === "sent" ? { origin: "sent" } : {}),
7733
9380
  ...(row.leaf_kind === "doc" ? { kind: "doc" } : {}),
9381
+ /**
9382
+ * ⚠️ `restoredAcrossRestart` EXISTS TO NAME A LOSS, NOT TO CHANGE BEHAVIOUR — review pass 2, H1.
9383
+ *
9384
+ * `held_content` has no authorship columns, so a SENT message held behind a gap and released
9385
+ * after a daemon restart comes back with **no signature** — its transcript row records
9386
+ * `self_authored` with no proof, indistinguishable from an unwitnessed send. That is the
9387
+ * defect bullet 5 exists to end, reappearing on the recovery path, and it was silent.
9388
+ *
9389
+ * The proof cannot be reconstructed here: it was made over Structure-1 bytes this process no
9390
+ * longer holds. **So the honest move is to say so, not to fabricate one** — a proof that
9391
+ * cannot be checked, presented as one that can, is worse than the absence.
9392
+ *
9393
+ * Two BLOB columns would close it properly (this table already carries two `ALTER TABLE …
9394
+ * ADD COLUMN` migrations, so the pattern exists). Under a frozen gate a log is additive and
9395
+ * tightenable where a schema change is neither, so this announces the loss now and
9396
+ * `DOD-M15-HELD-AUTHORSHIP-1` carries the column.
9397
+ */
9398
+ restoredAcrossRestart: true,
7734
9399
  });
7735
9400
  restored++;
7736
9401
  }
@@ -7794,7 +9459,25 @@ export class SessionNodeManager {
7794
9459
  // OBSERVED, not assumed — the received path already does this. The leaf commits either way,
7795
9460
  // so a dropped transcript write means the operator's OWN message is missing from their own
7796
9461
  // transcript with the chain saying it is there, and nothing anywhere said so.
7797
- if (!this.recordTranscriptMessage(agentName, sessionId, nextExpected, "sent", entry.content, entry.correlationId)) {
9462
+ // bullet 5: the proof was captured at submit time and rides the held entry — see #heldContent.
9463
+ /**
9464
+ * THE RESTART LOSS, ANNOUNCED — review pass 2, H1. Only for an entry that actually crossed a
9465
+ * restart: an in-memory held entry carries its proof, and an unwitnessed send legitimately
9466
+ * has none, so warning on every absent proof would fire on a designed benign state and bury
9467
+ * the one occurrence that means something.
9468
+ */
9469
+ if (entry.restoredAcrossRestart === true && entry.authorship === undefined) {
9470
+ this.#logger.warn("session.content.released.authorship.lost", {
9471
+ agentName, sessionId, sequenceNumber: nextExpected, correlationId: entry.correlationId,
9472
+ impact: "this message was held behind a gap, survived a daemon restart, and is now committed with " +
9473
+ "attribution 'self_authored' and NO signature. Its transcript row asserts its author rather " +
9474
+ "than proving one, and is indistinguishable from a send the relay never witnessed.",
9475
+ guidance: "Not recoverable after the fact — the signature covered Structure-1 bytes this process no longer " +
9476
+ "holds, and fabricating one would be worse than the absence. Tracked as DOD-M15-HELD-AUTHORSHIP-1: " +
9477
+ "held_content needs the two proof columns so a restart carries them.",
9478
+ });
9479
+ }
9480
+ if (!this.recordTranscriptMessage(agentName, sessionId, nextExpected, "sent", entry.content, entry.correlationId, entry.authorship)) {
7798
9481
  this.#logger.error("session.content.released.transcript.failed", {
7799
9482
  agentName, sessionId, sequenceNumber: nextExpected, correlationId: entry.correlationId,
7800
9483
  impact: "this side's own message is committed to the chain but missing from its transcript",
@@ -8269,13 +9952,22 @@ export class SessionNodeManager {
8269
9952
  * is correct today rather than an oversight.
8270
9953
  *
8271
9954
  * 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.
9955
+ * `sha256`. In part B1 that was exactly right and provably so: no sender salted, so every parked
9956
+ * entry in existence had been hashed unsalted.
8274
9957
  *
8275
9958
  * ✅ FIXED IN PART B2a, at BOTH sites: here, and the independent verifier in `content-park.ts`.
8276
9959
  * 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.
9960
+ * `sha256` — which is what a peer predating the field actually used.
9961
+ *
9962
+ * > **⛔ THE LAST SENTENCE HERE READ "Every envelope this build emits is still v2, because
9963
+ * > nothing salts yet." THAT IS FALSE NOW.** B2b-2 turned salting on: a session holding an
9964
+ * > agreed salt hashes under `hmac-sha256-salt-v1`, so this build DOES emit v3 envelopes.
9965
+ * > Rewritten rather than deleted, per `DOD-M15-CLAIM-COMMENTS-1` — the sentence is why the
9966
+ * > staleness survived, and an absence would read as deliberate.
9967
+ * >
9968
+ * > The consequence is not theoretical: a v2 envelope carrying a SALTED hash recomputes unsalted
9969
+ * > at the far end and reports `content_hash_mismatch` — a false tamper claim on honest content,
9970
+ * > which also blocks auto-co-sign at seal. Measured on 2026-08-24.
8279
9971
  *
8280
9972
  * ─── AND THE REFUSAL DOES NOT HOLD — review F2 ────────────────────────────────────────────
8281
9973
  *
@@ -8491,6 +10183,401 @@ export class SessionNodeManager {
8491
10183
  * obvious place to put one. A session that shipped it there could not be repaired: the relay
8492
10184
  * would already hold the salt and every hash it protects.
8493
10185
  */
10186
+ /**
10187
+ * MINT THIS SESSION'S THROWAWAY KEYPAIR — once, at the moment the session becomes active.
10188
+ *
10189
+ * Idempotent on purpose. Three paths make a session active (open, hand-off from the standing
10190
+ * receiver, revive) and a reconnect can re-enter them; minting a second keypair mid-session would
10191
+ * leave the two sides deriving against a moving value, and the symptom — a session that reconnects
10192
+ * and still cannot agree — reads as a network fault rather than as a bug here. `#saltContributionFor`
10193
+ * mints once for exactly the same reason.
10194
+ *
10195
+ * A REVIVED session is not an exception, and this guard is why it took a fix to make that true.
10196
+ * Revival requires status `interrupted`, and the producer of `interrupted` drops the entry without
10197
+ * evicting — so while the secret outlived that path, this guard found it still present and
10198
+ * silently kept a key that had been resident for hours, on the one path where re-keying was
10199
+ * explicitly decided. The interrupt path now destroys it, so the map really is empty by the time
10200
+ * a revival reaches here and it mints fresh (Decisions Carried #5).
10201
+ */
10202
+ #mintSessionEphemeral(agentName, sessionId, correlationId) {
10203
+ const key = this.#k(agentName, sessionId);
10204
+ if (this.#sessionEphemerals.has(key))
10205
+ return;
10206
+ this.#sessionEphemerals.set(key, generateSessionEphemeral());
10207
+ this.#logger.debug("session.ephemeral.minted", {
10208
+ agentName, sessionId, correlationId,
10209
+ // The PUBLIC half only, and only a prefix of it. The secret must never reach a log line, and
10210
+ // an operator correlating two daemons needs an identifier rather than the value.
10211
+ publicKeyPrefix: Buffer.from(this.#sessionEphemerals.get(key).publicKey.subarray(0, 8)).toString("hex"),
10212
+ });
10213
+ }
10214
+ /**
10215
+ * DESTROY THIS SESSION'S THROWAWAY SECRET — 006-CRYPTO, and this is what forward secrecy IS.
10216
+ *
10217
+ * ⚠️ CALLED FROM EVERY SITE THAT DROPS THE `#activeNodes` ENTRY, not from `#evictSessionCaches`,
10218
+ * and review pass 2 finding 2 is why. The first version rode the evict, which sounded right and
10219
+ * was wrong on the path an interrupted session actually takes:
10220
+ *
10221
+ * `markInterruptedWithDetails` drops the entry and DELIBERATELY does not evict — the received
10222
+ * plaintext has to stay drainable and the TTF park timers have to stay armed. So the secret
10223
+ * survived. Then, when that session later sealed, `destroySessionNode` returned at its
10224
+ * `if (!entry) return` — the entry was already gone — and never reached the evict either. The
10225
+ * receipt landed, the session was over, and the secret stayed resident until the process exited.
10226
+ *
10227
+ * A relay blip is the ORDINARY way a session ends badly, so that was the common path, not a corner.
10228
+ * The evict's reasons for keeping the other caches are real and do not transfer: buffered plaintext
10229
+ * must stay readable, and a secret nothing reads must not stay alive.
10230
+ *
10231
+ * Zero THEN drop. Dropping alone leaves the bytes wherever the collector last moved them;
10232
+ * `destroySessionEphemeral` overwrites the one copy this process controls.
10233
+ */
10234
+ #destroySessionEphemeralFor(agentName, sessionId, correlationId) {
10235
+ const key = this.#k(agentName, sessionId);
10236
+ // 007-CRYPTO: the AGREED KEY goes with the ephemeral it was derived from. It is the same secret
10237
+ // one step on — leaving it behind would keep the thing the destruction exists to remove.
10238
+ const agreed = this.#sessionContentKeys.get(key);
10239
+ if (agreed) {
10240
+ agreed.fill(0);
10241
+ this.#sessionContentKeys.delete(key);
10242
+ }
10243
+ this.#sessionContentKeyPeerHalf.delete(key);
10244
+ this.#contentEncryptionReasons.delete(key);
10245
+ const ephemeral = this.#sessionEphemerals.get(key);
10246
+ if (!ephemeral)
10247
+ return;
10248
+ destroySessionEphemeral(ephemeral);
10249
+ this.#sessionEphemerals.delete(key);
10250
+ this.#logger.debug("session.ephemeral.destroyed", { agentName, sessionId, correlationId });
10251
+ }
10252
+ /**
10253
+ * AN INBOUND SIGNED EPHEMERAL — verify, THEN derive. Never the other way round.
10254
+ *
10255
+ * 🚨 A FAILED VERIFICATION IS A SECURITY EVENT AND IT STOPS THE SESSION. It is not a degradation
10256
+ * to unencrypted, and the difference is the whole unit: an unsigned or wrongly-signed key is what
10257
+ * a relay substituting its own looks like, and carrying on unencrypted would hand that relay
10258
+ * exactly the plaintext it was reaching for. Missing, malformed and mismatched take this same
10259
+ * path — a check that is lenient about a missing proof is a check an attacker skips.
10260
+ *
10261
+ * Contrast with a peer that says NOTHING at all: that is an old build, it is not evidence of
10262
+ * anything about them, and it is recorded as `PEER_SILENT` — which still blocks sending, because
10263
+ * there is no unencrypted path to fall back to.
10264
+ */
10265
+ async #handleEphemeralFrame(agentName, sessionId, frame, correlationId) {
10266
+ const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
10267
+ if (!entry)
10268
+ return;
10269
+ /**
10270
+ * THE IDENTITY WE EXPECT is the session's own counterparty record — and its provenance differs
10271
+ * by side, which review F10 measured and an earlier version of this comment overstated.
10272
+ *
10273
+ * INITIATOR: what the OPERATOR asked for (`initiate-session-handler` takes `target_pubkey`).
10274
+ * RESPONDER: the initiator identity the DIRECTORY attested in the offer/assignment.
10275
+ *
10276
+ * So this binds the ephemeral to that identity, whichever it is. The attack it closes is the
10277
+ * RELAY substituting its own key — a different actor from the directory — and that is closed in
10278
+ * both directions. What it does NOT do is move the responder's trust off the directory; the
10279
+ * inbound path says as much itself ("a single compromised directory still controls both frames
10280
+ * here"), and that is a separate line.
10281
+ *
10282
+ * The distinction is written down rather than smoothed over because this is a public repo and
10283
+ * the sentence it replaces — "never from anything the directory handed back" — was absolute and
10284
+ * false on one of the two sides.
10285
+ */
10286
+ const expected = Buffer.from(entry.counterpartyPubkey, "hex");
10287
+ const sessionIdBytes = Buffer.from(sessionId, "hex");
10288
+ const verdict = verifySessionEphemeral({
10289
+ expectedIdentityPublic: new Uint8Array(expected),
10290
+ sessionId: sessionIdBytes,
10291
+ peerEphemeralPublic: frame.ephemeralPublic,
10292
+ peerSignature: frame.signature,
10293
+ });
10294
+ if (!verdict.ok) {
10295
+ this.#logger.error("session.key.refused", {
10296
+ agentName, sessionId, correlationId,
10297
+ reason: verdict.reason,
10298
+ detail: verdict.detail,
10299
+ guidance: "STOPPED ON PURPOSE. The session key your counterparty sent could not be tied to them, so " +
10300
+ "this session has been stopped rather than continued in the open. The ordinary cause is a " +
10301
+ "build mismatch; the one that matters is something in the middle of your connection " +
10302
+ "substituting its own key so it can read what you send. Confirm with your counterparty OUT " +
10303
+ "OF BAND — not over CELLO — before opening another session with them.",
10304
+ });
10305
+ // Session-ending, not per-message: one proven wrong signer is evidence about the CONNECTION,
10306
+ // not about the frame that carried it.
10307
+ await this.#freezeSessionForKeyRefusal(agentName, sessionId, verdict.reason, correlationId);
10308
+ return;
10309
+ }
10310
+ /**
10311
+ * ALREADY AGREED WITH **THIS** PEER HALF — idempotence keyed on the bytes, not on presence.
10312
+ *
10313
+ * ⚠️ KEYING IT ON PRESENCE WAS A DEFECT, and a routine relay roll was enough to trigger it.
10314
+ * Only the side whose witness stream closed interrupts, so only that side destroys its key and
10315
+ * re-keys on revival. The OTHER side is never torn down — nothing else clears this map — so it
10316
+ * saw the peer's NEW ephemeral, found a key already present, and kept the old one.
10317
+ *
10318
+ * Two different keys, and the damage is worse than a dead path: every message then fails GCM,
10319
+ * and the receiving daemon reports *"the message did not decrypt — it was modified in flight, or
10320
+ * encrypted under a different key"* and tells the operator to confirm OUT OF BAND. Nothing was
10321
+ * modified. Two people have a security conversation about a local key skew.
10322
+ *
10323
+ * I MEASURED THIS TWICE AND CALLED IT A HARNESS QUIRK — the notes in `seam-4` and `m9-core-001`
10324
+ * about seeding "before the settle" leaving the two ends on different keys are this defect,
10325
+ * observed and worked around instead of read.
10326
+ *
10327
+ * So: the same half re-announced on every connect is still a no-op, and a DIFFERENT half — which
10328
+ * only a re-keying peer sends — is adopted. The peer is identity-authenticated by the time we
10329
+ * get here, so letting them move the key is not a new capability.
10330
+ */
10331
+ const peerHalfHex = Buffer.from(frame.ephemeralPublic).toString("hex");
10332
+ if (this.#sessionContentKeyPeerHalf.get(this.#k(agentName, sessionId)) === peerHalfHex)
10333
+ return;
10334
+ const ownEphemeral = this.#sessionEphemeralFor(agentName, sessionId);
10335
+ if (!ownEphemeral) {
10336
+ this.#logger.error("session.key.refused", {
10337
+ agentName, sessionId, correlationId, reason: "no_local_ephemeral",
10338
+ detail: "the peer's key verified but this side holds no throwaway keypair to agree with, so nothing can be derived. This is a LOCAL defect, not something the peer did.",
10339
+ });
10340
+ this.#noteContentEncryptionReason(agentName, sessionId, CONTENT_ENCRYPTION_REASONS.OUR_ANNOUNCE_FAILED);
10341
+ return;
10342
+ }
10343
+ try {
10344
+ const secrets = deriveSessionSecrets({
10345
+ ownEphemeralSecret: ownEphemeral.secretKey,
10346
+ peerEphemeralPublic: frame.ephemeralPublic,
10347
+ sessionId: sessionIdBytes,
10348
+ });
10349
+ const prior = this.#sessionContentKeyPeerHalf.get(this.#k(agentName, sessionId));
10350
+ this.#sessionContentKeys.set(this.#k(agentName, sessionId), secrets.contentKey);
10351
+ this.#sessionContentKeyPeerHalf.set(this.#k(agentName, sessionId), peerHalfHex);
10352
+ this.#contentEncryptionReasons.delete(this.#k(agentName, sessionId));
10353
+ this.#logger.info("session.key.agreed", {
10354
+ agentName, sessionId, correlationId,
10355
+ // A RE-KEY is a different event from a first agreement and an operator correlating two
10356
+ // daemons needs to tell them apart: a re-key means the other side restarted.
10357
+ rekey: prior !== undefined && prior !== peerHalfHex,
10358
+ impact: "message bodies on this session are now encrypted by CELLO under a key both sides agreed and neither sent, and which is destroyed when the session ends",
10359
+ });
10360
+ }
10361
+ catch (err) {
10362
+ // The primitive owns every rule about the peer's half — a degenerate point, a non-canonical
10363
+ // encoding, a reflection — and owns the WORDING. Substituting a code of our own here would
10364
+ // destroy the only explanation that exists at the only moment anyone reads it.
10365
+ this.#logger.error("session.key.refused", {
10366
+ agentName, sessionId, correlationId, reason: "derivation_failed",
10367
+ detail: err instanceof Error ? err.message : String(err),
10368
+ });
10369
+ await this.#freezeSessionForKeyRefusal(agentName, sessionId, "derivation_failed", correlationId);
10370
+ }
10371
+ }
10372
+ /**
10373
+ * Stop a session whose counterparty's key could not be tied to them.
10374
+ *
10375
+ * Reuses the identity-freeze machinery rather than inventing a second way for a session to stop:
10376
+ * the operator-facing shape, the refusal-to-revive, and the status write are already right there,
10377
+ * and a second mechanism is a second thing to keep correct.
10378
+ */
10379
+ async #freezeSessionForKeyRefusal(agentName, sessionId, reason, correlationId) {
10380
+ /**
10381
+ * THE REASON IS RECORDED BEFORE THE TEARDOWN, AND SURVIVES IT — review F2.
10382
+ *
10383
+ * The teardown destroys this session's key material and used to clear the reason with it, so the
10384
+ * listing recomputed `NOT_YET_AGREED` and the agent was told *"still agreeing its key, sending is
10385
+ * held"* — a reassurance, for the one detection in this unit that means someone may be
10386
+ * substituting keys on the connection. Its only real consumer was a log line.
10387
+ */
10388
+ this.#noteContentEncryptionReason(agentName, sessionId, CONTENT_ENCRYPTION_REASONS.KEY_REFUSED);
10389
+ /**
10390
+ * ⚠️ `"key_refused"`, NOT `"stream_close"` — review F3, and this is error substitution of the
10391
+ * exact kind Invariant 3 names. `stream_close` is written to the row as
10392
+ * `interrupted_by = 'relay_stream_close'`, so a key-authentication refusal was durably recorded
10393
+ * as a relay problem and an operator debugging it would go and look at the relay fleet.
10394
+ */
10395
+ const stopped = await this.markInterruptedWithDetails(agentName, sessionId, 0, "key_refused");
10396
+ /**
10397
+ * OBSERVE THE OUTCOME rather than asserting it — review F11. `markInterruptedWithDetails`
10398
+ * returns early when the row is not `active`, so claiming "the session was stopped" here
10399
+ * unconditionally would state something that did not happen.
10400
+ */
10401
+ this.#logger.error("session.key.session_stopped", {
10402
+ agentName, sessionId, correlationId, reason,
10403
+ stopped,
10404
+ impact: stopped
10405
+ ? "the session was stopped rather than continued unencrypted; a substituted key would otherwise have been handed exactly the plaintext it was reaching for"
10406
+ : "the session was already not active, so nothing was torn down here — the refusal stands and no content was accepted",
10407
+ guidance: CONTENT_ENCRYPTION_GUIDANCE[CONTENT_ENCRYPTION_REASONS.KEY_REFUSED],
10408
+ });
10409
+ }
10410
+ /**
10411
+ * Test seam: see every decoded inbound content frame, as it arrived.
10412
+ *
10413
+ * Review F4. The "bytes on the wire are ciphertext" claim needs the ACTUAL frame; asserting on a
10414
+ * freshly sealed stand-in tests the crypto primitive and stays green when the send path is
10415
+ * reverted to putting plaintext on the wire. There is no other way to reach the decoded frame from
10416
+ * outside — the handler consumes it and hands ingest the plaintext.
10417
+ *
10418
+ * Read-only by construction: the callback receives the frame and cannot influence routing.
10419
+ */
10420
+ observeInboundContentFramesForTest(cb) {
10421
+ this.#inboundFrameObserver = cb;
10422
+ }
10423
+ /** Injected by the daemon once its per-agent key providers exist. See `#keyProviderResolver`. */
10424
+ setKeyProviderResolver(resolver) {
10425
+ this.#keyProviderResolver = resolver;
10426
+ }
10427
+ /**
10428
+ * The agreed content key for a session, or `null` with the reason there is none.
10429
+ *
10430
+ * ONE place decides this, so the send path, the receive path and the status surface cannot
10431
+ * disagree about whether a session is encrypted — the failure `wire-content-hash.ts` already
10432
+ * records for the hash, where the expression was written out at five call sites and the two added
10433
+ * last got it wrong.
10434
+ */
10435
+ #contentEncryptionState(agentName, sessionId) {
10436
+ const k = this.#k(agentName, sessionId);
10437
+ const agreed = this.#sessionContentKeys.get(k);
10438
+ if (agreed)
10439
+ return { key: agreed };
10440
+ // No recorded fault means the exchange simply has not finished yet — the ordinary state in the
10441
+ // instant between a session opening and its first connect completing.
10442
+ return { key: null, reason: this.#contentEncryptionReasons.get(k) ?? CONTENT_ENCRYPTION_REASONS.NOT_YET_AGREED };
10443
+ }
10444
+ #noteContentEncryptionReason(agentName, sessionId, reason) {
10445
+ const k = this.#k(agentName, sessionId);
10446
+ // FIRST reason wins. A later, vaguer one must not overwrite the specific cause already recorded
10447
+ // — "they never answered" written over "we could not sign" points the operator at the wrong
10448
+ // machine, which is the substitution this closed set exists to end.
10449
+ if (!this.#contentEncryptionReasons.has(k))
10450
+ this.#contentEncryptionReasons.set(k, reason);
10451
+ }
10452
+ /**
10453
+ * ANNOUNCE THIS SIDE'S SIGNED EPHEMERAL — `DOD-M15-EPHEMERAL-AUTH-1`.
10454
+ *
10455
+ * 🚨 ON THE PEER-TO-PEER CONTENT STREAM ONLY, exactly like the salt contribution and for the same
10456
+ * unrepairable reason: it rides circuit-relay-v2 carrying its own Noise session, so a forwarding
10457
+ * relay sees ciphertext. It must NEVER be added to `session_offer` / `session_offer_accept` or
10458
+ * anything a DIRECTORY brokers — and that is the trap, because the only round trip at session open
10459
+ * is the directory's signaling stream.
10460
+ *
10461
+ * Fire-and-forget on the connect handler, like the salt: a failed announcement must not turn a
10462
+ * peer-connect handler into a rejected promise, and we re-announce on the next connect.
10463
+ */
10464
+ async #sendEphemeralFrame(agentName, sessionId, correlationId) {
10465
+ const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
10466
+ if (!entry)
10467
+ return;
10468
+ const ephemeral = this.#sessionEphemeralFor(agentName, sessionId);
10469
+ if (!ephemeral) {
10470
+ this.#logger.error("session.key.announce.failed", {
10471
+ agentName, sessionId, correlationId, reason: "no_ephemeral",
10472
+ impact: "this session is active with no throwaway keypair, so there is nothing to announce; content stays unencrypted by CELLO",
10473
+ });
10474
+ this.#noteContentEncryptionReason(agentName, sessionId, CONTENT_ENCRYPTION_REASONS.OUR_ANNOUNCE_FAILED);
10475
+ return;
10476
+ }
10477
+ const signer = this.#keyProviderResolver?.(agentName);
10478
+ if (!signer) {
10479
+ /**
10480
+ * A LOCAL fault, and it is named as one. Without this branch the peer simply never hears from
10481
+ * us and blames a build that is fine — the operator whose machine cannot sign reads a message
10482
+ * about their counterparty, which is the exact substitution the salt work already paid for.
10483
+ */
10484
+ this.#logger.error("session.key.announce.failed", {
10485
+ agentName, sessionId, correlationId, reason: "no_identity_key",
10486
+ impact: "this machine has no identity key for the agent, so it cannot sign its half of the session key; every session it opens is unencrypted by CELLO and the counterparty is not involved",
10487
+ guidance: CONTENT_ENCRYPTION_GUIDANCE[CONTENT_ENCRYPTION_REASONS.NO_LOCAL_IDENTITY],
10488
+ });
10489
+ this.#noteContentEncryptionReason(agentName, sessionId, CONTENT_ENCRYPTION_REASONS.NO_LOCAL_IDENTITY);
10490
+ return;
10491
+ }
10492
+ let stream = null;
10493
+ try {
10494
+ const sessionIdBytes = Buffer.from(sessionId, "hex");
10495
+ const signature = await signSessionEphemeral(signer, sessionIdBytes, ephemeral.publicKey);
10496
+ stream = await entry.node.newStream(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
10497
+ stream.send(lp.encode.single(encodeCbor({
10498
+ type: "session_key_agreement",
10499
+ session_id: sessionId,
10500
+ ephemeral_public: ephemeral.publicKey,
10501
+ ephemeral_sig: signature,
10502
+ })).subarray());
10503
+ await stream.close();
10504
+ this.#logger.debug("session.key.announced", {
10505
+ agentName, sessionId, correlationId,
10506
+ publicKeyPrefix: Buffer.from(ephemeral.publicKey.subarray(0, 8)).toString("hex"),
10507
+ });
10508
+ }
10509
+ catch (err) {
10510
+ // The frame never left. Say so as a LOCAL fault rather than letting the session look like a
10511
+ // counterparty on an old build — a re-announce rides the next connect.
10512
+ this.#logger.error("session.key.announce.failed", {
10513
+ agentName, sessionId, correlationId, reason: "stream_failed",
10514
+ error: err instanceof Error ? err.message : String(err),
10515
+ impact: "this side's half of the session key never reached the counterparty, so content stays unencrypted by CELLO until a later connect succeeds",
10516
+ });
10517
+ this.#noteContentEncryptionReason(agentName, sessionId, CONTENT_ENCRYPTION_REASONS.OUR_ANNOUNCE_FAILED);
10518
+ /**
10519
+ * ABORT, don't just close — review F13. A `close()` on a broken stream can itself fail and
10520
+ * leave the slot held, which is the per-protocol stream-cap failure this file's longest
10521
+ * comment documents. Every other failed write on this protocol aborts.
10522
+ */
10523
+ if (stream) {
10524
+ try {
10525
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
10526
+ }
10527
+ catch { /* already gone */ }
10528
+ }
10529
+ /**
10530
+ * AND RETRY — review F5, and without it a single failed stream open kills encryption for the
10531
+ * life of the session.
10532
+ *
10533
+ * The announce otherwise rides `onPeerConnect` only. If the connection then stays up there is
10534
+ * no next connect, so nothing re-announces, every send parks forever, and the guidance's
10535
+ * "it re-announces on the next connect" names an event that never arrives. It also compounds
10536
+ * the re-key path: a revived session announces its FRESH half exactly once, and if that one
10537
+ * attempt loses the race with the reconnect, the two ends sit on different keys.
10538
+ *
10539
+ * Bounded and self-cancelling: it stops when the session is no longer active, when a key has
10540
+ * been agreed, and after `SESSION_KEY_ANNOUNCE_RETRIES` attempts.
10541
+ */
10542
+ this.#retryEphemeralAnnounce(agentName, sessionId, correlationId, 1);
10543
+ }
10544
+ }
10545
+ /**
10546
+ * Re-announce this side's ephemeral after a failed attempt — review F5.
10547
+ *
10548
+ * Backs off, and gives up rather than looping: a peer that is simply gone must not have a timer
10549
+ * chasing it for the life of the process.
10550
+ */
10551
+ #retryEphemeralAnnounce(agentName, sessionId, correlationId, attempt) {
10552
+ if (attempt > SESSION_KEY_ANNOUNCE_RETRIES) {
10553
+ this.#logger.warn("session.key.announce.gave_up", {
10554
+ agentName, sessionId, correlationId, attempts: SESSION_KEY_ANNOUNCE_RETRIES,
10555
+ impact: "this side never managed to send its half of the session key, so every message on this session takes the relay mailbox instead of the direct path",
10556
+ guidance: CONTENT_ENCRYPTION_GUIDANCE[CONTENT_ENCRYPTION_REASONS.OUR_ANNOUNCE_FAILED],
10557
+ });
10558
+ return;
10559
+ }
10560
+ const timer = setTimeout(() => {
10561
+ // Stop if the session went away, or if a key has since been agreed by any route.
10562
+ if (!this.#activeNodes.has(this.#k(agentName, sessionId)))
10563
+ return;
10564
+ if (this.#sessionContentKeys.has(this.#k(agentName, sessionId)))
10565
+ return;
10566
+ void this.#sendEphemeralFrame(agentName, sessionId, correlationId);
10567
+ }, SESSION_KEY_ANNOUNCE_RETRY_MS * attempt);
10568
+ // Never hold the process open for a retry.
10569
+ if (typeof timer.unref === "function")
10570
+ timer.unref();
10571
+ }
10572
+ /**
10573
+ * Our throwaway keypair for a session, WITHOUT minting one — the read-only counterpart.
10574
+ *
10575
+ * `null` means the session is not active here. It never means "mint one now": minting outside
10576
+ * `#mintSessionEphemeral` is how a second keypair appears mid-session.
10577
+ */
10578
+ #sessionEphemeralFor(agentName, sessionId) {
10579
+ return this.#sessionEphemerals.get(this.#k(agentName, sessionId)) ?? null;
10580
+ }
8494
10581
  #saltContributionFor(agentName, sessionId) {
8495
10582
  const key = this.#k(agentName, sessionId);
8496
10583
  let contribution = this.#saltContributions.get(key);
@@ -8542,6 +10629,114 @@ export class SessionNodeManager {
8542
10629
  setSaltContributionForTest(agentName, sessionId, contribution) {
8543
10630
  this.#saltContributions.set(this.#k(agentName, sessionId), contribution);
8544
10631
  }
10632
+ /**
10633
+ * Test seams: re-enter the mint path, and read back the PUBLIC half — 006-CRYPTO.
10634
+ *
10635
+ * `#mintSessionEphemeral` is idempotent because a reconnect can re-enter an activation path, and a
10636
+ * second keypair mid-session would leave the two sides deriving against a moving value. Proving
10637
+ * that needs the path called TWICE, and the alternative — driving a real reconnect — drags in node
10638
+ * rebuild and relay reconnection, none of which the property is about. Same justification as
10639
+ * `forgetSaltContributionForTest` above.
10640
+ *
10641
+ * It calls the REAL private method, so a test cannot pass against a decision production does not
10642
+ * make. The reader returns the public half ONLY: a seam that could hand out the secret is a way
10643
+ * for the secret to leave this object, which is the one thing the whole unit is about.
10644
+ */
10645
+ mintSessionEphemeralForTest(agentName, sessionId) {
10646
+ this.#mintSessionEphemeral(agentName, sessionId);
10647
+ }
10648
+ sessionEphemeralPublicForTest(agentName, sessionId) {
10649
+ const e = this.#sessionEphemeralFor(agentName, sessionId);
10650
+ return e ? Uint8Array.from(e.publicKey) : null;
10651
+ }
10652
+ /**
10653
+ * Test seam: INSTALL a keypair the caller already holds — the only way to prove ZEROING.
10654
+ *
10655
+ * Presence is easy to assert and is not the property. `destroySessionEphemeral` overwrites the
10656
+ * buffer before the entry is dropped, and a mutant that drops without overwriting leaves the
10657
+ * secret wherever the collector last moved it while passing every presence check — which is
10658
+ * exactly what happened: the shutdown zeroing shipped with a surviving mutant, and the transport
10659
+ * seeds four lines above it have the same untested gap today.
10660
+ *
10661
+ * The direction matters. Nothing here HANDS OUT a secret — the test supplies an object it already
10662
+ * owns and then inspects its own reference. A reader that returned the live keypair would be a
10663
+ * path for the secret to leave this object, which is the one thing this unit exists to prevent.
10664
+ */
10665
+ setSessionEphemeralForTest(agentName, sessionId, ephemeral) {
10666
+ this.#sessionEphemerals.set(this.#k(agentName, sessionId), ephemeral);
10667
+ }
10668
+ /**
10669
+ * Test seam: put a session into the state a COMPLETED exchange leaves it in — 007-CRYPTO.
10670
+ *
10671
+ * A live send now requires an agreed key, because there is no plaintext path to fall back to. In
10672
+ * production the exchange completes on connect, before any send. A fixture with no real peer never
10673
+ * completes it, so without this every content test in the repo would be exercising the refusal
10674
+ * path instead of the thing it was written for.
10675
+ *
10676
+ * ⚠️ IT SHORT-CIRCUITS HOW THE KEY GOT THERE, NEVER WHAT THE KEY IS FOR. The state it produces —
10677
+ * a session holding an agreed content key — is exactly the production state, which is what makes
10678
+ * it legitimate; `setSaltContributionForTest` exists for the same reason. Tests of the EXCHANGE
10679
+ * itself drive the real signed frames and must not use this.
10680
+ */
10681
+ setSessionContentKeyForTest(agentName, sessionId, key) {
10682
+ this.#sessionContentKeys.set(this.#k(agentName, sessionId), Uint8Array.from(key));
10683
+ this.#contentEncryptionReasons.delete(this.#k(agentName, sessionId));
10684
+ }
10685
+ /**
10686
+ * Test seam: drop the agreed key while leaving the session up — the state before an exchange
10687
+ * completes, and after a teardown evicts one. Its mirror above is what a completed exchange
10688
+ * leaves; both are needed, or a status field stuck in one position passes either test alone.
10689
+ */
10690
+ forgetSessionContentKeyForTest(agentName, sessionId) {
10691
+ this.#sessionContentKeys.delete(this.#k(agentName, sessionId));
10692
+ }
10693
+ /**
10694
+ * Test seam: produce THIS side's signed ephemeral, using the manager's own identity resolver.
10695
+ *
10696
+ * For harnesses whose connectivity is one-directional — one side dials, so only one announce ever
10697
+ * lands. Carrying the other side's half across with a REAL signature is what completes the
10698
+ * exchange, and it beats seeding a key: a seeded key has no peer half recorded against it, so the
10699
+ * first genuine announce replaces it and the two ends drift apart (which is correct behaviour —
10700
+ * see the re-key guard — and exactly what made seeding fragile here).
10701
+ *
10702
+ * It signs with the same provider production signs with, so a test cannot pass against a signature
10703
+ * production would have refused.
10704
+ */
10705
+ async signOwnEphemeralForTest(agentName, sessionId) {
10706
+ const eph = this.#sessionEphemeralFor(agentName, sessionId);
10707
+ const signer = this.#keyProviderResolver?.(agentName);
10708
+ if (!eph || !signer)
10709
+ return null;
10710
+ const signature = await signSessionEphemeral(signer, Buffer.from(sessionId, "hex"), eph.publicKey);
10711
+ return { ephemeralPublic: Uint8Array.from(eph.publicKey), signature };
10712
+ }
10713
+ /**
10714
+ * Test seam: deliver a peer's signed ephemeral, exactly as the content-stream decoder does.
10715
+ *
10716
+ * For harnesses whose connectivity is one-directional — one side dials, so only one announce ever
10717
+ * lands — this is what completes the exchange instead of stuffing a key in. It runs the REAL
10718
+ * verification and the REAL derivation, so a test cannot pass against a signature production would
10719
+ * have refused.
10720
+ */
10721
+ async handleEphemeralFrameForTest(agentName, sessionId, frame, correlationId = "test") {
10722
+ await this.#handleEphemeralFrame(agentName, sessionId, frame, correlationId);
10723
+ }
10724
+ /**
10725
+ * Test seam: deliver an inbound salt frame, exactly as the content-stream decoder does.
10726
+ *
10727
+ * 006-CRYPTO finding 2. WHICH of the four reasons the peer gave decides what the operator is told,
10728
+ * and reaching that decision from a test otherwise needs a second live daemon that has closed
10729
+ * adoption for a specific reason — which is not something a counterparty can be asked to do on
10730
+ * demand. The four labels are the whole point of the finding, so they need to be reachable.
10731
+ *
10732
+ * It calls the REAL private handler rather than reproducing its routing, so a test cannot pass
10733
+ * against a decision production does not make. It takes the DECODED frame, so it deliberately
10734
+ * does NOT stand in for the decoder above it — the length and vocabulary checks there have their
10735
+ * own tests driving `handleContentFrameForTest`.
10736
+ */
10737
+ async handleSaltFrameForTest(agentName, sessionId, frame, correlationId = "test") {
10738
+ await this.#handleSaltFrame(agentName, sessionId, frame, correlationId);
10739
+ }
8545
10740
  /**
8546
10741
  * Test seam: drop this session's own half while leaving the stored salt in place — the state every
8547
10742
  * teardown produces, because `#evictSessionCaches` clears the map and the row survives.
@@ -8599,6 +10794,26 @@ export class SessionNodeManager {
8599
10794
  async contentHashForSession(agentName, sessionId, content) {
8600
10795
  const { salt, reason } = await this.#saltForHashing(agentName, sessionId);
8601
10796
  if (salt !== null) {
10797
+ /**
10798
+ * ⚠️ THE SALTED HASH MARKS ITSELF SPENT — `DOD-M15-SALTSPLIT-1` review pass 1, HIGH-2.
10799
+ *
10800
+ * The unsalted branch below has counted itself since review pass 2 F1, for a reason stated
10801
+ * there in full: between hashing and `#trackAwaitingAck` there is a relay round trip, and in
10802
+ * that window leaves, held content and awaiting-ack ALL read zero. **The salted direction was
10803
+ * left with no counterpart**, which was harmless while nothing acted on the answer — and
10804
+ * `#discardUnspentSalt` is the first code that acts on it destructively.
10805
+ *
10806
+ * Without this, a peer's `adoption_closed` frame arriving inside that window finds adoption
10807
+ * "open", discards the salt, and the message already on the wire carries
10808
+ * `content_hash_alg: hmac-salt-v1` with a hash **nobody — including this daemon — can ever
10809
+ * recompute**. The alg is copied verbatim into the parked envelope on TTF expiry, so it
10810
+ * survives the round trip that would otherwise have hidden it.
10811
+ *
10812
+ * A COUNT, not a bit, for the same reason the unsalted side is a count: two connections can be
10813
+ * mid-send at once, and one finishing must not clear the claim the other is still relying on.
10814
+ */
10815
+ const key = this.#k(agentName, sessionId);
10816
+ this.#hashedWithSalt.set(key, (this.#hashedWithSalt.get(key) ?? 0) + 1);
8602
10817
  const alg = CONTENT_HASH_ALGS.HMAC_SALT_V1;
8603
10818
  return { hash: contentHashFor(content, { alg, salt }), alg };
8604
10819
  }
@@ -8625,6 +10840,58 @@ export class SessionNodeManager {
8625
10840
  * run, and the entries then outlive the session they describe. There is also nothing to protect:
8626
10841
  * a session that no longer exists cannot adopt a salt or split a transcript.
8627
10842
  */
10843
+ /**
10844
+ * ⚠️ THE DEFERRED ERASE — `DOD-M15-SALTSPLIT-1`. This is the moment a suspended salt becomes both
10845
+ * harmless to erase and NECESSARY to erase, and it must run BEFORE the count below.
10846
+ *
10847
+ * Harmless: this session has hashed nothing under the salt, which is what let it be suspended.
10848
+ * Necessary: we are about to hash unsalted, and a salt left on disk reads back fine after a
10849
+ * restart — so the next process would hash salted and the transcript would be split down the
10850
+ * middle by a reboot rather than by any frame.
10851
+ *
10852
+ * **Before the `#hashedWithoutSalt` increment on purpose.** `#discardUnspentSalt` refuses to erase
10853
+ * once adoption is closed, and that counter is one of the things that closes it — increment first
10854
+ * and the erase we just decided is correct gets refused by our own guard, leaving exactly the
10855
+ * split this ordering exists to prevent.
10856
+ */
10857
+ if (reason !== UNSALTED_REASONS.SESSION_TORN_DOWN && this.#saltSuspended.has(this.#k(agentName, sessionId))) {
10858
+ /**
10859
+ * ⚠️ GOING UNSALTED AND ERASING THE SALT ARE ONE DECISION — pass 2, F2 (HIGH), and this is my
10860
+ * regression, not a pre-existing one.
10861
+ *
10862
+ * The note above claimed the ordering was sufficient because `#hashedWithoutSalt` is what
10863
+ * closes adoption. **It is one of FOUR contributors.** Leaves, held rows and awaiting-ack close
10864
+ * it too — and the most ordinary event in the protocol closes it: *the peer sends us its next
10865
+ * message.* Reproduced through the real inbound path: suspend, peer's message lands as leaf 0,
10866
+ * we hash `sha256`, and the erase is REFUSED with `already_hashing` while the bytes stay on
10867
+ * disk. One teardown-and-revive later — no process restart required — we hash `hmac` again.
10868
+ * That is the split transcript, produced by the fix for the split transcript.
10869
+ *
10870
+ * Worth naming precisely: **the immediate-erase design this replaced could NOT produce it.**
10871
+ * There, a refused discard simply kept the session salted — one rule throughout, and loud.
10872
+ * Suspension is what made "unsalted now, salted later" reachable. Same shape as pass 1: the fix
10873
+ * worse than the defect on one path.
10874
+ *
10875
+ * So the two are atomic. If the salt cannot be erased, we do **not** go unsalted — we keep
10876
+ * hashing under the held salt, which is one rule for the whole session, and say so at ERROR.
10877
+ * The counterparty may refuse those messages, and that is the honest failure: a dead session
10878
+ * beats a transcript no single rule can verify. The durable column remains the real answer.
10879
+ */
10880
+ if (!this.#discardUnspentSalt(agentName, sessionId)) {
10881
+ const stillHeld = this.#getSessionSalt(agentName, sessionId);
10882
+ if (stillHeld !== null) {
10883
+ const key = this.#k(agentName, sessionId);
10884
+ this.#logger.error("session.salt.split", {
10885
+ agentName, sessionId, reason: "suspended_but_unerasable",
10886
+ impact: "this session stays SALTED even though the counterparty says it can never hold a salt, because the salt could not be erased and hashing unsalted now would leave half this transcript under each rule — verifiable by nobody. Expect the counterparty to refuse messages sent from here.",
10887
+ guidance: "Start a new session with this counterparty: the salt agreement runs at open, before anything is hashed. This one cannot be repaired — look for session.salt.discard.refused immediately above for why the salt could not be released.",
10888
+ });
10889
+ this.#hashedWithSalt.set(key, (this.#hashedWithSalt.get(key) ?? 0) + 1);
10890
+ const alg = CONTENT_HASH_ALGS.HMAC_SALT_V1;
10891
+ return { hash: contentHashFor(content, { alg, salt: stillHeld }), alg };
10892
+ }
10893
+ }
10894
+ }
8628
10895
  if (reason !== UNSALTED_REASONS.SESSION_TORN_DOWN) {
8629
10896
  /**
8630
10897
  * ⚠️ A COUNT, NOT A BIT — review pass 2, F1 (HIGH). It was a `Set`, and that made it ONE FLAG
@@ -8675,10 +10942,25 @@ export class SessionNodeManager {
8675
10942
  * bound expires.
8676
10943
  */
8677
10944
  async #saltForHashing(agentName, sessionId) {
10945
+ const key = this.#k(agentName, sessionId);
8678
10946
  const held = this.#getSessionSalt(agentName, sessionId);
8679
- if (held !== null)
10947
+ if (held !== null) {
10948
+ /**
10949
+ * SUSPENDED BEATS HELD — `DOD-M15-SALTSPLIT-1`. The peer has said it can never hold a salt, so
10950
+ * hashing under ours produces a message it must refuse. We hold one and deliberately do not
10951
+ * use it.
10952
+ *
10953
+ * ⚠️ An earlier note here said `PEER_CLOSED_ADOPTION` "already carries exactly the right
10954
+ * guidance, so no new reason is needed and none is invented." That was right about not
10955
+ * inventing a reason and wrong about which one applies: the peer can suspend us for any of
10956
+ * four reasons, and the one hardcoded here asserted the most flattering of them. It now asks
10957
+ * the same mapping every other closed path asks (006-CRYPTO finding 2).
10958
+ */
10959
+ if (this.#saltSuspended.has(key)) {
10960
+ return { salt: null, reason: this.#peerClosedReason(key) };
10961
+ }
8680
10962
  return { salt: held };
8681
- const key = this.#k(agentName, sessionId);
10963
+ }
8682
10964
  if (this.#saltAdoptionClosed(agentName, sessionId).closed) {
8683
10965
  return { salt: null, reason: UNSALTED_REASONS.ADOPTION_CLOSED_LOCALLY };
8684
10966
  }
@@ -8688,7 +10970,7 @@ export class SessionNodeManager {
8688
10970
  // "your counterparty was not connected", and only an absent entry means it.
8689
10971
  const last = this.#saltLastOutcome.get(key);
8690
10972
  if (last !== undefined)
8691
- return { salt: null, reason: this.#reasonForOutcome(last) };
10973
+ return { salt: null, reason: this.#reasonForOutcome(key, last) };
8692
10974
  return { salt: null, reason: UNSALTED_REASONS.NO_AGREEMENT_STARTED };
8693
10975
  }
8694
10976
  const settled = await pending.settled;
@@ -8795,15 +11077,41 @@ export class SessionNodeManager {
8795
11077
  * ONE mapping from a settled outcome to the operator-facing reason, so the send that WAITED and the
8796
11078
  * send that arrived afterwards cannot disagree about what happened.
8797
11079
  */
8798
- #reasonForOutcome(outcome) {
11080
+ #reasonForOutcome(key, outcome) {
8799
11081
  if (outcome === "announce_failed")
8800
11082
  return UNSALTED_REASONS.ANNOUNCE_FAILED;
8801
11083
  if (outcome === "persist_failed")
8802
11084
  return UNSALTED_REASONS.OUR_PERSIST_FAILED;
8803
11085
  if (outcome === "closed")
8804
- return UNSALTED_REASONS.PEER_CLOSED_ADOPTION;
11086
+ return this.#peerClosedReason(key);
8805
11087
  return UNSALTED_REASONS.AGREEMENT_TIMED_OUT;
8806
11088
  }
11089
+ /**
11090
+ * WHICH of the four terminal answers the peer actually gave — 006-CRYPTO finding 2.
11091
+ *
11092
+ * The default is the NON-ASSERTING reason, not the most common one. An unknown label means a build
11093
+ * we do not understand, and rendering that as "they had already hashed messages" states something
11094
+ * about a counterparty that may be untrue — which is what sends an operator to raise a
11095
+ * non-problem with them. The label is peer-supplied, so nothing outside the known set is repeated
11096
+ * back as our own diagnosis.
11097
+ *
11098
+ * A missing entry maps to the already-hashing case: `PEER_CLOSED_FIRST` and an absent label both
11099
+ * mean the peer is answering a closure of OURS, and `#saltForHashing` answers that with
11100
+ * `ADOPTION_CLOSED_LOCALLY` one branch earlier — this is only the fallback if it did not.
11101
+ */
11102
+ #peerClosedReason(key) {
11103
+ const label = this.#saltPeerClosedLabel.get(key);
11104
+ if (label === undefined || label === SALT_ADOPTION_LABELS.PEER_CLOSED_FIRST) {
11105
+ return UNSALTED_REASONS.PEER_CLOSED_ADOPTION;
11106
+ }
11107
+ if (label === SALT_ADOPTION_LABELS.ALREADY_HASHING)
11108
+ return UNSALTED_REASONS.PEER_CLOSED_ADOPTION;
11109
+ if (label === SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE)
11110
+ return UNSALTED_REASONS.PEER_FRONTIER_UNREADABLE;
11111
+ if (label === SALT_ADOPTION_LABELS.EXCHANGE_STALLED)
11112
+ return UNSALTED_REASONS.PEER_EXCHANGE_STALLED;
11113
+ return UNSALTED_REASONS.PEER_CLOSED_UNSPECIFIED;
11114
+ }
8807
11115
  /** Resolve a pending agreement. Idempotent: the first outcome wins and the timer is cleared. */
8808
11116
  #settleSaltPending(agentName, sessionId, outcome) {
8809
11117
  const key = this.#k(agentName, sessionId);
@@ -8889,9 +11197,70 @@ export class SessionNodeManager {
8889
11197
  * Read-only and cache-backed, so exposing it adds no way to CHANGE the salt from outside — the
8890
11198
  * only writer remains `#persistSessionSalt`, behind the one-salt-per-session predicate.
8891
11199
  */
11200
+ /**
11201
+ * IS THIS SESSION ACTUALLY PROTECTED BY ITS SALT RIGHT NOW — pass 2, F3.
11202
+ *
11203
+ * Distinct from `getSessionContentSalt`, which is POSSESSION and is what the verifier needs: a
11204
+ * message parked before suspension was hashed under this salt and must still be checkable against
11205
+ * it, so that accessor must keep answering with the bytes.
11206
+ *
11207
+ * This one answers the OPERATOR's question, and it is a different question. A suspended session
11208
+ * holds a salt it will not use, so every hash it produces is `sha256` — reporting `contentSalted:
11209
+ * true` there is not a gap, it is an affirmatively false security claim on the surface whose own
11210
+ * comment reads *"a security property must not be inferable from a gap."* Same predicate
11211
+ * `#saltForHashing` uses, so the flag cannot drift from the behaviour it describes.
11212
+ */
11213
+ isContentSaltActive(agentName, sessionId) {
11214
+ if (this.#saltSuspended.has(this.#k(agentName, sessionId)))
11215
+ return false;
11216
+ return this.#getSessionSalt(agentName, sessionId) !== null;
11217
+ }
8892
11218
  getSessionContentSalt(agentName, sessionId) {
8893
11219
  return this.#getSessionSalt(agentName, sessionId);
8894
11220
  }
11221
+ /**
11222
+ * THE SALT, OR WHY THERE ISN'T ONE — `DOD-M15-INCLUSION-1`, fallback-finder finding 2.
11223
+ *
11224
+ * `getSessionContentSalt` above answers `null` for THREE different situations, and a caller that
11225
+ * turns that null into a sentence for an operator gets two of them wrong:
11226
+ *
11227
+ * `none` — no salt was ever agreed. The session really is unsalted.
11228
+ * `unreadable` — a salt row EXISTS and could not be used: the wrong width (corruption on this
11229
+ * operator's own disk), or the read threw.
11230
+ *
11231
+ * The distinction is not cosmetic. `unreadable` means the session's leaves WERE hashed under a
11232
+ * salt, so telling its operator *"this session's content hashes are UNSALTED … start a session
11233
+ * while you are both connected"* is an affirmatively false statement about a security property
11234
+ * their session has, and it points them at their counterparty over damage to their own database.
11235
+ * That is the same defect `#getSessionSalt`'s own F8 note was written to end, re-committed one
11236
+ * surface out.
11237
+ *
11238
+ * ⚠️ IT DELEGATES — there is no second read here. Calling `#getSessionSalt` first means the salt
11239
+ * this reports is the salt the hashing path uses, including its cache and its wrong-width refusal.
11240
+ * A parallel query would be free to disagree with it, which is the whole failure this returns a
11241
+ * reason to prevent.
11242
+ */
11243
+ getSessionContentSaltState(agentName, sessionId) {
11244
+ const salt = this.#getSessionSalt(agentName, sessionId);
11245
+ if (salt)
11246
+ return { salt };
11247
+ if (!this.#db)
11248
+ return { salt: null, reason: "unreadable" };
11249
+ try {
11250
+ const row = this.#db
11251
+ .prepare("SELECT length(content_salt) AS n FROM sessions WHERE agent_id = ? AND session_id = ?")
11252
+ .get(this.#requireAgentId(agentName), sessionId);
11253
+ // A row with a non-empty blob that `#getSessionSalt` still refused is the corruption case: the
11254
+ // bytes are there and they are not a salt. NULL or zero-length is a genuine absence.
11255
+ const stored = row?.n ?? 0;
11256
+ return { salt: null, reason: stored > 0 ? "unreadable" : "none" };
11257
+ }
11258
+ catch {
11259
+ // The read that would tell us which case it is has itself failed, so "no salt was agreed" is
11260
+ // exactly the thing we cannot assert.
11261
+ return { salt: null, reason: "unreadable" };
11262
+ }
11263
+ }
8895
11264
  /**
8896
11265
  * This session's agreed salt, or null. Reads the durable row through a cache, because Decision #8
8897
11266
  * persists it for exactly one reason: *"a restart silently splits the transcript"* if the lookup
@@ -9118,16 +11487,224 @@ export class SessionNodeManager {
9118
11487
  });
9119
11488
  return false;
9120
11489
  }
9121
- this.#sessionSalts.set(this.#k(agentName, sessionId), salt);
9122
- return true;
11490
+ this.#sessionSalts.set(this.#k(agentName, sessionId), salt);
11491
+ return true;
11492
+ }
11493
+ catch (err) {
11494
+ this.#logger.error("session.salt.persist.failed", {
11495
+ agentName, sessionId, error: extractErrorMessage(err),
11496
+ 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",
11497
+ });
11498
+ return false;
11499
+ }
11500
+ }
11501
+ /**
11502
+ * DROP AN UNSPENT SALT — `DOD-M15-SALTSPLIT-1`. The second writer of `content_salt`, and the only
11503
+ * one that clears it.
11504
+ *
11505
+ * Reached when the counterparty tells us it can never adopt a salt for this session. Keeping ours
11506
+ * would mean every message we send from here is refused by them with
11507
+ * `content_hash_salt_unavailable` — a conversation that dies while looking merely quiet, which is
11508
+ * the failure this exists to prevent.
11509
+ *
11510
+ * ⚠️ THE ADOPTION CHECK IS REPEATED HERE ON PURPOSE, not because the caller is untrusted.
11511
+ *
11512
+ * The caller has already computed `adoption`, so this looks redundant — and it is, for today's one
11513
+ * call site. It stays because the cost of a future caller getting it wrong is a transcript that no
11514
+ * single rule can verify: leaves hashed under a salt that has just been erased, with nothing
11515
+ * recording that they were. A guard whose failure mode is silent and permanent belongs next to the
11516
+ * destructive act, not only at the place that currently decides to perform it. Same reasoning that
11517
+ * made `placeOwnLeaf`'s authorship parameter required rather than optional.
11518
+ *
11519
+ * Returns true only if a salt was actually cleared.
11520
+ */
11521
+ /**
11522
+ * SUSPEND, don't destroy — `DOD-M15-SALTSPLIT-1`, the authorization argument. Returns true if a
11523
+ * salt is now suspended (or already was).
11524
+ *
11525
+ * This is the frame handler's entry point. It runs the same two refusals as the erase below —
11526
+ * a spent salt and one mid-flight are not ours to set aside either, because the messages already
11527
+ * hashed under them would become unverifiable the moment we stop using it — and where they do not
11528
+ * fire it records the suspension instead of doing anything irreversible.
11529
+ */
11530
+ #suspendSalt(agentName, sessionId, correlationId) {
11531
+ const key = this.#k(agentName, sessionId);
11532
+ if (this.#getSessionSalt(agentName, sessionId) === null)
11533
+ return false;
11534
+ if (this.#saltSuspended.has(key))
11535
+ return true;
11536
+ const inFlight = this.#hashedWithSalt.get(key) ?? 0;
11537
+ const adoption = this.#saltAdoptionClosed(agentName, sessionId);
11538
+ if (inFlight > 0 || adoption.closed) {
11539
+ /**
11540
+ * SPENT, or mid-send. Suspending is not destructive, but it IS a split: content already hashed
11541
+ * under this salt stays hashed under it while everything after would be hashed the other way,
11542
+ * in one session, with nothing recording where the change happened. That is the one thing
11543
+ * Decision #8 forbids outright, so the salt keeps being used and the session stays honestly
11544
+ * broken rather than becoming dishonestly half-verifiable.
11545
+ */
11546
+ this.#logger.info("session.salt.suspend.refused", {
11547
+ agentName, sessionId, correlationId,
11548
+ reason: inFlight > 0 ? "salted_hash_in_flight" : adoption.label,
11549
+ ...(inFlight > 0 ? { inFlight } : { frontier: adoption.why }),
11550
+ impact: "the salt stays IN USE, because content in this session is already hashed under it and switching now would split the transcript — half verifiable by one rule, half by another. The counterparty cannot hold this salt, so it will keep refusing messages sent from here. See session.salt.split.",
11551
+ });
11552
+ return false;
11553
+ }
11554
+ this.#saltSuspended.add(key);
11555
+ this.#logger.info("session.salt.suspended", {
11556
+ agentName, sessionId, correlationId,
11557
+ impact: "the counterparty can never adopt a salt for this session, so this side has STOPPED USING its own — messages are hashed the way every build before content salting hashed them, and every message continues to be accepted. Nothing was hashed under it, so nothing is split.",
11558
+ guidance: "No action. The salt bytes are kept, not erased: if the counterparty was merely unable to read its own state for a moment, its next announcement carrying a matching fingerprint restores this session to salted automatically. The bytes are erased only when this session actually hashes a message unsalted, which is the point after which keeping them would re-salt the session at the next restart.",
11559
+ });
11560
+ return true;
11561
+ }
11562
+ /** Un-suspend: the peer answered with a fingerprint matching the salt we kept. */
11563
+ #resumeSalt(agentName, sessionId, correlationId) {
11564
+ const key = this.#k(agentName, sessionId);
11565
+ if (!this.#saltSuspended.has(key))
11566
+ return;
11567
+ /**
11568
+ * ⚠️ REFUSE THE RESUME IF THIS SESSION HAS ALREADY HASHED UNSALTED — pass 2, F1 (HIGH).
11569
+ *
11570
+ * `#resumeSalt` deleted the mark unconditionally, and the reviewer produced the counter-example
11571
+ * in ONE process with no restart: suspend, the peer keeps talking so a leaf lands, we send `m1`
11572
+ * under `sha256`, the peer's frontier recovers and announces `fingerprint(S)`, we resume, and
11573
+ * `m2` goes out under `hmac`. Two rules, one session — and `session.salt.resumed` asserted
11574
+ * *"No message was hashed while suspended, so the transcript is uniform"* while it was happening.
11575
+ * **The code never checked the thing its own log line claimed**, which is this milestone's
11576
+ * signature defect committed inside the fix for it.
11577
+ *
11578
+ * `#unsaltedAnnounced` is exactly that fact and is already maintained, so the check costs a
11579
+ * lookup. Once it is set the salt can never be used again, so it is erased here rather than left
11580
+ * to be found by a later restart.
11581
+ */
11582
+ if (this.#unsaltedAnnounced.has(key)) {
11583
+ this.#logger.warn("session.salt.resume.refused", {
11584
+ agentName, sessionId, correlationId,
11585
+ impact: "the counterparty now confirms a salt this side is holding, but this session has ALREADY hashed at least one message unsalted. Resuming would put half the transcript under each rule, which no single rule can verify — so the session stays unsalted for its whole life and the salt is released.",
11586
+ guidance: "Nothing to do here, and nothing is lost: the transcript stays uniform and every message is intact. If you want the salt protection with this counterparty, start a new session — the agreement runs at open, before anything is hashed.",
11587
+ });
11588
+ this.#saltSuspended.delete(key);
11589
+ this.#discardUnspentSalt(agentName, sessionId, correlationId);
11590
+ return;
11591
+ }
11592
+ this.#saltSuspended.delete(key);
11593
+ /**
11594
+ * THE RECOVERY THE ERASE MADE IMPOSSIBLE. Keeping the bytes is what allows this line to exist:
11595
+ * the peer's earlier terminal frame was wrong (a frontier it could not read for a moment), it can
11596
+ * read again, and the fingerprints match — so the session resumes salted with nothing lost. An
11597
+ * erased salt cannot be re-derived from one side.
11598
+ */
11599
+ this.#logger.info("session.salt.resumed", {
11600
+ agentName, sessionId, correlationId,
11601
+ impact: "the counterparty now confirms the same salt this side kept, so this session is salted again. It was suspended earlier because the counterparty reported it could never hold one; that has resolved. No message was hashed while suspended, so the transcript is uniform.",
11602
+ });
11603
+ }
11604
+ #discardUnspentSalt(agentName, sessionId, correlationId) {
11605
+ const held = this.#getSessionSalt(agentName, sessionId);
11606
+ if (held === null)
11607
+ return false;
11608
+ /**
11609
+ * ⚠️ IN-FLIGHT FIRST — `DOD-M15-SALTSPLIT-1` review HIGH-2. `#saltAdoptionClosed` cannot see a
11610
+ * hash that has been computed under the salt but has not yet become a leaf, a hold or an
11611
+ * awaiting-ack entry, and that gap is a full relay round trip wide.
11612
+ */
11613
+ /**
11614
+ * ⚠️ MEASURED UNREACHABLE FROM TODAY'S CALLERS, AND KEPT ANYWAY — pass 2 test-teeth, survivor 2.
11615
+ *
11616
+ * Deleting this block leaves the whole salt suite GREEN. That is the definition this unit has
11617
+ * used all along for *"not a guard, a comment that happens to execute"*, so it is labelled rather
11618
+ * than quietly left to look load-bearing. `#suspendSalt` refuses on `inFlight > 0` before a
11619
+ * session can ever be marked, and both callers of this method require the mark — so the deferred
11620
+ * erase cannot observe a non-zero count.
11621
+ *
11622
+ * It stays for one reason: **it sits at an irreversible write.** The earlier instance of this
11623
+ * question in this same unit was resolved by making the guard the actual decision-maker, and that
11624
+ * option does not exist here — `#suspendSalt` genuinely must refuse early, so the duplication is
11625
+ * structural rather than a mistake about where responsibility lives. For a destructive act, the
11626
+ * safe direction is to keep a check that cannot fire over removing one that turns out it could.
11627
+ *
11628
+ * What must NOT happen is claiming it as coverage. It is not tested and it is not testable from
11629
+ * outside; if a third caller ever reaches this method without the suspension mark, this becomes
11630
+ * reachable and needs a test in the same commit.
11631
+ */
11632
+ const inFlight = this.#hashedWithSalt.get(this.#k(agentName, sessionId)) ?? 0;
11633
+ if (inFlight > 0) {
11634
+ this.#logger.info("session.salt.discard.refused", {
11635
+ agentName, sessionId, correlationId, reason: "salted_hash_in_flight", inFlight,
11636
+ impact: "the salt was NOT dropped: a message has already been hashed under it and is mid-send, so erasing it now would put a hash on the wire that nothing — including this daemon — could ever recompute. The session stays salted and the counterparty, which cannot adopt, will refuse what is in flight.",
11637
+ });
11638
+ return false;
11639
+ }
11640
+ const adoption = this.#saltAdoptionClosed(agentName, sessionId);
11641
+ if (adoption.closed) {
11642
+ /**
11643
+ * SPENT. Something is already hashed under this salt, so it is not ours to drop.
11644
+ *
11645
+ * INFO, not ERROR, and the level is a judgement rather than a downgrade: this is the guard
11646
+ * doing its job correctly, and the FAILURE it accompanies — the session is split and unusable
11647
+ * — is reported by `session.salt.split` at ERROR from the caller that has the operator-facing
11648
+ * detail. Two ERRORs for one condition trains people to read neither. This line stays so the
11649
+ * refusal itself is correlatable when someone asks why the salt is still on disk.
11650
+ */
11651
+ this.#logger.info("session.salt.discard.refused", {
11652
+ agentName, sessionId, correlationId, reason: adoption.label, frontier: adoption.why,
11653
+ impact: "the salt was NOT dropped, because content in this session is already hashed under it and erasing it would leave a transcript no single rule can verify. The session stays split: the counterparty holds no salt and refuses everything sent from here.",
11654
+ });
11655
+ return false;
11656
+ }
11657
+ if (!this.#db) {
11658
+ this.#logger.error("session.salt.discard.failed", {
11659
+ agentName, sessionId, correlationId, reason: "db_closed",
11660
+ impact: "the salt is still stored, so after the next restart this side hashes salted while the counterparty refuses every message. Only reachable during shutdown; the agreement re-runs on the next connect, which discards it then.",
11661
+ });
11662
+ return false;
11663
+ }
11664
+ try {
11665
+ const cleared = this.#db
11666
+ .prepare("UPDATE sessions SET content_salt = NULL WHERE agent_id = ? AND session_id = ?")
11667
+ .run(this.#requireAgentId(agentName), sessionId);
11668
+ if (Number(cleared.changes) !== 1) {
11669
+ // The row-count check that `#persistSessionSalt` learned the hard way: an UPDATE matching no
11670
+ // row does not throw, and reporting success here would leave the durable salt in place while
11671
+ // the cache said otherwise — salted after a restart, unsalted before one.
11672
+ this.#logger.error("session.salt.discard.failed", {
11673
+ agentName, sessionId, correlationId, changes: Number(cleared.changes), reason: "no_session_row",
11674
+ impact: "the stored salt was NOT cleared, so this side hashes unsalted now and salted again after a restart — the transcript splits at the restart rather than here",
11675
+ });
11676
+ return false;
11677
+ }
9123
11678
  }
9124
11679
  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",
11680
+ this.#logger.error("session.salt.discard.failed", {
11681
+ agentName, sessionId, correlationId, error: extractErrorMessage(err),
11682
+ impact: "the stored salt was NOT cleared, so this side hashes unsalted now and salted again after a restart the transcript splits at the restart rather than here",
9128
11683
  });
9129
11684
  return false;
9130
11685
  }
11686
+ /**
11687
+ * CACHE AFTER ROW, and both or the session is worse off than before.
11688
+ *
11689
+ * `#saltForHashing` reads the cache on its first line and never consults the row, so clearing
11690
+ * one without the other produces a session that hashes one way in this process and the other way
11691
+ * in the next — the split transcript, arriving at a daemon restart instead of at a frame.
11692
+ */
11693
+ this.#sessionSalts.delete(this.#k(agentName, sessionId));
11694
+ /**
11695
+ * ⚠️ THE MARK GOES WITH THE BYTES — pass 2, F5. Leaving the key in `#saltSuspended` after a
11696
+ * successful erase means a LATER agreed salt is silently never used: `#persistSessionSalt`'s
11697
+ * predicate explicitly allows a write when the column is NULL, and `abandonUnsaltedHash` can
11698
+ * re-open adoption — so the session would log `session.salt.agreed`, surface as protected, and
11699
+ * hash `sha256` for the rest of its life. A stale suppression is indistinguishable from a
11700
+ * feature that does not work.
11701
+ */
11702
+ this.#saltSuspended.delete(this.#k(agentName, sessionId));
11703
+ this.#logger.info("session.salt.discarded", {
11704
+ agentName, sessionId, correlationId,
11705
+ impact: "the counterparty can never adopt a salt for this session, so this side dropped its own before spending it. Both sides now hash unsalted — exactly as verifiable as every session shipped before content salting existed, and every message continues to be accepted. Nothing was hashed under the discarded salt.",
11706
+ });
11707
+ return true;
9131
11708
  }
9132
11709
  /**
9133
11710
  * Announce our state to the counterparty: a contribution if we hold no salt, a fingerprint if we
@@ -9260,6 +11837,13 @@ export class SessionNodeManager {
9260
11837
  async #handleSaltFrame(agentName, sessionId, frame, correlationId) {
9261
11838
  const key = this.#k(agentName, sessionId);
9262
11839
  const peerHalfHex = frame.contribution ? Buffer.from(frame.contribution).toString("hex") : null;
11840
+ const peerFingerprintHex = frame.fingerprint ? Buffer.from(frame.fingerprint).toString("hex") : null;
11841
+ // WHY the peer closed, kept for the operator-facing reason — 006-CRYPTO finding 2. Recorded here
11842
+ // rather than in the `adoption_closed` handler because that action fires for OUR closure too,
11843
+ // and only the frame says what the PEER said.
11844
+ if (typeof frame.adoptionClosed === "string") {
11845
+ this.#saltPeerClosedLabel.set(key, frame.adoptionClosed);
11846
+ }
9263
11847
  const adoption = this.#saltAdoptionClosed(agentName, sessionId);
9264
11848
  const action = onPeerSaltFrame({
9265
11849
  ...this.#saltState(agentName, sessionId),
@@ -9272,9 +11856,15 @@ export class SessionNodeManager {
9272
11856
  // Keyed on the peer's BYTES, not on a repair counter: a genuinely NEW half from the peer must
9273
11857
  // still get our contribution back, and only an identical re-offer is the loop (review F14).
9274
11858
  alreadyRepairedAgainstPeerHalf: peerHalfHex !== null && this.#saltRepairedAgainst.get(key) === peerHalfHex,
11859
+ // The mirror, 006-CRYPTO finding 1: without it a saltless side answers a latched holder's
11860
+ // fingerprint forever. Same keying rule — an identical re-offer is the loop, a new one is not.
11861
+ alreadyRepairedAgainstPeerFingerprint: peerFingerprintHex !== null && this.#saltRepairedAgainstFingerprint.get(key) === peerFingerprintHex,
9275
11862
  frame,
9276
11863
  });
9277
11864
  if (action.action === "confirmed") {
11865
+ // DOD-M15-SALTSPLIT-1: the peer confirms the salt we KEPT while suspended — resume before logging
11866
+ // agreement, so a resumed session is never reported as agreed while still suspended.
11867
+ this.#resumeSalt(agentName, sessionId, correlationId);
9278
11868
  this.#logger.info("session.salt.agreed", {
9279
11869
  agentName, sessionId, correlationId, via: "fingerprint_match",
9280
11870
  });
@@ -9339,9 +11929,44 @@ export class SessionNodeManager {
9339
11929
  * is nothing for its operator to do. Logging that at WARN would fire on the innocent side of
9340
11930
  * every such session and train them to ignore the name.
9341
11931
  */
11932
+ /**
11933
+ * DOD-M15-SALTSPLIT-1 — ONE PLACE DECIDES WHETHER THE SALT GOES, and it is not here.
11934
+ *
11935
+ * ⚠️ THIS CALL WAS INSIDE THE `else` BELOW, AND THE REVERT TEST CAUGHT IT.
11936
+ *
11937
+ * Guarding it by `adoption.closed` here meant `#discardUnspentSalt`'s own adoption check could
11938
+ * never be reached, so deleting that check left all three tests GREEN — the survivor. A guard
11939
+ * nothing can redden is not a guard; it is a comment that happens to execute, which is the
11940
+ * shape this milestone keeps finding.
11941
+ *
11942
+ * Called unconditionally now. The method owns the spent/unspent decision, both outcomes run
11943
+ * through it, and deleting its check reddens the spent test immediately. That also removes the
11944
+ * duplicated condition: two places deciding the same thing is one place being wrong later.
11945
+ */
11946
+ /**
11947
+ * The return is CONSUMED, not decorative — review LOW-5. `true` means a salt was actually
11948
+ * cleared, which settles the question below without a second read; `false` is ambiguous (we
11949
+ * held none, or we refused to drop one), so that case still asks.
11950
+ */
11951
+ const suspended = this.#suspendSalt(agentName, sessionId, correlationId);
11952
+ /**
11953
+ * "Still holds a salt it is USING" — suspension is what settles it, not possession. A suspended
11954
+ * session keeps the bytes on disk deliberately, and reporting that as an unrecoverable split
11955
+ * would fire the ERROR below on the one case that recovers by itself.
11956
+ */
11957
+ const stillHoldsSalt = !suspended && this.#getSessionSalt(agentName, sessionId) !== null;
9342
11958
  const shared = {
9343
11959
  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.",
11960
+ /**
11961
+ * ⚠️ *"no message is affected"* IS FALSE WHEN WE ARE STILL HOLDING A SALT — review MEDIUM-4,
11962
+ * second instance. The sentence was written for a session where neither side ever adopted
11963
+ * one, and it stayed attached to a branch that now also covers the case where this side
11964
+ * kept a spent salt and every message it sends is about to be refused. Two log lines from
11965
+ * one event contradicting each other is worse than either alone.
11966
+ */
11967
+ impact: stillHoldsSalt
11968
+ ? "the counterparty will not use a content salt, and this side is still holding one it cannot drop — see session.salt.split on the next line for what that costs and what to do about it."
11969
+ : "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
11970
  };
9346
11971
  if (adoption.closed) {
9347
11972
  /**
@@ -9366,8 +11991,67 @@ export class SessionNodeManager {
9366
11991
  });
9367
11992
  }
9368
11993
  else {
11994
+ /**
11995
+ * DOD-M15-SALTSPLIT-1 — CARRY OUT THE CLAIM ABOVE INSTEAD OF ONLY STATING IT.
11996
+ *
11997
+ * `shared.impact` says *"neither side will use a content salt for this session, and both now
11998
+ * know it."* Nothing made that true: a salt already agreed on this side stayed on disk and in
11999
+ * the cache, and `#saltForHashing` returns it before it ever looks at adoption. Our adoption
12000
+ * is still open here, so nothing has been hashed under it and dropping it is free.
12001
+ *
12002
+ * Ordering matters — discard BEFORE the log, so the line cannot claim an outcome that the
12003
+ * write then failed to produce.
12004
+ */
9369
12005
  this.#logger.info("session.salt.adoption.closed", shared);
9370
12006
  }
12007
+ /**
12008
+ * ⚠️ OUTSIDE THE ADOPTION BRANCH — pass 2, F4. This used to live inside `if (adoption.closed)`,
12009
+ * so the one case that needed it most never got it: suspension refused for
12010
+ * `salted_hash_in_flight` while adoption is still OPEN leaves us holding a salt the peer can
12011
+ * never accept, and it took the `else` path. Measured on that exact scenario:
12012
+ * `suspend.refused = 1`, `adoption.closed = 1`, **`split = 0`** — while two other log lines
12013
+ * told the operator to *"see session.salt.split on the next line"*, a line that was never
12014
+ * written. Guidance pointing at an event that does not fire is worse than no guidance: it
12015
+ * spends the reader's trust and their time.
12016
+ *
12017
+ * The condition was always `stillHoldsSalt`; only its placement disagreed.
12018
+ *
12019
+ * ─── What this event means, moved here with the code it describes ─────────────────────────
12020
+ *
12021
+ * We hold a salt AND the peer has told us it can never hold one. Either our frontier closed
12022
+ * with the salt already spent, or a salted hash is mid-flight — both mean the salt cannot be
12023
+ * released, so the peer will refuse every message we send with `content_hash_salt_unavailable`.
12024
+ *
12025
+ * `session.salt.adoption.refused` may fire alongside, saying *"nothing is degraded relative to
12026
+ * any shipped release, and no message is affected"* — true for the ordinary refusal and FALSE
12027
+ * here, at the exact moment every message stops being accepted. Hence its own event at ERROR
12028
+ * rather than a tightened sentence on that one: an operator filtering for the refusal is
12029
+ * looking at a benign condition, and this is not it.
12030
+ */
12031
+ if (stillHoldsSalt) {
12032
+ /**
12033
+ * ⚠️ TWO REASONS REACH `adoption.closed`, AND ONLY ONE IS ABOUT CONTENT — review MEDIUM-4.
12034
+ *
12035
+ * This fired for both with a single impact asserting *"content here is already hashed
12036
+ * under a salt"*. For `frontier_unreadable` that is a claim about content made from a
12037
+ * database read that FAILED — we do not know what was hashed; that is the whole condition.
12038
+ *
12039
+ * The WARN twenty lines above was explicitly corrected for this exact collapse — its
12040
+ * comment reads *"TWO REFUSALS, TWO DIFFERENT THINGS TO DO — and this used to report both
12041
+ * as `already_hashing`"* — and I reintroduced it one severity level up, with the guidance
12042
+ * that WARN was fixed to stop giving. Branching on the label the way it already does.
12043
+ */
12044
+ const unreadable = adoption.label === SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE;
12045
+ this.#logger.error("session.salt.split", {
12046
+ agentName, sessionId, correlationId, reason: adoption.label, frontier: adoption.why,
12047
+ impact: unreadable
12048
+ ? "this side holds a salt, the counterparty can never hold one, and this side could NOT read its own message frontier — so whether anything has been hashed under that salt is unknown. The salt is kept rather than dropped, because dropping one that HAS been spent leaves a transcript no single rule can verify. Until the read succeeds, expect the counterparty to refuse messages sent from here."
12049
+ : "this session cannot continue. Content here is already hashed under a salt the counterparty can never hold, so they refuse every message sent from this side — the conversation looks quiet rather than broken, and the session can never be sealed because the two transcripts no longer agree on a leaf.",
12050
+ guidance: unreadable
12051
+ ? "Do NOT start a new session yet — it would refuse in exactly the same way, because the fault is this side's storage rather than this conversation. Look for session.content.held.restore.failed or other storage errors around this line. Once the frontier reads again, this resolves to either an ordinary salted session or the split case, and the log will say which."
12052
+ : "Start a new session with this counterparty: the salt agreement runs at open, before anything is hashed, so a fresh session agrees or declines cleanly on both sides. This one cannot be repaired — the salt cannot be dropped without leaving a transcript no single rule can verify, and it cannot be shared with a peer that has already closed adoption.",
12053
+ });
12054
+ }
9371
12055
  if (action.announce) {
9372
12056
  void this.#sendSaltFrame(agentName, sessionId, correlationId, action.announce);
9373
12057
  }
@@ -9387,10 +12071,16 @@ export class SessionNodeManager {
9387
12071
  answeredWith: action.frame.contribution ? "contribution" : "fingerprint",
9388
12072
  });
9389
12073
  // 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.
12074
+ // offer must not repeat (review F14).
9392
12075
  if (peerHalfHex && action.frame.contribution)
9393
12076
  this.#saltRepairedAgainst.set(key, peerHalfHex);
12077
+ // AND THE MIRROR (006-CRYPTO finding 1): we answered the peer's FINGERPRINT with our half. An
12078
+ // earlier note here said recording this "says nothing, that branch is already terminal for the
12079
+ // peer" — it is terminal only for a peer that HOLDS a salt, and the loop is the case where we
12080
+ // do not. A second identical fingerprint now closes adoption instead of repairing again.
12081
+ if (peerFingerprintHex && action.frame.contribution) {
12082
+ this.#saltRepairedAgainstFingerprint.set(key, peerFingerprintHex);
12083
+ }
9394
12084
  void this.#sendSaltFrame(agentName, sessionId, correlationId, action.frame);
9395
12085
  return;
9396
12086
  }
@@ -9742,6 +12432,23 @@ export class SessionNodeManager {
9742
12432
  * by name — so a non-Uint8Array in either slot must arrive at that function as ABSENT, not as
9743
12433
  * a present-but-wrong value it would then try to use.
9744
12434
  */
12435
+ /**
12436
+ * 007-CRYPTO — the peer's SIGNED ephemeral.
12437
+ *
12438
+ * Fields are read defensively rather than cast, exactly like the salt frame below: an inbound
12439
+ * value is whatever a peer chose to encode, and `verifySessionEphemeral` refuses a missing or
12440
+ * wrong-width one BY NAME — so a non-`Uint8Array` must arrive there as ABSENT rather than as a
12441
+ * present-but-wrong value it would try to use.
12442
+ */
12443
+ if (frame["type"] === "session_key_agreement") {
12444
+ const ephemeralPublic = frame["ephemeral_public"];
12445
+ const signature = frame["ephemeral_sig"];
12446
+ await this.#handleEphemeralFrame(agentName, sessionId, {
12447
+ ...(ephemeralPublic instanceof Uint8Array ? { ephemeralPublic } : {}),
12448
+ ...(signature instanceof Uint8Array ? { signature } : {}),
12449
+ }, correlationId);
12450
+ return;
12451
+ }
9745
12452
  if (frame["type"] === "session_salt_agreement") {
9746
12453
  const contribution = frame["contribution"];
9747
12454
  const fingerprint = frame["fingerprint"];
@@ -9751,7 +12458,14 @@ export class SessionNodeManager {
9751
12458
  ...(fingerprint instanceof Uint8Array ? { fingerprint } : {}),
9752
12459
  // A non-string stays ABSENT rather than being coerced, exactly like the other two: the
9753
12460
  // decision function refuses a shape it cannot read, and must never be handed a `"42"`.
9754
- ...(typeof adoptionClosed === "string" && adoptionClosed.length > 0 ? { adoptionClosed } : {}),
12461
+ //
12462
+ // TRUNCATED AT THE BOUNDARY — 006-CRYPTO finding 6. Every label CELLO sends is under
12463
+ // twenty characters, and this one is chosen entirely by the peer. Cutting it here means
12464
+ // no unbounded peer string is stored, logged or rendered anywhere downstream; the
12465
+ // rendering that keeps it out of our own sentences is `renderPeerAdoptionLabel`.
12466
+ ...(typeof adoptionClosed === "string" && adoptionClosed.length > 0
12467
+ ? { adoptionClosed: adoptionClosed.slice(0, SALT_ADOPTION_LABEL_MAX) }
12468
+ : {}),
9755
12469
  }, correlationId);
9756
12470
  return;
9757
12471
  }
@@ -9771,6 +12485,9 @@ export class SessionNodeManager {
9771
12485
  // field was PRESENT and wrong, so omitting it passed. Its own sibling twenty lines up already
9772
12486
  // refused absence, with a comment saying treating a missing field as agreement is how a guard
9773
12487
  // stops guarding. Same file, same switch, opposite conclusion.
12488
+ // Review F4: hand the DECODED frame to a test observer before anything consumes it. Absent in
12489
+ // production — the field is null unless a test installs one.
12490
+ this.#inboundFrameObserver?.(frame);
9774
12491
  const contentBytes = frame["content_bytes"];
9775
12492
  const contentHash = frame["content_hash"];
9776
12493
  if (!(contentBytes instanceof Uint8Array) || !(contentHash instanceof Uint8Array)) {
@@ -9784,6 +12501,61 @@ export class SessionNodeManager {
9784
12501
  });
9785
12502
  return;
9786
12503
  }
12504
+ /**
12505
+ * 🚨 DECRYPT BEFORE ANYTHING ELSE READS THE BODY — `DOD-M15-EPHEMERAL-AUTH-1`.
12506
+ *
12507
+ * `content_hash` is over the PLAINTEXT, so the hash check, the transcript, the seal and the
12508
+ * salted hash all keep meaning exactly what they mean today — but only if the body is put back
12509
+ * before any of them run.
12510
+ *
12511
+ * ⚠️ ABSENT IS NOT A PASS. A frame with no `content_encryption` is refused rather than read as
12512
+ * plaintext. There is no unencrypted sender to be compatible with, and treating a missing
12513
+ * marker as "this one is in the clear" is precisely the downgrade an attacker asks for: strip
12514
+ * one field and the receiver reads the body raw. Missing and unknown take the same path as a
12515
+ * failed decrypt, for the reason that runs through this whole unit — a check lenient about an
12516
+ * absent proof is a check that gets skipped.
12517
+ */
12518
+ const declaredEncryption = frame["content_encryption"];
12519
+ const encState = this.#contentEncryptionState(agentName, sessionId);
12520
+ let plaintextBody;
12521
+ if (declaredEncryption !== SESSION_CONTENT_ENCRYPTION_V1) {
12522
+ this.#logger.error("session.content.refused", {
12523
+ agentName, sessionId, correlationId,
12524
+ reason: "content_encryption_absent_or_unknown",
12525
+ declared: typeof declaredEncryption === "string" ? declaredEncryption : "(absent)",
12526
+ impact: "the frame did not say it was encrypted under this session's key, so it was refused unread. Nothing was shown and nothing was stored.",
12527
+ guidance: "STOPPED ON PURPOSE. A message arrived that was not encrypted under this session's key. " +
12528
+ "This build never sends one, so either something between you rewrote the frame, or your " +
12529
+ "counterparty is running something that is not CELLO. Confirm with them OUT OF BAND " +
12530
+ "before opening another session.",
12531
+ });
12532
+ return;
12533
+ }
12534
+ if (encState.key === null) {
12535
+ this.#logger.error("session.content.refused", {
12536
+ agentName, sessionId, correlationId,
12537
+ reason: "no_session_key",
12538
+ detail: encState.reason,
12539
+ impact: "an encrypted message arrived and this side has no agreed key to open it, so it was refused unread rather than shown as garbage.",
12540
+ guidance: CONTENT_ENCRYPTION_GUIDANCE[encState.reason],
12541
+ });
12542
+ return;
12543
+ }
12544
+ const opened = openSessionContent(encState.key, contentBytes);
12545
+ if (opened === null) {
12546
+ // GCM's tag is the only thing separating "not for us" from "modified in flight", and this
12547
+ // side must not branch on which — that would be branching on attacker-controlled input.
12548
+ this.#logger.error("session.content.refused", {
12549
+ agentName, sessionId, correlationId,
12550
+ reason: "decrypt_failed",
12551
+ impact: "the message did not decrypt under this session's agreed key — it was modified in flight, or it was encrypted under a different key. Refused unread.",
12552
+ guidance: "STOPPED ON PURPOSE. Nothing was shown and nothing was stored. A message that fails this " +
12553
+ "check has either been altered on its way to you or was not encrypted for this session. " +
12554
+ "Confirm with your counterparty OUT OF BAND, then start a new session.",
12555
+ });
12556
+ return;
12557
+ }
12558
+ plaintextBody = opened;
9787
12559
  // DOD-MSG-4 (self-ordering content frame): if the frame carries the relay's signed ordering
9788
12560
  // record, verify the sender signature and record the canonical sequence FROM THE FRAME, BEFORE
9789
12561
  // ingest — so the strict-in-order gate has the position without waiting on the separate
@@ -9850,7 +12622,10 @@ export class SessionNodeManager {
9850
12622
  * Coercing here would collapse that distinction and turn a version skew into a tamper report.
9851
12623
  */
9852
12624
  const declaredAlg = frame["content_hash_alg"];
9853
- const ingest = await this.ingestReceivedContent(agentName, sessionId, contentBytes, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship);
12625
+ const ingest = await this.ingestReceivedContent(
12626
+ // THE DECRYPTED body — everything downstream (the hash cross-check, the leaf, the transcript,
12627
+ // the delivery buffer) works on plaintext, exactly as it did before this layer existed.
12628
+ agentName, sessionId, plaintextBody, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship);
9854
12629
  // AC-001: after the content is durably ingested AND its hash cross-check
9855
12630
  // succeeds, emit an unsigned `persisted` delivery ACK back to the sender. A
9856
12631
  // rejected ingest (tamper / not-active) produces NO ACK, so the sender's TTF
@@ -10071,6 +12846,46 @@ export class SessionNodeManager {
10071
12846
  * relay pool, and the only source a FRESH agent (no session history) has.
10072
12847
  */
10073
12848
  #directoryRelayEndpoints = new Map();
12849
+ /**
12850
+ * DOD-M15-RELAYSLOTS-1: the directory-issued online token per agent — the credential the relays
12851
+ * above now require before they will let this agent hold a circuit reservation slot.
12852
+ *
12853
+ * Arrives with `signaling_auth_ok`, on the same frame as the relay endpoints and on the same
12854
+ * cadence: every connect AND every reconnect. Held here rather than passed to a relay client at
12855
+ * construction because it expires within the hour and the client outlives it — the client reads
12856
+ * it through `getDirectoryOnlineToken` at each authentication.
12857
+ */
12858
+ #directoryOnlineTokens = new Map();
12859
+ /**
12860
+ * DOD-M15-RELAYSLOTS-1: accept the directory's online token for an agent. Called on every
12861
+ * signaling connect and reconnect, which is what keeps it fresh.
12862
+ */
12863
+ setDirectoryOnlineToken(agentName, token) {
12864
+ this.#directoryOnlineTokens.set(agentName, token);
12865
+ this.#directoryOnlineTokenAbsent.delete(agentName);
12866
+ }
12867
+ /**
12868
+ * DOD-M15-RELAYSLOTS-1 review M1: the directory issued no token, and this is which absence it was.
12869
+ *
12870
+ * Kept so the relay's eventual `online_token_required` refusal can be reported with the cause the
12871
+ * DIRECTORY knew and the relay never learns — most importantly `not_registered_here`, where the
12872
+ * generic advice ("check that you are reaching a directory") points at a connection that is
12873
+ * working and away from the actual problem.
12874
+ */
12875
+ #directoryOnlineTokenAbsent = new Map();
12876
+ setDirectoryOnlineTokenAbsent(agentName, reason) {
12877
+ this.#directoryOnlineTokens.delete(agentName);
12878
+ this.#directoryOnlineTokenAbsent.set(agentName, reason ?? "unstated");
12879
+ }
12880
+ /**
12881
+ * The current token, or `undefined` when the directory has not issued one — either no directory
12882
+ * connection yet, or this key has no agent profile there. Undefined is a real answer, not a
12883
+ * missing one: the relay refuses without a token, which is the intended outcome for a key the
12884
+ * directory does not recognise.
12885
+ */
12886
+ getDirectoryOnlineToken(agentName) {
12887
+ return this.#directoryOnlineTokens.get(agentName);
12888
+ }
10074
12889
  /**
10075
12890
  * DOD-NAT-REACHABILITY-1 (Phase 2): accept the directory's relay-pool endpoints
10076
12891
  * for an agent (arrives with signaling_auth_ok, i.e. on every connect AND every
@@ -10172,6 +12987,57 @@ export class SessionNodeManager {
10172
12987
  * of past sessions (getAgentRelayEndpoints — covers a directory that predates
10173
12988
  * the auth_ok extension).
10174
12989
  */
12990
+ /**
12991
+ * DOD-M15-RELAYSLOTS-1: relays this agent should skip, and until when — see the failover note in
12992
+ * `#reservationCircuitAddrs`. Keyed agent → relay peer id → expiry.
12993
+ *
12994
+ * Time-boxed rather than permanent because the fault is somebody else's to fix and we will not
12995
+ * hear when they have: an operator sets the missing directory key and restarts, and this agent
12996
+ * should find that relay again without needing its own restart.
12997
+ */
12998
+ #relayQuarantine = new Map();
12999
+ /**
13000
+ * Is this agent currently skipping this relay? The observable half of the failover decision — a
13001
+ * test that asserts only on the classifier's boolean proves nothing about what the daemon does.
13002
+ */
13003
+ isRelayQuarantined(agentName, relayPeerId) {
13004
+ return this.#relayQuarantineFor(agentName).has(relayPeerId);
13005
+ }
13006
+ /** Live quarantine entries for an agent, expired ones swept on read. */
13007
+ #relayQuarantineFor(agentName) {
13008
+ const byRelay = this.#relayQuarantine.get(agentName);
13009
+ if (!byRelay)
13010
+ return new Set();
13011
+ const now = Date.now();
13012
+ for (const [relayPeerId, expiresAt] of byRelay) {
13013
+ if (now >= expiresAt)
13014
+ byRelay.delete(relayPeerId);
13015
+ }
13016
+ if (byRelay.size === 0)
13017
+ this.#relayQuarantine.delete(agentName);
13018
+ return new Set(byRelay.keys());
13019
+ }
13020
+ /**
13021
+ * Skip this relay for this agent for a while. Called only for refusals the classifier marks
13022
+ * `tryAnotherRelay` — a fault of the relay's, not one that would follow us to the next one.
13023
+ */
13024
+ #quarantineRelay(agentName, relayPeerId, reason) {
13025
+ let byRelay = this.#relayQuarantine.get(agentName);
13026
+ if (!byRelay) {
13027
+ byRelay = new Map();
13028
+ this.#relayQuarantine.set(agentName, byRelay);
13029
+ }
13030
+ byRelay.set(relayPeerId, Date.now() + RELAY_QUARANTINE_MS);
13031
+ this.#logger.warn("session.standing_receiver.relay_quarantined", {
13032
+ agentName,
13033
+ relayPeerId,
13034
+ reason,
13035
+ forMs: RELAY_QUARANTINE_MS,
13036
+ impact: "this relay refused this agent for a fault of its own, so the agent will ask a " +
13037
+ "different relay for its reservation until the quarantine lapses. Its inbound reachability " +
13038
+ "is restored by moving, not by waiting for someone to fix that relay.",
13039
+ });
13040
+ }
10175
13041
  #reservationCircuitAddrs(agentName) {
10176
13042
  let persisted;
10177
13043
  try {
@@ -10193,9 +13059,33 @@ export class SessionNodeManager {
10193
13059
  if (!merged.has(ep.relayPeerId))
10194
13060
  merged.set(ep.relayPeerId, ep);
10195
13061
  }
13062
+ /**
13063
+ * DOD-M15-RELAYSLOTS-1 — **THE FAILOVER.** Skip relays that refused this agent for a fault of
13064
+ * their own (today: a relay holding no directory public key, which can verify nobody and is
13065
+ * refusing everyone). We run several relays precisely so one being broken is survivable.
13066
+ *
13067
+ * ⚠️ NEVER TO THE POINT OF HAVING NO RELAY AT ALL. If the quarantine would empty the candidate
13068
+ * list it is ignored wholesale: a relay that refuses is strictly better than no relay, because
13069
+ * the refusal at least has a cause the operator can read, while an agent with no candidates is
13070
+ * simply unreachable with nothing to show for it. This is the same "refusing too eagerly is the
13071
+ * failure mode" rule, applied to the client's own choice of where to ask.
13072
+ */
13073
+ const quarantined = this.#relayQuarantineFor(agentName);
13074
+ const eligible = [...merged.values()].filter((ep) => !quarantined.has(ep.relayPeerId));
13075
+ const usable = eligible.length > 0 ? eligible : [...merged.values()];
13076
+ if (eligible.length === 0 && quarantined.size > 0 && merged.size > 0) {
13077
+ this.#logger.warn("session.standing_receiver.relay_quarantine.ignored", {
13078
+ agentName,
13079
+ quarantined: [...quarantined],
13080
+ impact: "every known relay has refused this agent for a relay-side fault, so the quarantine " +
13081
+ "is being ignored and they are all being tried again. A relay that refuses with a cause " +
13082
+ "is better than no relay at all — but if this persists, every relay this agent knows " +
13083
+ "about is misconfigured, and that is the thing to look at.",
13084
+ });
13085
+ }
10196
13086
  const addrs = [];
10197
13087
  const relayPeerIds = [];
10198
- for (const ep of merged.values()) {
13088
+ for (const ep of usable) {
10199
13089
  const base = ep.relayAddrs[0];
10200
13090
  if (!base)
10201
13091
  continue;
@@ -10429,6 +13319,133 @@ export class SessionNodeManager {
10429
13319
  * Candidates are tried in order (directory pool first). The first that actually
10430
13320
  * grants a reservation wins; the rest are never touched.
10431
13321
  */
13322
+ /**
13323
+ * DOD-M15-RELAYSLOTS-1 — tell the relay this transport identity belongs to a registered agent, so
13324
+ * the reservation it just refused is granted on the next attempt.
13325
+ *
13326
+ * Returns `"proven"` on success, or the shape of the failure so the candidate loop can act on it.
13327
+ * It never throws: throwing would turn one unreachable relay into a failure to build a receiver
13328
+ * at all.
13329
+ *
13330
+ * ─── Why this returns a verdict instead of a boolean ──────────────────────────────────────────
13331
+ *
13332
+ * Review HIGH-1/HIGH-2. Putting the reservation behind a proof MOVED THE FIRST REFUSAL onto this
13333
+ * path. `#authenticateStandingReceiver` does everything right with a refusal — records it where
13334
+ * `cello_status` can read it, and quarantines a relay whose fault is its own — but it runs only
13335
+ * on a receiver that ALREADY HAS a reservation, so under the new gate a total failure never
13336
+ * reaches it. This method was logging `proven: false` and returning.
13337
+ *
13338
+ * What that cost the operator: an expired token, or an agent at its slot cap, refused by every
13339
+ * relay in the pool. `cello_status` shows an agent that is online and reachable by nobody, with
13340
+ * no cause anywhere the person will look — while the relay had computed the cause, the count, and
13341
+ * the next step, and put them on the wire. And `slot_cap_exceeded` is classified
13342
+ * `tryAnotherRelay: false` precisely so the client STOPS walking the fleet; without the verdict,
13343
+ * the loop walked it anyway, turning one client-side fault into what reads as a fleet outage.
13344
+ */
13345
+ async #proveToRelay(agentName, circuitAddr, node, correlationId,
13346
+ /**
13347
+ * Whether this proof is the STANDING RECEIVER's, and may therefore write the surface
13348
+ * `cello_status` reads as "your standing receiver was refused".
13349
+ *
13350
+ * A revival proves itself too, and its refusal is real — but it is not evidence about the
13351
+ * receiver. A receiver that proved thirty seconds ago and holds a slot, plus a revival refused
13352
+ * with `slot_cap_exceeded`, would otherwise have `cello_status` report a front door as refused
13353
+ * while it is open. The refusal is still logged and still steers the candidate loop; what it
13354
+ * does not do is claim to be about something it did not measure.
13355
+ */
13356
+ surfaceAsReceiverRefusal) {
13357
+ const relayPeerId = relayPeerIdOf(circuitAddr);
13358
+ const baseRelayAddr = circuitAddr.split("/p2p-circuit")[0];
13359
+ if (!relayPeerId || !baseRelayAddr) {
13360
+ this.#logger.warn("session.standing_receiver.prove.address_unreadable", {
13361
+ agentName,
13362
+ circuitAddr,
13363
+ correlationId,
13364
+ impact: "this candidate's circuit address does not name a relay peer, so there is nothing " +
13365
+ "to prove to and its reservation will stay refused. Skipped silently before — which made " +
13366
+ "a malformed address look identical to a relay that simply said no.",
13367
+ });
13368
+ return "unavailable";
13369
+ }
13370
+ /**
13371
+ * Declared out here so the `finally` can close it. Scoped inside the `try` before, so a throw
13372
+ * from `proveReservation` skipped the close and left the stream and its pending settles behind.
13373
+ */
13374
+ let client;
13375
+ try {
13376
+ client = this.#detachedRelayClientBuilder?.(agentName, relayPeerId, [baseRelayAddr], {
13377
+ receiptStore: this.#relayReceiptStore ?? undefined,
13378
+ sealLeafStore: this.#sealLeafStore ?? undefined,
13379
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
13380
+ });
13381
+ if (!client) {
13382
+ this.#logger.warn("session.standing_receiver.prove.no_builder", {
13383
+ agentName,
13384
+ relayPeerId,
13385
+ correlationId,
13386
+ impact: "no relay client could be built, so this receiver cannot prove itself and the " +
13387
+ "relay will refuse its reservation again. The agent is reachable only over a direct " +
13388
+ "connection until this is wired.",
13389
+ });
13390
+ return "unavailable";
13391
+ }
13392
+ const proven = await client.proveReservation(node);
13393
+ if (proven) {
13394
+ if (surfaceAsReceiverRefusal)
13395
+ this.#srRelayRefusal.delete(agentName);
13396
+ this.#logger.info("session.standing_receiver.prove.result", {
13397
+ agentName, relayPeerId, peerId: node.getPeerId(), proven: true, correlationId,
13398
+ });
13399
+ return "proven";
13400
+ }
13401
+ /**
13402
+ * The same two lines `#authenticateStandingReceiver` runs, for the same reason. The `else`
13403
+ * matters as much as the `if`: `proveReservation` also fails for transport reasons, which
13404
+ * leave `getLastAuthRefusal()` null, and leaving a PREVIOUS refusal in the map would have
13405
+ * `cello_status` explaining a cause that is no longer what is wrong.
13406
+ */
13407
+ const refusal = client.getLastAuthRefusal();
13408
+ if (surfaceAsReceiverRefusal) {
13409
+ if (refusal) {
13410
+ this.#srRelayRefusal.set(agentName, { ...this.#withDirectoryCause(agentName, refusal), relayPeerId });
13411
+ }
13412
+ else {
13413
+ this.#srRelayRefusal.delete(agentName);
13414
+ }
13415
+ }
13416
+ this.#logger.warn("session.standing_receiver.prove.result", {
13417
+ agentName,
13418
+ relayPeerId,
13419
+ peerId: node.getPeerId(),
13420
+ proven: false,
13421
+ refusalReason: refusal?.reason ?? "no_relay_verdict",
13422
+ tryAnotherRelay: refusal?.tryAnotherRelay ?? true,
13423
+ correlationId,
13424
+ impact: refusal?.advice ??
13425
+ "the relay would not accept this agent's proof and said nothing about why, which is what " +
13426
+ "a transport failure mid-handshake looks like. The candidate loop moves on to the next relay.",
13427
+ });
13428
+ if (refusal && !refusal.tryAnotherRelay)
13429
+ return "refused_this_agent";
13430
+ if (refusal?.tryAnotherRelay && !this.#shuttingDown) {
13431
+ this.#quarantineRelay(agentName, relayPeerId, refusal.reason);
13432
+ }
13433
+ return "refused_try_another_relay";
13434
+ }
13435
+ catch (err) {
13436
+ this.#logger.warn("session.standing_receiver.prove.failed", {
13437
+ agentName,
13438
+ correlationId,
13439
+ error: extractErrorMessage(err),
13440
+ impact: "this receiver could not prove itself, so its retry will be refused and the " +
13441
+ "candidate loop will try the next relay.",
13442
+ });
13443
+ return "unavailable";
13444
+ }
13445
+ finally {
13446
+ client?.close();
13447
+ }
13448
+ }
10432
13449
  async #startReceiverNode(agentName, sessionId, gater, candidateCircuitAddrs, correlationId) {
10433
13450
  for (const circuitAddr of candidateCircuitAddrs) {
10434
13451
  // DOD-M12B-SESSION-SEED-1: A SEED PER CANDIDATE, NOT ONE FOR THE LOOP.
@@ -10444,60 +13461,158 @@ export class SessionNodeManager {
10444
13461
  //
10445
13462
  // Nothing reads the seed before the winner is installed, so per-candidate costs nothing.
10446
13463
  const candidateSeed = randomBytes(32);
10447
- const candidate = await this.#factory.createNode({
10448
- sessionId,
10449
- connectionGater: gater,
10450
- nodeType: "standing_receiver",
10451
- circuitRelayListenAddrs: [circuitAddr],
10452
- transportPrivateKey: candidateSeed,
10453
- });
10454
- let timer;
10455
- const timedOut = Symbol("reservation_timeout");
10456
- let outcome = "failed";
10457
- let error = "";
10458
- try {
10459
- outcome = await Promise.race([
10460
- candidate.start().then(() => "started"),
10461
- new Promise((resolve) => {
10462
- timer = setTimeout(() => resolve(timedOut), this.#srReservationTimeoutMs);
10463
- }),
10464
- ]);
13464
+ /**
13465
+ * DOD-M15-RELAYSLOTS-1 — **TWO ATTEMPTS PER RELAY: ask, prove, ask again.**
13466
+ *
13467
+ * The relay now refuses a reservation from a peer that has not shown it belongs to a
13468
+ * registered agent. A brand-new receiver has shown nothing, so its FIRST ask is refused —
13469
+ * expected, not a failure. It then authenticates over `/cello/relay/1.0.0`, which tells the
13470
+ * relay this transport identity is a registered agent's, and asks again on a fresh connection
13471
+ * carrying the SAME identity (that is what reusing `candidateSeed` buys).
13472
+ *
13473
+ * ⚠️ It has to be two connections, and that was measured rather than chosen. Taking the
13474
+ * reservation by hand on the same connection as the proof DOES get a slot — and libp2p then
13475
+ * announces no circuit address for it, because it only announces addresses for reservations
13476
+ * its own relay-discovery made. The agent would hold a slot nobody could dial through.
13477
+ */
13478
+ let candidateNode;
13479
+ // Set when the relay refused the AGENT rather than being unwilling itself: every other relay
13480
+ // in the pool answers identically, so the walk ends here rather than reproducing it N times.
13481
+ let candidateRefusedAgent = false;
13482
+ for (let attempt = 0; attempt < 2; attempt++) {
13483
+ const candidate = await this.#createAgentNode(agentName, {
13484
+ sessionId,
13485
+ connectionGater: gater,
13486
+ nodeType: "standing_receiver",
13487
+ circuitRelayListenAddrs: [circuitAddr],
13488
+ transportPrivateKey: candidateSeed,
13489
+ });
13490
+ let timer;
13491
+ const timedOut = Symbol("reservation_timeout");
13492
+ let outcome = "failed";
13493
+ let error = "";
13494
+ try {
13495
+ outcome = await Promise.race([
13496
+ candidate.start().then(() => "started"),
13497
+ new Promise((resolve) => {
13498
+ timer = setTimeout(() => resolve(timedOut), this.#srReservationTimeoutMs);
13499
+ }),
13500
+ ]);
13501
+ }
13502
+ catch (err) {
13503
+ error = extractErrorMessage(err);
13504
+ }
13505
+ finally {
13506
+ if (timer !== undefined)
13507
+ clearTimeout(timer);
13508
+ }
13509
+ // The only proof that counts: the relay actually GRANTED the reservation.
13510
+ // start() resolving is not enough — a relay that is out of reservation slots
13511
+ // completes the handshake and simply grants nothing, leaving a node that looks
13512
+ // started and is reachable by nobody.
13513
+ if (outcome === "started" && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
13514
+ candidateNode = candidate;
13515
+ break;
13516
+ }
13517
+ /**
13518
+ * No reservation. On the FIRST attempt that is the expected answer for a receiver that has
13519
+ * not proved itself yet, so prove and go round once more. `proveReservation` opens its own
13520
+ * stream from this node, which is what binds this transport identity to the agent at the
13521
+ * relay; the relay remembers it across the reconnect below.
13522
+ */
13523
+ if (attempt === 0 && outcome === "started") {
13524
+ const verdict = await this.#proveToRelay(agentName, circuitAddr, candidate, correlationId, true);
13525
+ // AWAITED, not fire-and-forget: the retry rebuilds on this same transport identity, and two
13526
+ // live nodes sharing one peer id is the defect DOD-M12B-SESSION-SEED-1 exists to prevent.
13527
+ try {
13528
+ await candidate.stop();
13529
+ }
13530
+ catch { /* it may never have finished starting */ }
13531
+ /**
13532
+ * DOD-M15-RELAYSLOTS-1 clause 9 — **A CLIENT-SIDE REFUSAL ENDS THE WALK.**
13533
+ *
13534
+ * `slot_cap_exceeded` and an expired or missing token are classified `tryAnotherRelay:
13535
+ * false` because they reproduce on every relay in the pool: the cap is per AGENT, and the
13536
+ * token comes from the directory, not from here. Walking on costs a node build and two
13537
+ * dials per remaining relay to arrive at the same answer, and it makes one client-side
13538
+ * fault look like a fleet-wide outage in the logs. The refusal is already recorded where
13539
+ * `cello_status` reads it, so stopping is not silence.
13540
+ */
13541
+ if (verdict === "refused_this_agent") {
13542
+ this.#srLastRejectionReason.set(agentName, "relay_refused_this_agent");
13543
+ this.#logger.warn("session.standing_receiver.relay.rejected", {
13544
+ agentName,
13545
+ circuitAddr,
13546
+ reason: "relay_refused_this_agent",
13547
+ attempts: attempt + 1,
13548
+ correlationId,
13549
+ impact: "the relay refused this AGENT rather than this relay being unwilling or " +
13550
+ "unwell, so every other relay would refuse it identically. Stopped here; " +
13551
+ "cello_status carries the cause and what to do about it.",
13552
+ });
13553
+ candidateRefusedAgent = true;
13554
+ break;
13555
+ }
13556
+ /**
13557
+ * ⚠️ RETRY ONLY WHAT A PROOF CAN FIX. The second attempt exists because the relay now
13558
+ * remembers this transport identity; if the proof did not land, it remembers nothing and
13559
+ * the retry is a node build and a dial spent to be refused identically. Only `proven`
13560
+ * earns the retry — everything else moves to the next relay.
13561
+ */
13562
+ if (verdict !== "proven") {
13563
+ this.#srLastRejectionReason.set(agentName, "relay_proof_refused");
13564
+ this.#logger.warn("session.standing_receiver.relay.rejected", {
13565
+ agentName,
13566
+ circuitAddr,
13567
+ reason: "relay_proof_refused",
13568
+ attempts: attempt + 1,
13569
+ correlationId,
13570
+ impact: "this relay would not take the agent's proof, so it will refuse the retry the " +
13571
+ "same way. Moving to the next relay rather than asking this one twice.",
13572
+ });
13573
+ break;
13574
+ }
13575
+ continue;
13576
+ }
13577
+ const rejectionReason = outcome === "started"
13578
+ ? /**
13579
+ * ⚠️ Review MEDIUM-7 — **"STARTED" DOES NOT MEAN THE RELAY ANSWERED.** A circuit listen
13580
+ * entry sets `FaultTolerance.NO_FATAL`, and `start()` only throws when the DIRECT
13581
+ * listener fails, so a relay that is simply DOWN resolves `started` with no circuit
13582
+ * address — indistinguishable, here, from a relay that answered and granted nothing.
13583
+ * Reporting that as `relay_granted_no_reservation` sends the operator to look at relay
13584
+ * capacity for what is a network fault. An open connection to the relay peer is the
13585
+ * thing that separates them, and we have one to ask.
13586
+ */
13587
+ (candidate.getConnections().some((c) => c.peerId === relayPeerIdOf(circuitAddr))
13588
+ ? "relay_granted_no_reservation"
13589
+ : "relay_unreachable")
13590
+ : outcome === "failed"
13591
+ ? "relay_unreachable"
13592
+ : "reservation_did_not_complete_in_time";
13593
+ this.#srLastRejectionReason.set(agentName, rejectionReason);
13594
+ this.#logger.warn("session.standing_receiver.relay.rejected", {
13595
+ agentName,
13596
+ circuitAddr,
13597
+ reason: rejectionReason,
13598
+ attempts: attempt + 1,
13599
+ ...(error !== "" ? { error } : {}),
13600
+ correlationId,
13601
+ });
13602
+ // Abandon it. start() may still be parked on a dial, so stop() is best-effort
13603
+ // and must never block the fallback.
13604
+ void Promise.resolve()
13605
+ .then(() => candidate.stop())
13606
+ .catch(() => { });
13607
+ break;
10465
13608
  }
10466
- catch (err) {
10467
- error = extractErrorMessage(err);
10468
- }
10469
- finally {
10470
- if (timer !== undefined)
10471
- clearTimeout(timer);
10472
- }
10473
- // The only proof that counts: the relay actually GRANTED the reservation.
10474
- // start() resolving is not enough — a relay that is out of reservation slots
10475
- // completes the handshake and simply grants nothing, leaving a node that looks
10476
- // started and is reachable by nobody.
10477
- if (outcome === "started" && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
10478
- return { node: candidate, seed: candidateSeed };
10479
- }
10480
- const rejectionReason = outcome === "started"
10481
- ? "relay_granted_no_reservation"
10482
- : outcome === "failed"
10483
- ? "relay_unreachable"
10484
- : "reservation_did_not_complete_in_time";
10485
- this.#srLastRejectionReason.set(agentName, rejectionReason);
10486
- this.#logger.warn("session.standing_receiver.relay.rejected", {
10487
- agentName,
10488
- circuitAddr,
10489
- reason: rejectionReason,
10490
- ...(error !== "" ? { error } : {}),
10491
- correlationId,
10492
- });
10493
- // Abandon it. start() may still be parked on a dial, so stop() is best-effort
10494
- // and must never block the fallback.
10495
- void Promise.resolve()
10496
- .then(() => candidate.stop())
10497
- .catch(() => { });
13609
+ if (candidateNode)
13610
+ return { node: candidateNode, seed: candidateSeed };
13611
+ if (candidateRefusedAgent)
13612
+ break;
10498
13613
  }
10499
13614
  const plainSeed = randomBytes(32);
10500
- const plain = await this.#factory.createNode({
13615
+ const plain = await this.#createAgentNode(agentName, {
10501
13616
  sessionId,
10502
13617
  connectionGater: gater,
10503
13618
  nodeType: "standing_receiver",
@@ -10624,6 +13739,23 @@ export class SessionNodeManager {
10624
13739
  sessionPeerId: node.getPeerId(),
10625
13740
  correlationId,
10626
13741
  });
13742
+ // DOD-M15-RELAYAUTH-1: authenticate to the reservation relay NOW, not when a session first
13743
+ // needs one. The relay times out a reservation nobody has proven key possession for
13744
+ // (relay-connection-gater.ts, trustless-cello) — proving it here, instead of waiting for a
13745
+ // real session to exist, is what keeps this reservation alive past that grace window.
13746
+ // Best-effort and unawaited: a failure here costs nothing beyond the relay's own grace-window
13747
+ // revoke, which the reservation watchdog already treats as an ordinary lost reservation.
13748
+ if (reservedRelayPeerId !== undefined && heldCircuitAddr !== undefined) {
13749
+ void this.#authenticateStandingReceiver(agentName, node, reservedRelayPeerId, heldCircuitAddr, correlationId)
13750
+ .catch((err) => {
13751
+ this.#logger.warn("session.standing_receiver.relay_auth.failed", {
13752
+ agentName,
13753
+ relayPeerId: reservedRelayPeerId,
13754
+ error: extractErrorMessage(err),
13755
+ correlationId,
13756
+ });
13757
+ });
13758
+ }
10627
13759
  // DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come
10628
13760
  // up? circuitAddrs === 0 with reservations requested means every relay
10629
13761
  // refused/was unreachable — the agent is deaf to NAT'd initiators (public
@@ -10733,63 +13865,131 @@ export class SessionNodeManager {
10733
13865
  */
10734
13866
  async #buildRevivedNode(sessionId, gater, seed, candidateAddrs, agentName) {
10735
13867
  for (const circuitAddr of candidateAddrs.slice(0, REVIVE_RESERVATION_CANDIDATES)) {
10736
- const candidate = await this.#factory.createNode({
10737
- sessionId,
10738
- connectionGater: gater,
10739
- nodeType: "session",
10740
- inboundReachable: true,
10741
- transportPrivateKey: seed,
10742
- circuitRelayListenAddrs: [circuitAddr],
10743
- });
10744
- // KEEP THE START PROMISE. Review HIGH-3: `libp2p.stop()` opens with
10745
- // `if (this.status !== 'started') return`, and during the whole timeout window the status is
10746
- // `'starting'` so awaiting `stop()` on a timed-out candidate stopped nothing and waited for
10747
- // nothing. The abandoned `start()` stayed in flight, and if the relay answered late the node
10748
- // went live holding THIS SESSION'S peer id, sharing the gater (so it admits the counterparty)
10749
- // with no content handler registered, and with no reference left to stop it. Verified against
10750
- // libp2p 3.3.2 rather than assumed.
10751
- const startP = candidate.start();
10752
- let startError;
10753
- const started = await Promise.race([
10754
- startP.then(() => true),
10755
- new Promise((res) => setTimeout(() => res(false), REVIVE_RESERVATION_TIMEOUT_MS).unref?.()),
10756
- ]).catch((err) => { startError = err; return false; });
10757
- if (started && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
10758
- this.#logger.info("session.revive.reservation.granted", { agentName, sessionId });
10759
- return candidate;
10760
- }
10761
- // Started but granted nothing, or never started. Either way this node is not the one.
10762
- //
10763
- // Review MEDIUM-5: name WHICH of the three causes this was, the way `#startReceiverNode` does.
10764
- // "declined" alone stood for a relay that is full, a relay that is unreachable, and a relay
10765
- // that is merely slow three different problems with three different responses, and the
10766
- // thrown error was discarded entirely.
10767
- const declineReason = started
10768
- ? "relay_granted_no_reservation"
10769
- : startError !== undefined
10770
- ? "relay_unreachable"
10771
- : "reservation_did_not_complete_in_time";
10772
- const isLast = circuitAddr === candidateAddrs.slice(0, REVIVE_RESERVATION_CANDIDATES).at(-1);
10773
- this.#logger.warn("session.revive.reservation.declined", {
10774
- agentName,
10775
- sessionId,
10776
- circuitAddr,
10777
- reason: declineReason,
10778
- ...(startError !== undefined ? { error: extractErrorMessage(startError) } : {}),
10779
- impact: isLast
10780
- ? "no relay granted; the session comes up reachable only via the relay park route"
10781
- : "trying the next relay",
10782
- });
10783
- // Teardown at SETTLEMENT, not now: a `stop()` issued while the node is still starting is a
10784
- // no-op (see above), so the only way to guarantee this node dies is to wait for its own start
10785
- // to finish first. Not awaited, so a hung start cannot hold the revival up the point is that
10786
- // the teardown eventually happens, not that it happens before the next candidate.
10787
- void startP.then(() => candidate.stop().catch(() => { }), () => { });
13868
+ /**
13869
+ * DOD-M15-RELAYSLOTS-1 — **A REVIVAL PROVES ITSELF TOO.**
13870
+ *
13871
+ * Review HIGH-3. The relay refuses a reservation to a peer that has not shown it belongs to
13872
+ * a registered agent, and it remembers a proof for two minutes. A revival is almost never
13873
+ * inside that window — the receiver last proved this peer id when the session was created,
13874
+ * possibly days ago — so without this loop every revived session was refused by every
13875
+ * candidate and came up on the plain floor: alive, `active`, and dialable by nobody, with
13876
+ * every message in both directions forced through the relay park route.
13877
+ *
13878
+ * Two attempts, exactly as `#startReceiverNode` does it, and for the same measured reason:
13879
+ * a reservation taken by hand on the same connection as the proof yields no dialable address.
13880
+ * The seed is fixed here that is what a revival IS so the second attempt necessarily
13881
+ * carries the identity the relay just recorded.
13882
+ */
13883
+ let revivedNode;
13884
+ let terminalRefusal = false;
13885
+ for (let attempt = 0; attempt < 2 && !terminalRefusal; attempt++) {
13886
+ const candidate = await this.#createAgentNode(agentName, {
13887
+ sessionId,
13888
+ connectionGater: gater,
13889
+ nodeType: "session",
13890
+ inboundReachable: true,
13891
+ transportPrivateKey: seed,
13892
+ circuitRelayListenAddrs: [circuitAddr],
13893
+ });
13894
+ // KEEP THE START PROMISE. Review HIGH-3: `libp2p.stop()` opens with
13895
+ // `if (this.status !== 'started') return`, and during the whole timeout window the status is
13896
+ // `'starting'` so awaiting `stop()` on a timed-out candidate stopped nothing and waited for
13897
+ // nothing. The abandoned `start()` stayed in flight, and if the relay answered late the node
13898
+ // went live holding THIS SESSION'S peer id, sharing the gater (so it admits the counterparty)
13899
+ // with no content handler registered, and with no reference left to stop it. Verified against
13900
+ // libp2p 3.3.2 rather than assumed.
13901
+ const startP = candidate.start();
13902
+ let startError;
13903
+ const started = await Promise.race([
13904
+ startP.then(() => true),
13905
+ new Promise((res) => setTimeout(() => res(false), REVIVE_RESERVATION_TIMEOUT_MS).unref?.()),
13906
+ ]).catch((err) => { startError = err; return false; });
13907
+ if (started && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
13908
+ this.#logger.info("session.revive.reservation.granted", { agentName, sessionId, attempts: attempt + 1 });
13909
+ revivedNode = candidate;
13910
+ break;
13911
+ }
13912
+ /**
13913
+ * No reservation on the first attempt is the EXPECTED answer for a peer whose proof has
13914
+ * aged out. Prove and go round once more.
13915
+ *
13916
+ * Only when `started` is true: `libp2p.stop()` opens with `if (this.status !== 'started')
13917
+ * return`, so a timed-out candidate cannot be torn down here and rebuilding on its seed
13918
+ * would put two live nodes on one peer id. That case falls through to the settlement-chained
13919
+ * teardown below, which is the only thing that reliably kills a still-starting node.
13920
+ */
13921
+ if (attempt === 0 && started) {
13922
+ const verdict = await this.#proveToRelay(agentName, circuitAddr, candidate, sessionId, false);
13923
+ try {
13924
+ await candidate.stop();
13925
+ }
13926
+ catch { /* best-effort */ }
13927
+ if (verdict === "refused_this_agent") {
13928
+ // The refusal is about this AGENT, so the remaining candidates would answer identically.
13929
+ terminalRefusal = true;
13930
+ this.#logger.warn("session.revive.reservation.declined", {
13931
+ agentName,
13932
+ sessionId,
13933
+ circuitAddr,
13934
+ reason: "relay_refused_this_agent",
13935
+ impact: "the relay refused this agent rather than being unwilling or unwell, so every " +
13936
+ "other relay refuses it the same way. The session comes up reachable only via the " +
13937
+ "relay park route; cello_status carries the cause.",
13938
+ });
13939
+ break;
13940
+ }
13941
+ // Only a landed proof earns the retry — see the same rule in `#startReceiverNode`.
13942
+ if (verdict !== "proven") {
13943
+ this.#logger.warn("session.revive.reservation.declined", {
13944
+ agentName,
13945
+ sessionId,
13946
+ circuitAddr,
13947
+ reason: "relay_proof_refused",
13948
+ impact: "this relay would not take the agent's proof, so asking it again would be " +
13949
+ "refused the same way. Trying the next relay.",
13950
+ });
13951
+ break;
13952
+ }
13953
+ continue;
13954
+ }
13955
+ // Started but granted nothing, or never started. Either way this node is not the one.
13956
+ //
13957
+ // Review MEDIUM-5: name WHICH of the three causes this was, the way `#startReceiverNode` does.
13958
+ // "declined" alone stood for a relay that is full, a relay that is unreachable, and a relay
13959
+ // that is merely slow — three different problems with three different responses, and the
13960
+ // thrown error was discarded entirely.
13961
+ const declineReason = started
13962
+ ? "relay_granted_no_reservation"
13963
+ : startError !== undefined
13964
+ ? "relay_unreachable"
13965
+ : "reservation_did_not_complete_in_time";
13966
+ const isLast = circuitAddr === candidateAddrs.slice(0, REVIVE_RESERVATION_CANDIDATES).at(-1);
13967
+ this.#logger.warn("session.revive.reservation.declined", {
13968
+ agentName,
13969
+ sessionId,
13970
+ circuitAddr,
13971
+ reason: declineReason,
13972
+ ...(startError !== undefined ? { error: extractErrorMessage(startError) } : {}),
13973
+ impact: isLast
13974
+ ? "no relay granted; the session comes up reachable only via the relay park route"
13975
+ : "trying the next relay",
13976
+ });
13977
+ // Teardown at SETTLEMENT, not now: a `stop()` issued while the node is still starting is a
13978
+ // no-op (see above), so the only way to guarantee this node dies is to wait for its own start
13979
+ // to finish first. Not awaited, so a hung start cannot hold the revival up — the point is that
13980
+ // the teardown eventually happens, not that it happens before the next candidate.
13981
+ void startP.then(() => candidate.stop().catch(() => { }), () => { });
13982
+ break;
13983
+ }
13984
+ if (revivedNode)
13985
+ return revivedNode;
13986
+ if (terminalRefusal)
13987
+ break;
10788
13988
  }
10789
13989
  // THE FLOOR. No reservation, so the counterparty cannot dial us directly — but their messages
10790
13990
  // park at the relay and drain, which is how every message in the 2026-08-18 test arrived. A
10791
13991
  // session usable one way beats a session that never comes back.
10792
- const plain = await this.#factory.createNode({
13992
+ const plain = await this.#createAgentNode(agentName, {
10793
13993
  sessionId,
10794
13994
  connectionGater: gater,
10795
13995
  nodeType: "session",
@@ -10855,6 +14055,8 @@ export class SessionNodeManager {
10855
14055
  client = this.#detachedRelayClientBuilder?.(agentName, ep.relayPeerId, [...ep.relayAddrs], {
10856
14056
  receiptStore: this.#relayReceiptStore ?? undefined,
10857
14057
  sealLeafStore: this.#sealLeafStore ?? undefined,
14058
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
14059
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
10858
14060
  });
10859
14061
  if (!client) {
10860
14062
  this.#logger.warn("session.revive.relay.builder_absent", {
@@ -11130,6 +14332,24 @@ export class SessionNodeManager {
11130
14332
  // different `impact` text — one event name standing for two meanings, fired twice for a single
11131
14333
  // condition. `#reconnectRevivedSessionRelay` takes it as a parameter now.
11132
14334
  const persistedRelay = this.getPersistedRelayEndpoint(agentName, sessionId);
14335
+ // 006-CRYPTO: a REVIVED session mints a FRESH keypair and re-keys — Decisions Carried #5. That
14336
+ // holds because the interrupt path destroys the old secret when it drops the entry; until it
14337
+ // did, this call found the stale key still in the map and quietly kept it. The salt, which IS
14338
+ // persisted, is re-read from the row instead — opposite lifetimes, deliberately.
14339
+ this.#mintSessionEphemeral(agentName, sessionId);
14340
+ /**
14341
+ * AND ANNOUNCE IT — review F1, second half. Minting a fresh key achieves nothing on its own: the
14342
+ * COUNTERPARTY has to hear about it, and it is the side that did NOT restart, so it is not
14343
+ * tearing anything down or reconnecting. `#sendEphemeralFrame` otherwise rides `onPeerConnect`,
14344
+ * which does not fire again for a connection that never dropped — the ordinary shape when only
14345
+ * one end's witness stream closed, which is what a relay roll produces.
14346
+ *
14347
+ * Without this the two ends sit on different keys for the life of the session, every message
14348
+ * fails GCM, and the receiving operator is told the content may have been MODIFIED IN FLIGHT for
14349
+ * what is a local key skew. Deferred a tick so the revived node's handlers are registered before
14350
+ * the frame goes out.
14351
+ */
14352
+ setTimeout(() => { void this.#sendEphemeralFrame(agentName, sessionId, "revive-rekey"); }, 0);
11133
14353
  this.#activeNodes.set(key, {
11134
14354
  node,
11135
14355
  agentName,
@@ -11167,7 +14387,37 @@ export class SessionNodeManager {
11167
14387
  // `interrupted` leaves a live, talking session where REVIVAL-BOUND-1's sweep can seal or abandon
11168
14388
  // it. Failing here means tearing the node back down rather than running in that split state.
11169
14389
  if (!this.#updateSessionStatus(agentName, sessionId, "active")) {
14390
+ /**
14391
+ * DOD-M15-RELAYLEAK-1 (review MEDIUM-4) — **THIS TEARDOWN LEAKED THE EXACT THING THE LINE IS
14392
+ * ABOUT, THROUGH A DIFFERENT DOOR.**
14393
+ *
14394
+ * `#reconnectRevivedSessionRelay` above has already called `registerSession` on the cached
14395
+ * relay client and hung it on this entry. Deleting the map key and stopping the node released
14396
+ * the daemon's own objects and left that registration standing with **no owner** — and
14397
+ * `#detachSessionRelay` closes a client only when `!hasSessions()`, so the orphaned
14398
+ * registration held that predicate false for the life of the process. The client, its
14399
+ * authenticated stream and its relay-side reservation were unreachable and immortal.
14400
+ *
14401
+ * The shutdown loop this line added does sweep it at exit, which is precisely why it had to be
14402
+ * fixed here too: a leak that is only cleaned up by process death is still a leak for every
14403
+ * hour the daemon is up.
14404
+ */
14405
+ /**
14406
+ * ⚠️ Review MEDIUM-2 — **THE ENTRY IS MATCHED BY IDENTITY, NOT BY KEY.** `reviveSessionNode`
14407
+ * has no in-flight guard: its `if (live) return` is separated from `#activeNodes.set` by the
14408
+ * whole node build, so two revivals for one key can both reach the `set` and the second
14409
+ * overwrites the first. Looking up by key alone would then hand THIS failing revival the
14410
+ * OTHER one's live entry, and detaching it would unregister a running session's leaf handler
14411
+ * — closing the client that session is using if it was the last one on it. Comparing `node`
14412
+ * costs one token and makes "the entry I created" provable rather than assumed.
14413
+ */
14414
+ const revivedEntry = this.#activeNodes.get(key);
14415
+ if (revivedEntry?.node === node)
14416
+ this.#detachSessionRelay(revivedEntry);
11170
14417
  this.#activeNodes.delete(key);
14418
+ // 006-CRYPTO: the revival FAILED, so the key it just minted belongs to a session that never
14419
+ // came back. Dropping the entry without this would strand it for the daemon's lifetime.
14420
+ this.#destroySessionEphemeralFor(agentName, sessionId);
11171
14421
  try {
11172
14422
  await node.stop();
11173
14423
  }