@cello-protocol/daemon 0.0.182 → 0.0.183

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 (90) 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/consortium-bootstrap.d.ts.map +1 -1
  5. package/dist/consortium-bootstrap.js +117 -2
  6. package/dist/consortium-bootstrap.js.map +1 -1
  7. package/dist/content-encryption-status.d.ts +97 -0
  8. package/dist/content-encryption-status.d.ts.map +1 -0
  9. package/dist/content-encryption-status.js +122 -0
  10. package/dist/content-encryption-status.js.map +1 -0
  11. package/dist/content-park-client.d.ts +10 -0
  12. package/dist/content-park-client.d.ts.map +1 -1
  13. package/dist/content-park-client.js +84 -3
  14. package/dist/content-park-client.js.map +1 -1
  15. package/dist/content-park.d.ts.map +1 -1
  16. package/dist/content-park.js +184 -13
  17. package/dist/content-park.js.map +1 -1
  18. package/dist/daemon.d.ts.map +1 -1
  19. package/dist/daemon.js +306 -23
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/document-handlers.d.ts.map +1 -1
  22. package/dist/document-handlers.js +119 -0
  23. package/dist/document-handlers.js.map +1 -1
  24. package/dist/document-layer.d.ts +0 -2
  25. package/dist/document-layer.d.ts.map +1 -1
  26. package/dist/document-layer.js.map +1 -1
  27. package/dist/initiate-session-handler.d.ts.map +1 -1
  28. package/dist/initiate-session-handler.js +34 -3
  29. package/dist/initiate-session-handler.js.map +1 -1
  30. package/dist/manifest-deps.d.ts +0 -11
  31. package/dist/manifest-deps.d.ts.map +1 -1
  32. package/dist/manifest-deps.js +28 -2
  33. package/dist/manifest-deps.js.map +1 -1
  34. package/dist/manifest-validity.d.ts +2 -0
  35. package/dist/manifest-validity.d.ts.map +1 -1
  36. package/dist/manifest-validity.js +1 -1
  37. package/dist/manifest-validity.js.map +1 -1
  38. package/dist/outbound-sessions.d.ts.map +1 -1
  39. package/dist/outbound-sessions.js +19 -0
  40. package/dist/outbound-sessions.js.map +1 -1
  41. package/dist/park-envelope.d.ts +23 -0
  42. package/dist/park-envelope.d.ts.map +1 -1
  43. package/dist/park-envelope.js +76 -0
  44. package/dist/park-envelope.js.map +1 -1
  45. package/dist/register-handler.d.ts.map +1 -1
  46. package/dist/register-handler.js +76 -0
  47. package/dist/register-handler.js.map +1 -1
  48. package/dist/relay-only.d.ts +133 -0
  49. package/dist/relay-only.d.ts.map +1 -0
  50. package/dist/relay-only.js +183 -0
  51. package/dist/relay-only.js.map +1 -0
  52. package/dist/retry-queue.d.ts +26 -1
  53. package/dist/retry-queue.d.ts.map +1 -1
  54. package/dist/retry-queue.js +18 -1
  55. package/dist/retry-queue.js.map +1 -1
  56. package/dist/session-ceremony.d.ts +6 -0
  57. package/dist/session-ceremony.d.ts.map +1 -1
  58. package/dist/session-ceremony.js +26 -0
  59. package/dist/session-ceremony.js.map +1 -1
  60. package/dist/session-content-handlers.d.ts +12 -0
  61. package/dist/session-content-handlers.d.ts.map +1 -1
  62. package/dist/session-content-handlers.js +39 -4
  63. package/dist/session-content-handlers.js.map +1 -1
  64. package/dist/session-node-manager.d.ts +247 -4
  65. package/dist/session-node-manager.d.ts.map +1 -1
  66. package/dist/session-node-manager.js +3088 -304
  67. package/dist/session-node-manager.js.map +1 -1
  68. package/dist/session-read-handlers.d.ts.map +1 -1
  69. package/dist/session-read-handlers.js +16 -1
  70. package/dist/session-read-handlers.js.map +1 -1
  71. package/dist/session-relay-client.d.ts +116 -0
  72. package/dist/session-relay-client.d.ts.map +1 -1
  73. package/dist/session-relay-client.js +395 -6
  74. package/dist/session-relay-client.js.map +1 -1
  75. package/dist/session-salt-agreement.d.ts +58 -4
  76. package/dist/session-salt-agreement.d.ts.map +1 -1
  77. package/dist/session-salt-agreement.js +110 -3
  78. package/dist/session-salt-agreement.js.map +1 -1
  79. package/dist/signaling-connect.d.ts +18 -1
  80. package/dist/signaling-connect.d.ts.map +1 -1
  81. package/dist/signaling-connect.js +164 -1
  82. package/dist/signaling-connect.js.map +1 -1
  83. package/dist/types.d.ts +21 -0
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/types.js.map +1 -1
  86. package/dist/wire-content-hash.d.ts +15 -3
  87. package/dist/wire-content-hash.d.ts.map +1 -1
  88. package/dist/wire-content-hash.js +15 -3
  89. package/dist/wire-content-hash.js.map +1 -1
  90. 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.
@@ -85,6 +91,15 @@ const MAX_UNREADABLE_ALG_FRAMES = 64;
85
91
  * park-only session never starts one and never waits (constraint 5).
86
92
  */
87
93
  const SALT_AGREEMENT_WAIT_MS = 5_000;
94
+ /**
95
+ * How many times this side re-attempts its session-key announce, and the base delay between them.
96
+ *
97
+ * Bounded on purpose (review F5): the announce rides `onPeerConnect`, so a connection that stays up
98
+ * after one failed attempt would never produce another — encryption off for the life of the session,
99
+ * with guidance pointing at a reconnect that never comes.
100
+ */
101
+ const SESSION_KEY_ANNOUNCE_RETRIES = 4;
102
+ const SESSION_KEY_ANNOUNCE_RETRY_MS = 250;
88
103
  /**
89
104
  * WHY a session is hashing unsalted — review Finding 1, and this exists because one sentence was
90
105
  * carrying five different situations.
@@ -106,8 +121,35 @@ const UNSALTED_REASONS = {
106
121
  NO_AGREEMENT_STARTED: "no_agreement_started",
107
122
  /** We announced and they did not answer inside the bound. */
108
123
  AGREEMENT_TIMED_OUT: "agreement_timed_out",
109
- /** They answered, terminally: they have already hashed content and can never adopt. */
124
+ /**
125
+ * They answered, terminally: they have already hashed content and can never adopt.
126
+ *
127
+ * ⚠️ THIS IS ONE OF FOUR THINGS THE PEER CAN SAY, AND IT USED TO BE ALL OF THEM — 006-CRYPTO
128
+ * finding 2. The wire frame carries WHICH reason, and `SaltAgreementFrame.adoptionClosed` is a
129
+ * label rather than a boolean precisely so a caller cannot say `closed` without saying why. That
130
+ * distinction reached the log and was then dropped one call before the operator, who was told
131
+ * "they had already hashed messages" no matter which of the four it was.
132
+ */
110
133
  PEER_CLOSED_ADOPTION: "peer_closed_adoption",
134
+ /**
135
+ * They answered terminally because their side could NOT READ its own frontier — local storage
136
+ * trouble on their machine, not a conversation that started early.
137
+ *
138
+ * Kept apart from `PEER_CLOSED_ADOPTION` because the remedies are opposites: a new session fixes
139
+ * the already-hashing case and does nothing at all for this one.
140
+ */
141
+ PEER_FRONTIER_UNREADABLE: "peer_frontier_unreadable",
142
+ /** They answered terminally because the two sides could not converge — 006-CRYPTO finding 1. */
143
+ PEER_EXCHANGE_STALLED: "peer_exchange_stalled",
144
+ /**
145
+ * They closed adoption naming a reason THIS build does not recognise.
146
+ *
147
+ * Deliberately non-asserting. The peer chooses this string, so the safe rendering states what we
148
+ * know — they declined, and the label is in the log line above — and asserts nothing about why.
149
+ * Guessing here is how an operator ends up asking a counterparty to change something that was
150
+ * never the problem.
151
+ */
152
+ PEER_CLOSED_UNSPECIFIED: "peer_closed_unspecified",
111
153
  /** The session was torn down while the first send was still waiting. */
112
154
  SESSION_TORN_DOWN: "session_torn_down",
113
155
  /** This side already hashed, leafed, held or has in flight — adoption closed here. */
@@ -137,6 +179,9 @@ const UNSALTED_GUIDANCE = {
137
179
  [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
180
  [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
181
  [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.",
182
+ [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.",
183
+ [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.",
184
+ [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
185
  [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
186
  [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
187
  [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 +261,15 @@ const REDIAL_COOLDOWN_MS = 15_000;
216
261
  * span about 2.5 hours.
217
262
  */
218
263
  export const SR_RESERVATION_MAX_RETRIES = 5;
264
+ /**
265
+ * DOD-M15-RELAYSLOTS-1 — how long an agent skips a relay that refused it for a relay-side fault.
266
+ *
267
+ * Ten minutes: long enough that the agent is not re-asking a relay that cannot serve it every time
268
+ * its receiver rebuilds, short enough that when someone fixes that relay the agent finds it again
269
+ * without needing its own restart. The fault is on somebody else's machine and nobody tells us when
270
+ * it is fixed, so this has to expire on its own.
271
+ */
272
+ export const RELAY_QUARANTINE_MS = 10 * 60 * 1000;
219
273
  export const CAP_INTERRUPTED_TTL_MS = Number(process.env["CELLO_CAP_INTERRUPTED_TTL_MS"]) || 2 * 60 * 60 * 1000;
220
274
  /**
221
275
  * DOD-CAP-SELF-HEAL-1 — what counts against a per-sender acceptance bound.
@@ -302,6 +356,15 @@ export const REVIVE_RESERVATION_CANDIDATES = 2;
302
356
  * the healthy direct latency and far below anything a person would notice.
303
357
  */
304
358
  export const LEAF_FETCH_GRACE_MS = 2_000;
359
+ /**
360
+ * The relay's peer id out of a circuit listen address, or `null` if the address does not name one.
361
+ *
362
+ * Returns null rather than throwing or guessing: an unreadable address means we cannot tell which
363
+ * relay this candidate was for, and every caller has a real thing to do with that answer.
364
+ */
365
+ function relayPeerIdOf(circuitAddr) {
366
+ return /\/p2p\/([^/]+)\/p2p-circuit/.exec(circuitAddr)?.[1] ?? null;
367
+ }
305
368
  export class SessionNodeManager {
306
369
  #factory;
307
370
  #logger;
@@ -335,6 +398,13 @@ export class SessionNodeManager {
335
398
  * manager deliberately does not hold. Only consulted on the detached seal path — an ACTIVE session
336
399
  * always uses its own registered client.
337
400
  */
401
+ /**
402
+ * DOD-M15-RELAYSLOTS-1: `onlineToken` is REQUIRED on the dependency bag, not optional. Every
403
+ * relay client needs the directory's token or the relay refuses it a reservation slot, and the
404
+ * failure is invisible from the client side — the agent comes up, reports itself online, and is
405
+ * reachable by nobody. Making it required means a call site that forgets to pass it is a type
406
+ * error rather than an agent that quietly stops being dialable.
407
+ */
338
408
  #detachedRelayClientBuilder = null;
339
409
  setDetachedRelayClientBuilder(fn) {
340
410
  this.#detachedRelayClientBuilder = fn;
@@ -390,6 +460,8 @@ export class SessionNodeManager {
390
460
  client = this.#detachedRelayClientBuilder?.(agentName, ep.relayPeerId, [...ep.relayAddrs], {
391
461
  receiptStore: this.#relayReceiptStore ?? undefined,
392
462
  sealLeafStore: this.#sealLeafStore ?? undefined,
463
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
464
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
393
465
  });
394
466
  if (!client)
395
467
  return { error: "relay_client_unavailable" };
@@ -405,8 +477,230 @@ export class SessionNodeManager {
405
477
  // relay_session_gone` branch — which exists to tell "the relay never had it" apart from "the
406
478
  // relay swept or sealed it" — can never fire, so the operator is handed a first-message-race
407
479
  // label for a swept session.
408
- client.registerSession(sessionId, node);
409
- return { node, relayClient: client, relaySessionIdBytes: new Uint8Array(Buffer.from(sessionId, "hex")) };
480
+ /**
481
+ * DOD-M15-RELAYLEAK-1 **RE-REGISTERING A SESSION THAT IS ALREADY REGISTERED BLINDS IT.**
482
+ *
483
+ * `registerSession` REPLACES the entry's `onLeafDeliver` with `onLeafDeliver ?? (() => {})`, and
484
+ * unlike `assignment` / `recorded` it does NOT carry the existing handler forward. **This path
485
+ * passes no handler.** So a detached seal on a session that still holds a live registration
486
+ * would swap that session's inbound leaf delivery for a no-op: the counterparty's leaves keep
487
+ * arriving at the relay client and are dropped on the floor, while the operator sees a healthy
488
+ * session that has simply gone quiet.
489
+ *
490
+ * A call that finds the session already registered is therefore a PASSENGER: it uses the client
491
+ * and touches nothing. It also does not release — ownership is never inferred, only claimed.
492
+ *
493
+ * ⚠️ **This is NOT justified by a concurrency race, and an earlier version of this comment said
494
+ * it was.** Review checked: `#resolveSealTransport` and everything above the first `await` are
495
+ * synchronous, and a second caller plants/hits `#responderSealSubmitted` and returns
496
+ * `responder_seal_already_submitted` before ever reaching `submitLeaf`. Two callers cannot be in
497
+ * the client at once through the only caller, so "the second one closes the client the first is
498
+ * awaiting" **cannot happen**. The wrong reason mattered: it invites a future reader to delete
499
+ * this guard as defensive clutter once they notice the race is impossible — taking the
500
+ * handler-clobbering protection with it.
501
+ */
502
+ const claimedRegistration = !client.hasSession(sessionId);
503
+ if (claimedRegistration) {
504
+ client.registerSession(sessionId, node);
505
+ }
506
+ else {
507
+ /**
508
+ * Review MEDIUM-1 — **A PATH THAT DECLINES TO FIX A LEAK MUST SAY SO.**
509
+ *
510
+ * Reaching here means the session is registered on this client while `#activeNodes` holds no
511
+ * entry for it — and the ways that happens are all orphans: a previous `releaseDetached()`
512
+ * that threw (loud once, then silent forever after), or a revival that registered and then
513
+ * failed to hang the client on an entry. Declining to unregister is right — we cannot prove
514
+ * no live session owns it — but without this line the orphan is invisible, which is the exact
515
+ * shape this whole line exists to remove.
516
+ */
517
+ this.#logger.info("session.seal.transport.registration_shared", {
518
+ agentName,
519
+ sessionId,
520
+ impact: "this seal is using a relay registration it did not create, so it will not release it. If " +
521
+ "no live session owns that registration the client is held until process exit.",
522
+ });
523
+ }
524
+ /**
525
+ * DOD-M15-RELAYLEAK-1 — **`releaseOnDone` IS THE RELEASE SIGNAL, and its absence was the leak.**
526
+ *
527
+ * This branch (no `#activeNodes` entry) CACHES a relay client and registers a session on it, and
528
+ * nothing ever unregistered. `#detachSessionRelay` closes a client only when
529
+ * `!client.hasSessions()`, so a registration that is never removed keeps that predicate false
530
+ * **forever** — the client, its authenticated stream and its reader survive for the process
531
+ * lifetime. The LIVE branch above registers nothing here, so only this one needs releasing, and
532
+ * marking it is what lets the caller tell them apart without guessing.
533
+ */
534
+ return {
535
+ node,
536
+ relayClient: client,
537
+ relaySessionIdBytes: new Uint8Array(Buffer.from(sessionId, "hex")),
538
+ // Not `true` unconditionally: a passenger call must not release a registration it did not
539
+ // claim — see the note above `claimedRegistration`.
540
+ ...(claimedRegistration ? { releaseOnDone: true } : {}),
541
+ };
542
+ }
543
+ /**
544
+ * DOD-M15-RELAYAUTH-1: authenticate a fresh standing receiver to its reservation relay over the
545
+ * CELLO relay protocol — proof of K_local key possession, not a session. Reuses the SAME
546
+ * `#relayClients` cache `#connectSessionRelay`/`#resolveSealTransport` read from, keyed
547
+ * identically (`${agentName}::${relayPeerId}`), so a session created moments later on this same
548
+ * relay finds an already-authenticated client instead of dialing and authenticating twice.
549
+ *
550
+ * The manager holds no K_local (M12-P15's own rationale for `#detachedRelayClientBuilder`) —
551
+ * without a builder wired (a narrow startup race, or a test harness that never wires one), this
552
+ * is a no-op and the relay's own grace-window revoke is the backstop, not a defect in this path.
553
+ */
554
+ async #authenticateStandingReceiver(agentName, node, relayPeerId, heldCircuitAddr, correlationId) {
555
+ const clientKey = `${agentName}::${relayPeerId}`;
556
+ let client = this.#relayClients.get(clientKey);
557
+ if (!client) {
558
+ if (!this.#relayReceiptStore && this.#db)
559
+ this.#relayReceiptStore = new RelayReceiptStore(this.#db, this.#logger);
560
+ if (!this.#sealLeafStore && this.#db)
561
+ this.#sealLeafStore = new SessionSealLeafStore(this.#db, this.#logger);
562
+ /**
563
+ * ⚠️ SPLIT, NOT AN ANCHORED STRIP. The held address is
564
+ * `/ip4/…/tcp/…/p2p/<relay>/p2p-circuit/p2p/<self>` — the `/p2p-circuit` marker is in the
565
+ * MIDDLE, not at the end, so a `/\/p2p-circuit$/` replace matches nothing and silently
566
+ * hands the relay client a circuit address as its DIAL address. Measured, not assumed:
567
+ * that first version failed this file's own test because the client could not dial.
568
+ */
569
+ const baseRelayAddr = heldCircuitAddr.split("/p2p-circuit")[0] ?? heldCircuitAddr;
570
+ client = this.#detachedRelayClientBuilder?.(agentName, relayPeerId, [baseRelayAddr], {
571
+ receiptStore: this.#relayReceiptStore ?? undefined,
572
+ sealLeafStore: this.#sealLeafStore ?? undefined,
573
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
574
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
575
+ });
576
+ if (!client) {
577
+ /**
578
+ * Review M5: this was a `debug` line, and it is not a debug-level event.
579
+ *
580
+ * If no builder is wired we return without proving key possession, the relay revokes this
581
+ * receiver's reservation about fifteen seconds later, and the agent stops being reachable
582
+ * from behind NAT — while reporting itself perfectly healthy. Calling that "a narrow startup
583
+ * race" in a comment concedes it happens in production, and a system that is unreachable
584
+ * must not be quieter about it than a system that is merely slow.
585
+ */
586
+ this.#logger.warn("session.standing_receiver.relay_auth.no_builder", {
587
+ agentName,
588
+ relayPeerId,
589
+ correlationId,
590
+ impact: "this receiver cannot prove key possession, so the relay will revoke its reservation and " +
591
+ "the agent becomes unreachable from behind NAT — nobody can start a session with it — " +
592
+ "even though it still reports itself online.",
593
+ });
594
+ return;
595
+ }
596
+ this.#relayClients.set(clientKey, client);
597
+ }
598
+ /**
599
+ * ⚠️ `proveReservation`, NOT `connect`. Review HIGH-1: `connect()` short-circuits on the
600
+ * client's cached stream, which belongs to whichever node connected FIRST — so every
601
+ * REPLACEMENT standing receiver (the one built behind each new session) sent nothing, the relay
602
+ * never saw its transport identity, and its reservation was revoked ~15s later. The agent then
603
+ * churned reserve→revoke→rebuild for the life of the conversation, holding no usable circuit
604
+ * address, so while you were talking to one person nobody else could reach you.
605
+ *
606
+ * `proveReservation` always opens its own stream from THIS node, and marks it so the relay
607
+ * proves possession without rebinding the agent's delivery target away from the live session.
608
+ */
609
+ const proven = await client.proveReservation(node);
610
+ /**
611
+ * DOD-M15-RELAYSLOTS-1: keep the relay's refusal where the OPERATOR can reach it.
612
+ *
613
+ * The log line below is a good log line and it is not an answer to "why is my agent
614
+ * unreachable?" — nobody opens the file. The relay now refuses for reasons a person can act on
615
+ * (no token yet, too many sessions still open, this relay is misconfigured), each with its own
616
+ * next step, and every one of them is useless if it stops at a log.
617
+ */
618
+ if (!proven) {
619
+ const refusal = client.getLastAuthRefusal();
620
+ /**
621
+ * Review L2: the `else` is not symmetry for its own sake. `proveReservation` also fails for
622
+ * transport reasons, which leave `getLastAuthRefusal()` null — and without this branch the
623
+ * PREVIOUS refusal stayed in the map, so `cello_status` went on showing a cause and an
624
+ * affordance for something that was no longer what was wrong.
625
+ */
626
+ if (refusal)
627
+ this.#srRelayRefusal.set(agentName, { ...this.#withDirectoryCause(agentName, refusal), relayPeerId });
628
+ else
629
+ this.#srRelayRefusal.delete(agentName);
630
+ /**
631
+ * DOD-M15-RELAYSLOTS-1 clause 9 — **ACT on the classification, do not merely record it.**
632
+ * A relay-side fault means a different relay will work now, so quarantine this one and
633
+ * rebuild the receiver against the rest of the pool. Everything else stays put: a token
634
+ * problem reproduces on every relay, and walking the fleet would turn one client fault into
635
+ * what looks like a fleet-wide outage.
636
+ */
637
+ if (refusal?.tryAnotherRelay && !this.#shuttingDown) {
638
+ this.#quarantineRelay(agentName, relayPeerId, refusal.reason);
639
+ void this.#rebuildStandingReceiver(agentName);
640
+ }
641
+ }
642
+ else {
643
+ this.#srRelayRefusal.delete(agentName);
644
+ }
645
+ this.#logger.info("session.standing_receiver.relay_auth.result", {
646
+ agentName,
647
+ relayPeerId,
648
+ // The node that actually proved itself — without this, HIGH-1 was invisible in the logs: the
649
+ // line said `connected: true` for a receiver that had sent nothing.
650
+ nodePeerId: node.getPeerId(),
651
+ proven,
652
+ ...(proven ? {} : {
653
+ refusalReason: client.getLastAuthRefusal()?.reason ?? "no_relay_verdict",
654
+ tryAnotherRelay: client.getLastAuthRefusal()?.tryAnotherRelay ?? false,
655
+ }),
656
+ correlationId,
657
+ });
658
+ }
659
+ /**
660
+ * DOD-M15-RELAYSLOTS-1: the last relay refusal per agent, with the advice that goes with it.
661
+ * Written where the refusal is actually known; read by whatever tells the operator.
662
+ */
663
+ #srRelayRefusal = new Map();
664
+ /**
665
+ * Why this agent's standing receiver could not hold a reservation, in words the person running it
666
+ * can act on — or null when the last attempt succeeded or none has been made.
667
+ *
668
+ * This is the surface DoD clause 7 is about: the assertion that matters is what the CLIENT can
669
+ * show someone, not what the relay wrote in its own log.
670
+ */
671
+ getStandingReceiverRefusal(agentName) {
672
+ return this.#srRelayRefusal.get(agentName) ?? null;
673
+ }
674
+ /**
675
+ * DOD-M15-RELAYSLOTS-1 review M1 — **replace the relay's guess with the directory's fact.**
676
+ *
677
+ * The relay can only say "no token was presented"; the DIRECTORY knows why there was none, and
678
+ * the two most useful answers point somewhere the relay's own advice does not. Left alone,
679
+ * `online_token_required` tells the operator to check that their agent is reaching a directory —
680
+ * which, in the `not_registered_here` case, it plainly is.
681
+ */
682
+ #withDirectoryCause(agentName, refusal) {
683
+ if (refusal.reason !== "online_token_required")
684
+ return refusal;
685
+ const absent = this.#directoryOnlineTokenAbsent.get(agentName);
686
+ if (absent === "not_registered_here") {
687
+ return {
688
+ ...refusal,
689
+ advice: "The directory this agent is connected to holds no profile for its key, so it " +
690
+ "issued no token and no relay will grant a reservation. The directory connection itself " +
691
+ "is fine — either this agent registered against a different sovereign node and its " +
692
+ "profile has not replicated here yet, or it is not registered at all.",
693
+ };
694
+ }
695
+ if (absent === "issue_failed") {
696
+ return {
697
+ ...refusal,
698
+ advice: "The directory could not issue this agent a token — its own lookup or signing " +
699
+ "failed. That is a fault on the directory, not on this agent or on any relay; it usually " +
700
+ "clears on the next connection.",
701
+ };
702
+ }
703
+ return refusal;
410
704
  }
411
705
  // DOD-LOOP-1: the standing receiver is PER-AGENT, not per-daemon. A daemon hosting two agents
412
706
  // (the loopback case) needs each agent to have its OWN inbound receiver node — otherwise the
@@ -645,6 +939,89 @@ export class SessionNodeManager {
645
939
  * `alreadyRepairedAgainstPeerHalf`.
646
940
  */
647
941
  #saltRepairedAgainst = new Map();
942
+ /**
943
+ * THE MIRROR OF THE ABOVE — the peer FINGERPRINT we last answered with our half, hex.
944
+ *
945
+ * 006-CRYPTO finding 1. `#saltRepairedAgainst` terminates the salt-HOLDER's direction only. A side
946
+ * holding no salt answered every fingerprint with its contribution, and a latched holder answers
947
+ * every contribution with its fingerprint — so after one failed persist plus a reconnect, two
948
+ * healthy daemons repair at each other for the life of the session, one new stream and one INFO
949
+ * line each per round trip. Keyed on the peer's fingerprint BYTES for the same reason the other
950
+ * map is keyed on its half: a genuinely NEW fingerprint is new information and must still be
951
+ * answered; only an identical re-offer is the loop.
952
+ */
953
+ #saltRepairedAgainstFingerprint = new Map();
954
+ /**
955
+ * THE LABEL THE PEER GAVE when it closed adoption — 006-CRYPTO finding 2.
956
+ *
957
+ * The wire carries WHY, `session-salt-agreement.ts` makes it a union so a caller cannot close
958
+ * without saying why, and the agreement's `detail` puts it in the log. It was going no further:
959
+ * `#settleSaltPending(..., "closed")` recorded only that it was closed, so every one of the four
960
+ * reasons arrived at the operator as "they had already hashed messages".
961
+ *
962
+ * Stored raw and rendered through `#peerClosedReason`, which maps anything outside the known set
963
+ * to a non-asserting reason — the peer chooses these bytes.
964
+ */
965
+ #saltPeerClosedLabel = new Map();
966
+ /**
967
+ * THIS SESSION'S THROWAWAY KEYPAIR — `DOD-M15-KEYAGREE-1`, the lifecycle half (006-CRYPTO).
968
+ *
969
+ * **A `Map`, never a field on the session row, and that is the whole point.** The secret is held
970
+ * in memory and nowhere else: not in SQLite, not in a backup, not in an export. Forward secrecy is
971
+ * not a property of minting a fresh key — it is a property of the old one being GONE — so anything
972
+ * that made this durable would void it permanently and silently.
973
+ *
974
+ * Minted ONCE per session, at the moment the session becomes active, and destroyed at every site
975
+ * that drops the `#activeNodes` entry — see `#destroySessionEphemeralFor`, which explains why it
976
+ * is keyed to the entry rather than to the cache eviction. A revived session therefore mints a
977
+ * FRESH one and re-keys, which is Decisions Carried #5; that is only true because the interrupt
978
+ * path destroys, and an earlier version of this comment asserted it while the interrupt path
979
+ * silently kept the old key for hours.
980
+ *
981
+ * ⚠️ NOTHING SENDS THE PUBLIC HALF YET. The exchange, the signature over it, and encrypting
982
+ * content with the agreed secret are `007-CRYPTO`, and they are one wire format that ships
983
+ * together. Until that lands, this keypair is minted, held and destroyed correctly and no message
984
+ * is encrypted with it — which `#contentEncryptionStatus` states on the session itself rather than
985
+ * leaving a reader to assume.
986
+ */
987
+ #sessionEphemerals = new Map();
988
+ /**
989
+ * THE AGREED CONTENT KEY — `DOD-M15-EPHEMERAL-AUTH-1` (007-CRYPTO).
990
+ *
991
+ * Present only once the peer's SIGNED ephemeral has been verified against the counterparty
992
+ * identity this session is with. Absent means every message body on this session goes out under
993
+ * the transport's protection alone, and `#contentEncryptionState` says which of the reasons that
994
+ * is — never silence.
995
+ *
996
+ * In memory only, and destroyed with the ephemeral it came from. It is the same secret one step
997
+ * on, so persisting it would void forward secrecy exactly as persisting the ephemeral would.
998
+ */
999
+ #sessionContentKeys = new Map();
1000
+ /**
1001
+ * WHICH peer ephemeral produced the key we hold, hex — 007-CRYPTO, review F1.
1002
+ *
1003
+ * The idempotence guard keys on THIS rather than on "a key exists", because a re-keying peer sends
1004
+ * a DIFFERENT half and must be adopted, while the same half re-announced on every connect must
1005
+ * not churn. Keying on presence meant the side that never restarted kept a stale key and every
1006
+ * message failed to decrypt — reported to its operator as possible tampering.
1007
+ */
1008
+ #sessionContentKeyPeerHalf = new Map();
1009
+ /**
1010
+ * WHY this session has no content key, when it has none. A closed reason, never a free string.
1011
+ */
1012
+ #contentEncryptionReasons = new Map();
1013
+ /**
1014
+ * Resolve an agent's long-term identity signer.
1015
+ *
1016
+ * Injected after construction, like `setParkedDrainHook`, because the daemon builds its agent key
1017
+ * providers after this manager exists. Absent resolver, or an agent it does not know, means this
1018
+ * side cannot sign its ephemeral — reported as `NO_LOCAL_IDENTITY` rather than quietly skipping
1019
+ * the exchange, because "we could not" and "they would not" send the operator to opposite
1020
+ * machines.
1021
+ */
1022
+ #keyProviderResolver = null;
1023
+ /** Test-only observer of decoded inbound content frames — see `observeInboundContentFramesForTest`. */
1024
+ #inboundFrameObserver = null;
648
1025
  /**
649
1026
  * ─── B2b-2 state: what the SEND path needs that the row cannot answer ─────────────────────────
650
1027
  *
@@ -681,6 +1058,60 @@ export class SessionNodeManager {
681
1058
  */
682
1059
  #saltLastOutcome = new Map();
683
1060
  #hashedWithoutSalt = new Map();
1061
+ /**
1062
+ * `#hashedWithSalt` — how many content hashes this session has computed UNDER its salt and not yet
1063
+ * landed anywhere a count can see (`DOD-M15-SALTSPLIT-1`, review HIGH-2).
1064
+ *
1065
+ * The mirror of `#hashedWithoutSalt`, and it exists for the same window: a hash is computed, then a
1066
+ * relay round trip happens, and only afterwards does the message appear as a leaf, a hold or an
1067
+ * awaiting-ack entry. In between, every count reads zero.
1068
+ *
1069
+ * It is read by `#discardUnspentSalt` alone. "Unspent" must mean *nothing has been hashed under
1070
+ * it*, and without this the answer is *nothing has FINISHED being hashed under it* — which is the
1071
+ * question nobody asked, answered destructively.
1072
+ *
1073
+ * Never decremented on success: a salted hash that reaches the wire is spent forever, and unlike
1074
+ * the unsalted counter there is no `abandonUnsaltedHash` equivalent to undo. It is cleared only
1075
+ * with the rest of the session's caches. **For a discard decision, erring toward "spent" is the
1076
+ * safe direction** — a salt kept is recoverable, a salt erased is not.
1077
+ */
1078
+ #hashedWithSalt = new Map();
1079
+ /**
1080
+ * `#saltSuspended` — the peer has told us it can never hold a salt, so ours must not be USED. The
1081
+ * bytes stay on disk (`DOD-M15-SALTSPLIT-1`, the other lane's authorization argument).
1082
+ *
1083
+ * ⚠️ THIS REPLACED AN IMMEDIATE, IRREVERSIBLE ERASE, AND THE REFRAMING IS THE WHOLE POINT.
1084
+ *
1085
+ * I defended the erase as a compatibility question — a legacy peer might send the misleading frame,
1086
+ * we are pre-launch, do not carry weight for a state nobody is in. All true, and it does not reach
1087
+ * the question. **It is an AUTHORIZATION question:** the receiver performed an irreversible
1088
+ * destruction of durable key material on a peer's bare assertion with nothing to check it against.
1089
+ * Re-derived against an empty database — *would I let one side erase the other's key material on an
1090
+ * unauthenticated claim carrying no evidence?* No. My own empty-database rule argued FOR a guard,
1091
+ * not against one.
1092
+ *
1093
+ * And my own trigger was the proof I walked past: `frontier_unreadable` is not a legacy peer, it is
1094
+ * a **healthy current peer having one bad second**. Fixing the producer made our side stop emitting
1095
+ * it wrongly and left the receiver built to obey it — *one side of that exchange correct by
1096
+ * construction, the other still correct by luck.*
1097
+ *
1098
+ * A salt that cannot be used is inert. The destruction is what turned a transient disagreement into
1099
+ * a permanent one, so **nothing irreversible hangs on the claim any more** and proving the claim
1100
+ * stops being load-bearing.
1101
+ *
1102
+ * ⚠️ IN MEMORY ON PURPOSE, AND THE ERASE IS DEFERRED RATHER THAN CANCELLED. A durable mark needs a
1103
+ * column, and this milestone has lost data twice in the rebuild DDL. In-memory alone would split
1104
+ * the transcript at the next restart — unsalted now, salted after a reboot — so the salt IS erased,
1105
+ * at the first unsalted hash, which is the moment erasing becomes both harmless (nothing was hashed
1106
+ * under it) and REQUIRED (keeping it would re-salt after a restart). Before that moment a corrected
1107
+ * announce carrying a matching fingerprint un-suspends and the session recovers fully salted, which
1108
+ * erasure makes impossible even in principle: the far side cannot re-derive without both halves.
1109
+ *
1110
+ * A restart before either outcome loses the mark, we are salted again, the peer refuses one message,
1111
+ * and the announce re-runs and re-suspends. **One refused message, then convergence** — against a
1112
+ * dead session.
1113
+ */
1114
+ #saltSuspended = new Set();
684
1115
  #unsaltedAnnounced = new Set();
685
1116
  // DOD-M12B-ACK-1: pending linger-resets for inbound content streams the peer has not closed.
686
1117
  // Held so shutdown can drop them rather than leave timers pointing at a torn-down node.
@@ -726,6 +1157,15 @@ export class SessionNodeManager {
726
1157
  // of being appended out of order. Once the missing in-between sequence(s) land (recovered from the
727
1158
  // relay mailbox), #releaseHeld drains the held entries in canonical order. content is plaintext in
728
1159
  // memory only — evicted on teardown, same as #receivedContent.
1160
+ /**
1161
+ * DOD-M15-SEALWIRE-1 bullet 5 — `authorship` rides the held entry.
1162
+ *
1163
+ * A SENT message that lands ahead of our tree tail is held here and its transcript row is written
1164
+ * later, on release. The hold happens AFTER the submit, so it was signed exactly like an unheld
1165
+ * one — without carrying the proof through, a message that happened to queue behind a gap became
1166
+ * permanently less provable than the identical message that did not, for a reason with nothing to
1167
+ * do with authorship.
1168
+ */
729
1169
  #heldContent = new Map();
730
1170
  // DOD-MSG-4: the relay's high-water canonical sequence for this session — the largest sequence the
731
1171
  // relay has witnessed (max over leaf_deliver). Keyed #k(agent,session). EXPOSED for the next
@@ -777,6 +1217,25 @@ export class SessionNodeManager {
777
1217
  * signature. That is unavoidable: deciding whether bytes are a document frame requires the bytes.
778
1218
  * The router it calls never logs them.
779
1219
  */
1220
+ /**
1221
+ * ⚠️ THE RETURN TYPE USED TO DECLARE `ok?: boolean; reason?: string`, AND THE PRODUCER CANNOT
1222
+ * SUPPLY EITHER. Narrowed so reading them is a compile error rather than a silent `undefined`.
1223
+ *
1224
+ * The implementation is `DocumentFrameRouter.routeSync`, whose own return type
1225
+ * (`FrameClassification`) is exactly `{consumed:false} | {consumed:true; kind}` — it has no such
1226
+ * fields at all. The wider shape here was a promise only this declaration made, and it was
1227
+ * assignable precisely because the extra members were optional.
1228
+ *
1229
+ * It is not an oversight in the router: `routeSync` dispatches with `void this.#enqueue(...)`, so
1230
+ * when it returns, the frame has been classified and queued and **no verdict exists yet**. A
1231
+ * synchronous caller cannot be told an asynchronous outcome.
1232
+ *
1233
+ * **The cost of the lie was a wrong lead.** `j-stale-session`'s investigation read those fields'
1234
+ * absence from every log line as "the router returned neither" and filed it as the next thread to
1235
+ * pull. There was no thread: a JSON logger omits `undefined`, so a field that can never be set is
1236
+ * indistinguishable from one that was set to nothing. The verdict lives on
1237
+ * `document.frame.refused`, joined by `correlationId`.
1238
+ */
780
1239
  #onDocumentFrame = null;
781
1240
  /**
782
1241
  * DOD-DOC-SCREEN-CLASSIFY-1: the classify-only half of the hook above — is this a document
@@ -1078,7 +1537,11 @@ export class SessionNodeManager {
1078
1537
  reason: result.reason,
1079
1538
  cause: result.cause,
1080
1539
  });
1081
- return { outcome: "refused", cause: result.cause ?? result.reason };
1540
+ return {
1541
+ outcome: "refused",
1542
+ cause: result.cause ?? result.reason,
1543
+ ...(result.retryAfterMs !== undefined ? { retryAfterMs: result.retryAfterMs } : {}),
1544
+ };
1082
1545
  }
1083
1546
  return { outcome: "parked" };
1084
1547
  }
@@ -1422,9 +1885,22 @@ export class SessionNodeManager {
1422
1885
  -- transcript is only ever read by its owner, and worthless the moment it is shown to anyone
1423
1886
  -- else — which is the whole point of a notarized record.
1424
1887
  --
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.
1888
+ -- sender_sig holds one of TWO things, and which one is told by direction:
1889
+ -- RECEIVED row -> the Structure-2 signature, stored ONLY after the receiver verified it
1890
+ -- against the pubkey inside the sender's own signed bytes
1891
+ -- (#recordFrameOrdering). Verified, never claimed.
1892
+ -- SENT row -> OUR OWN signature over the Structure-1 bytes we put on the wire, taken
1893
+ -- from the submit result. Produced, not verified — there was no
1894
+ -- counterparty in the act, so it must NEVER be labelled verified_signature.
1895
+ --
1896
+ -- ⚠️ self_authored COVERS TWO PROVENANCES, and sender_sig IS NOT NULL is the discriminator.
1897
+ -- Named here because it is the same shape this column exists to prevent, one level up: a
1898
+ -- provable sent row and an unprovable one share a label, so a reader keying on attribution
1899
+ -- alone cannot tell them apart. An unprovable sent row is legitimate — an UNWITNESSED send
1900
+ -- never put a Structure 1 on the wire, so there is nothing signed to store — but the reader
1901
+ -- has to be told where the distinction lives, or it will be rediscovered as a bug.
1902
+ -- self_authored + sender_sig NOT NULL -> we wrote it and can prove we did
1903
+ -- self_authored + sender_sig NULL -> we wrote it; the relay never witnessed it
1428
1904
  --
1429
1905
  -- attribution is NOT NULL ON PURPOSE, and it is the load-bearing column. There is a soft
1430
1906
  -- path — session.content.ordering.decode_failed falls back to hash-dedup — that ingests a
@@ -1663,6 +2139,59 @@ export class SessionNodeManager {
1663
2139
  }
1664
2140
  return this.#db;
1665
2141
  }
2142
+ /**
2143
+ * DOD-M15-RELAYONLY-1: is the settings store readable RIGHT NOW?
2144
+ *
2145
+ * ⚠️ Exists because `getSetting` cannot answer it. That method returns `null` for BOTH "the key is
2146
+ * unset" and "there is no database", and a security setting must tell those apart: unset-means-off
2147
+ * is correct, db-gone-means-off publishes the operator's real address during the shutdown window.
2148
+ * `getDb()` cannot stand in either — it THROWS when there is no database, which on a catch-less
2149
+ * ceremony path is worse than the wrong answer.
2150
+ */
2151
+ hasDatabase() {
2152
+ return this.#db !== null;
2153
+ }
2154
+ /**
2155
+ * DOD-M15-RELAYONLY-1: build a transport node for THIS AGENT, with its privacy posture applied.
2156
+ *
2157
+ * ⚠️ THE CHOKE POINT FOR NODE CREATION, and it exists for the same reason as the one around
2158
+ * `getStandingReceiverInfo`. Five call sites construct nodes; passing `relayOnly` at each would be
2159
+ * a hand-kept list, and the SIXTH — added next month by someone who has never read this line —
2160
+ * would build a node that hole-punches its way to a direct connection for an operator who asked
2161
+ * never to be directly reachable. Here, a new caller inherits the posture instead of being told.
2162
+ *
2163
+ * `unknown` counts as ON, matching the publish and dial halves: a node that declines to hole-punch
2164
+ * is reachable over the relay, while a disclosed address cannot be recalled.
2165
+ */
2166
+ // ⚠️ NOT `async`. This wrapper sits in the standing-receiver startup path, and making it async
2167
+ // added ONE extra microtask hop before the receiver was installed in `#standingReceivers` — which
2168
+ // was enough for `createSessionNode` to run first and answer `standing_receiver_unavailable`. Two
2169
+ // tests in `msg-021-session-seed` caught it. Returning the factory's promise directly keeps the
2170
+ // await count identical to the call it replaced. **This is a real fragility in the install path,
2171
+ // not a quirk of the tests:** anything that adds a tick here re-breaks it.
2172
+ #createAgentNode(agentName, config) {
2173
+ // ⚠️ THE POSTURE READ MUST NEVER COST US A NODE. This sits in the standing-receiver startup
2174
+ // path, whose caller treats a throw as "no receiver" and leaves the agent deaf to all inbound —
2175
+ // surfacing to the operator as `standing_receiver_unavailable`, which names the transport for a
2176
+ // fault in a settings lookup. `relayOnlyState` already absorbs a throwing GETTER; this absorbs
2177
+ // everything else, including a resolution failure for an agent row that is not there yet.
2178
+ //
2179
+ // The fallback is ON, not off: an agent whose posture we cannot read gets the private-but-
2180
+ // reachable node, because a node that declines to hole-punch still works over the relay while a
2181
+ // disclosed address cannot be recalled.
2182
+ let relayOnly = true;
2183
+ try {
2184
+ relayOnly = relayOnlyState((key) => this.getSetting(agentName, key), this.#db !== null) !== "off";
2185
+ }
2186
+ catch (err) {
2187
+ this.#logger.warn("settings.relay_only.unreadable", {
2188
+ agentName,
2189
+ reason: err instanceof Error ? err.message : String(err),
2190
+ impact: "could not read this agent's relay-only posture, so the node is built WITHOUT the hole-punch",
2191
+ });
2192
+ }
2193
+ return this.#factory.createNode({ ...config, relayOnly });
2194
+ }
1666
2195
  /**
1667
2196
  * RELAYSIG-1: the durably-stored, signature-verified relay ordering-record receipts for an agent
1668
2197
  * (optionally a single session). Empty when no receipts have been recorded yet. Read-only.
@@ -1823,7 +2352,21 @@ export class SessionNodeManager {
1823
2352
  *
1824
2353
  * No plumbing needed: `direction` already carries the answer at write time.
1825
2354
  */
1826
- authorship ? "verified_signature" : direction === "sent" ? "self_authored" : "local_session_state");
2355
+ /**
2356
+ * DIRECTION FIRST — DOD-M15-SEALWIRE-1 bullet 5, sent half.
2357
+ *
2358
+ * This used to read `authorship ? "verified_signature" : …`, which was right while only
2359
+ * RECEIVED rows could carry a signature. Now a SENT row carries one too — our own, over
2360
+ * the Structure-1 bytes we put on the wire — and labelling that `verified_signature` would
2361
+ * be false in the way this column exists to prevent: **we did not verify it, we produced
2362
+ * it.** Nobody checked a counterparty's key; there was no counterparty in the act.
2363
+ *
2364
+ * So the three values keep meaning three different things:
2365
+ * `self_authored` — this agent wrote it. Now PROVABLE when a signature is stored.
2366
+ * `verified_signature` — someone else wrote it and we checked their key against it.
2367
+ * `local_session_state`— someone else wrote it and nobody checked anything.
2368
+ */
2369
+ direction === "sent" ? "self_authored" : authorship ? "verified_signature" : "local_session_state");
1827
2370
  this.#logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
1828
2371
  return true;
1829
2372
  }
@@ -2657,7 +3200,42 @@ export class SessionNodeManager {
2657
3200
  const sr = this.#standingReceivers.get(agentName);
2658
3201
  if (!sr)
2659
3202
  return null;
2660
- return { peerId: sr.node.getPeerId(), addrs: sr.node.listenAddresses() };
3203
+ // DOD-M15-RELAYONLY-1: THE CHOKE POINT. Every path that publishes this agent's session
3204
+ // addresses draws from here — `initiator_session_addrs` on the way out, and
3205
+ // `counterparty_session_addrs` when answering an offer — and this method has no other kind of
3206
+ // consumer: its whole purpose is to be advertised, as the docstring above says.
3207
+ //
3208
+ // The suppression lives HERE rather than at those call sites deliberately. Call-site gating
3209
+ // would be a hand-kept list, and a fourth publish path added later would leak the operator's IP
3210
+ // while every test stayed green. At the choke point a new caller inherits the protection
3211
+ // instead of having to be told about it.
3212
+ const endpoint = { peerId: sr.node.getPeerId(), addrs: sr.node.listenAddresses() };
3213
+ // ⚠️ TRI-STATE, not a boolean, and the third state is the one that matters. `getSetting` answers
3214
+ // `null` both for "unset" and for "there is no database", and reading the second as OFF fails
3215
+ // TOWARD DISCLOSURE: the standing receiver outlives the DB during shutdown, so an offer arriving
3216
+ // in that window would publish the operator's real addresses with relay-only switched on.
3217
+ // `relayOnlyState` also absorbs a THROW — `#requireAgentId` throws for a retired agent, and this
3218
+ // method is called from the offer ceremony inside a floating async with no catch, where the
3219
+ // throw becomes an unhandled rejection and the offer vanishes with no local log.
3220
+ // ⚠️ `!== null`, NOT `!== undefined`. The field is declared `DaemonDatabase | null` and is only
3221
+ // ever assigned on open or set to `null` on close — **it is never `undefined` at any point in
3222
+ // its lifetime**, so the first version of this line was a compile-time-constant `true` that
3223
+ // TypeScript had no reason to complain about, and the whole `"unknown"` branch was unreachable
3224
+ // dead code. The fix for the disclosure window silently did nothing, which is worse than not
3225
+ // having written it: the DoD said the window was closed and it was wide open.
3226
+ const state = relayOnlyState((key) => this.getSetting(agentName, key), this.#db !== null);
3227
+ if (state === "unknown") {
3228
+ this.#logger.warn("settings.relay_only.unreadable", {
3229
+ agentName,
3230
+ impact: "cannot tell whether relay-only is on, so ONLY this agent's relay-circuit addresses are " +
3231
+ "published — never a direct one. Publishing a real address is irreversible and a narrowed " +
3232
+ "route is not, so this errs toward reachability loss rather than disclosure",
3233
+ });
3234
+ }
3235
+ // ONE filter, not two. The `unknown` branch used to build its own filtered object inline, which
3236
+ // put a second implementation inside the very method whose design rationale is that there is
3237
+ // exactly one — and the bypass guard could not see it.
3238
+ return publishableEndpoint(endpoint, state !== "off");
2661
3239
  }
2662
3240
  /**
2663
3241
  * DOD-M15-ASSIGN-1 — name the one peer allowed to dial this agent's standing receiver, at the
@@ -2963,7 +3541,7 @@ export class SessionNodeManager {
2963
3541
  });
2964
3542
  try {
2965
3543
  seed = randomBytes(32);
2966
- node = await this.#factory.createNode({ sessionId, connectionGater: gater, nodeType: "session", transportPrivateKey: seed });
3544
+ node = await this.#createAgentNode(agentName, { sessionId, connectionGater: gater, nodeType: "session", transportPrivateKey: seed });
2967
3545
  await node.start();
2968
3546
  }
2969
3547
  catch (err) {
@@ -3040,6 +3618,9 @@ export class SessionNodeManager {
3040
3618
  correlationId,
3041
3619
  });
3042
3620
  // Add to active map (keyed by (agentName, sessionId) — DOD-LOOP-1)
3621
+ // 006-CRYPTO: the session's throwaway keypair is minted here, with the node, so "a session is
3622
+ // active" and "a session has a key" are the same moment. All THREE activation paths mint.
3623
+ this.#mintSessionEphemeral(agentName, sessionId);
3043
3624
  this.#activeNodes.set(this.#k(agentName, sessionId), {
3044
3625
  node,
3045
3626
  agentName,
@@ -3157,6 +3738,8 @@ export class SessionNodeManager {
3157
3738
  logger: this.#logger,
3158
3739
  receiptStore: this.#relayReceiptStore ?? undefined,
3159
3740
  sealLeafStore: this.#sealLeafStore ?? undefined,
3741
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
3742
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
3160
3743
  });
3161
3744
  this.#relayClients.set(clientKey, client);
3162
3745
  }
@@ -3170,6 +3753,8 @@ export class SessionNodeManager {
3170
3753
  // 2b: remember the relay endpoint so the content-park backstop deposits to the SAME relay.
3171
3754
  entry.relayPeerId = relay.relayPeerId;
3172
3755
  entry.relayAddrs = relay.relayAddrs;
3756
+ // Review H1: the dial path needs the credential in hand, not just the endpoint.
3757
+ entry.relayAssignment = relay.assignment;
3173
3758
  // MSG-2 startup-flush: also PERSIST it, so a restart's crash-backstop flush (which runs
3174
3759
  // before the in-memory entry exists) can deposit un-acked content to the same relay.
3175
3760
  try {
@@ -3196,6 +3781,24 @@ export class SessionNodeManager {
3196
3781
  // Proactively connect so the relay has this agent's stream to deliver leaves to
3197
3782
  // (the RECEIVER must be connected before the counterparty submits). Best-effort.
3198
3783
  await client.connect(node);
3784
+ // DOD-M15-RELAYAUTH-1 review HIGH-2 — see below. Best-effort and non-blocking: a session must
3785
+ // never fail to come up because a SECOND relay could not be told about it.
3786
+ //
3787
+ // Review M1: `.catch()` is not decoration. This is an unawaited promise, so a throw it does not
3788
+ // handle is an unhandled rejection — and this file already carries a comment elsewhere about an
3789
+ // absent `await` that became a remote process kill. The method's own try/catch does not cover
3790
+ // its prologue, so the catch here is the only thing standing between a torn-down node and the
3791
+ // daemon dying.
3792
+ void this.#presentAssignmentToReservationRelay(agentName, node, relay, sessionIdHexForRelay, correlationId, entry)
3793
+ .catch((err) => {
3794
+ this.#logger.warn("session.relay.assignment.reservation_relay_failed", {
3795
+ agentName,
3796
+ sessionId: sessionIdHexForRelay.slice(0, 16),
3797
+ error: extractErrorMessage(err),
3798
+ 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",
3799
+ correlationId,
3800
+ });
3801
+ });
3199
3802
  }
3200
3803
  catch (err) {
3201
3804
  this.#logger.warn("session.relay.connect.error", {
@@ -3205,6 +3808,89 @@ export class SessionNodeManager {
3205
3808
  });
3206
3809
  }
3207
3810
  }
3811
+ /**
3812
+ * DOD-M15-RELAYAUTH-1 review HIGH-2 — **THE RELAY THAT GATES THE DIAL IS NOT ALWAYS THE RELAY
3813
+ * THAT HOLDS THE ASSIGNMENT, AND THE GATE DENIES WHEN THEY DIFFER.**
3814
+ *
3815
+ * Two relays are in play for one session, chosen by unrelated rules:
3816
+ * - the WITNESS relay, `assignment.relay_endpoint`, picked by the directory. Both parties
3817
+ * present `client_record_assignment` to it and to nowhere else.
3818
+ * - the RESERVATION relay, whichever one this node's circuit address is held on — the first
3819
+ * candidate that granted when it was a standing receiver.
3820
+ *
3821
+ * The counterparty dials our CIRCUIT address, so it is the RESERVATION relay whose gater is asked
3822
+ * `denyOutboundRelayedConnection(them, us)`. With no assignment recorded there it finds no binding
3823
+ * and refuses a completely legitimate dial. Two relays run in production and the client's own
3824
+ * logs show the fall-through to the second candidate is frequent, so this is the ordinary case,
3825
+ * not a corner: the session still opens, but every message falls to the store-and-forward park
3826
+ * path, and the only trace is a denial on a relay nobody is tailing.
3827
+ *
3828
+ * So the node that will be DIALLED presents the same assignment to the relay that will be asked
3829
+ * to allow it. Safe by construction: the assignment is self-authenticating (a per-node directory
3830
+ * signature the relay verifies against its consortium set), so presenting it more widely grants
3831
+ * nothing that forging it would not already require. No directory change, no new frame.
3832
+ */
3833
+ async #presentAssignmentToReservationRelay(agentName, node, relay, sessionIdHex, correlationId, entry) {
3834
+ // Review M1: the prologue below lives INSIDE the try. `listenAddresses()` on a node torn down
3835
+ // while we were wiring is exactly the case handled 30 lines above at the caller, and out here it
3836
+ // would have escaped both this method's catch and (before the caller's `.catch`) the process.
3837
+ let reservationRelayPeerId;
3838
+ try {
3839
+ if (!relay.assignment)
3840
+ return; // direct/legacy/persisted-reconnect: nothing to present anywhere
3841
+ const heldCircuitAddr = node.listenAddresses().find((a) => a.includes("/p2p-circuit"));
3842
+ if (!heldCircuitAddr)
3843
+ return; // no reservation held → nobody will gate a dial to us
3844
+ reservationRelayPeerId = /\/p2p\/([^/]+)\/p2p-circuit/.exec(heldCircuitAddr)?.[1];
3845
+ if (!reservationRelayPeerId || reservationRelayPeerId === relay.relayPeerId)
3846
+ return; // same relay — already recorded
3847
+ const clientKey = `${agentName}::${reservationRelayPeerId}`;
3848
+ let client = this.#relayClients.get(clientKey);
3849
+ if (!client) {
3850
+ if (!this.#relayReceiptStore && this.#db)
3851
+ this.#relayReceiptStore = new RelayReceiptStore(this.#db, this.#logger);
3852
+ if (!this.#sealLeafStore && this.#db)
3853
+ this.#sealLeafStore = new SessionSealLeafStore(this.#db, this.#logger);
3854
+ // Split on the marker, not an anchored strip: the held address is
3855
+ // `…/p2p/<relay>/p2p-circuit/p2p/<self>`, so the marker is in the MIDDLE.
3856
+ const baseRelayAddr = heldCircuitAddr.split("/p2p-circuit")[0] ?? heldCircuitAddr;
3857
+ client = this.#detachedRelayClientBuilder?.(agentName, reservationRelayPeerId, [baseRelayAddr], {
3858
+ receiptStore: this.#relayReceiptStore ?? undefined,
3859
+ sealLeafStore: this.#sealLeafStore ?? undefined,
3860
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
3861
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
3862
+ });
3863
+ if (!client)
3864
+ return;
3865
+ this.#relayClients.set(clientKey, client);
3866
+ // Review M4: record the key so teardown releases this client too — `relayClientKey` names
3867
+ // only the witness relay, so before this the client and its session registration leaked.
3868
+ if (entry)
3869
+ entry.extraRelayClientKeys = [...(entry.extraRelayClientKeys ?? []), clientKey];
3870
+ }
3871
+ // registerSession presents the assignment eagerly (see its own comment). No leaf handler: this
3872
+ // relay is not witnessing the session, it only needs the binding that authorizes the dial.
3873
+ client.registerSession(sessionIdHex, node, undefined, relay.assignment);
3874
+ this.#logger.info("session.relay.assignment.presented_to_reservation_relay", {
3875
+ agentName,
3876
+ sessionId: sessionIdHex.slice(0, 16),
3877
+ witnessRelayPeerId: relay.relayPeerId,
3878
+ reservationRelayPeerId,
3879
+ impact: "the relay that will be asked to allow inbound circuit dials to this node now holds the assignment authorizing them",
3880
+ correlationId,
3881
+ });
3882
+ }
3883
+ catch (err) {
3884
+ this.#logger.warn("session.relay.assignment.reservation_relay_failed", {
3885
+ agentName,
3886
+ sessionId: sessionIdHex.slice(0, 16),
3887
+ reservationRelayPeerId,
3888
+ error: extractErrorMessage(err),
3889
+ 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",
3890
+ correlationId,
3891
+ });
3892
+ }
3893
+ }
3208
3894
  /**
3209
3895
  * M7 DOD-SPINE-6 / MSG-001-3b: detach a session from its (agent, relay) client and
3210
3896
  * close the client when it has no remaining sessions. Idempotent and identity-guarded:
@@ -3214,11 +3900,32 @@ export class SessionNodeManager {
3214
3900
  #detachSessionRelay(entry) {
3215
3901
  const client = entry.relayClient;
3216
3902
  const key = entry.relayClientKey;
3217
- if (!client || !entry.relaySessionIdBytes)
3903
+ if (!entry.relaySessionIdBytes)
3904
+ return;
3905
+ const sidHex = Buffer.from(entry.relaySessionIdBytes).toString("hex");
3906
+ /**
3907
+ * Review M4: release the EXTRA relay clients first — the ones opened to relays that gate circuit
3908
+ * dials. `relayClientKey` above names only the witness relay, so these were registered and never
3909
+ * unregistered: an authenticated relay stream and a `#sessions` entry leaked per session, and
3910
+ * relay-side the dial-through binding they hold is then cleared only by the idle timer, which is
3911
+ * now 24h. Runs before the early return below so it happens even for a session that never got a
3912
+ * witness client.
3913
+ */
3914
+ for (const extraKey of entry.extraRelayClientKeys ?? []) {
3915
+ const extra = this.#relayClients.get(extraKey);
3916
+ if (!extra)
3917
+ continue;
3918
+ extra.unregisterSession(sidHex);
3919
+ if (!extra.hasSessions()) {
3920
+ extra.close();
3921
+ this.#relayClients.delete(extraKey);
3922
+ }
3923
+ }
3924
+ entry.extraRelayClientKeys = undefined;
3925
+ if (!client)
3218
3926
  return;
3219
3927
  // Idempotent: clear the entry's reference so a second teardown of the same entry no-ops.
3220
3928
  entry.relayClient = undefined;
3221
- const sidHex = Buffer.from(entry.relaySessionIdBytes).toString("hex");
3222
3929
  client.unregisterSession(sidHex);
3223
3930
  if (!client.hasSessions() && key && this.#relayClients.get(key) === client) {
3224
3931
  client.close();
@@ -3257,9 +3964,12 @@ export class SessionNodeManager {
3257
3964
  const entry = this.#activeNodes.get(key);
3258
3965
  return entry?.relayPeerId !== peerId;
3259
3966
  };
3260
- node.onPeerConnect((peerId) => {
3261
- if (!isCounterparty(peerId))
3262
- return;
3967
+ /**
3968
+ * Named rather than inline so the already-attached sweep below can invoke **this exact function**
3969
+ * instead of a second copy of it. A copy is what would drift: the two would have to be kept in
3970
+ * step by whoever edits either, and the failure would be silent.
3971
+ */
3972
+ const onCounterpartyAttached = (peerId) => {
3263
3973
  /**
3264
3974
  * DOD-M12B-RESPONDER-ADDR-1 (review MEDIUM-4) — LEARN THE ADDRESS HERE, where it cannot race.
3265
3975
  *
@@ -3307,7 +4017,63 @@ export class SessionNodeManager {
3307
4017
  * single lost frame does not strand the agreement.
3308
4018
  */
3309
4019
  void this.#sendSaltFrame(agentName, sessionId, correlationId);
3310
- });
4020
+ // 007-CRYPTO: the signed ephemeral rides the SAME moment as the salt half — one connect, one
4021
+ // round trip, the same peer-to-peer content stream. Fire-and-forget for the same reason: a
4022
+ // failed announcement must not reject a peer-connect handler, and the next connect re-announces.
4023
+ void this.#sendEphemeralFrame(agentName, sessionId, correlationId);
4024
+ };
4025
+ node.onPeerConnect(onCounterpartyAttached);
4026
+ /**
4027
+ * ⚠️ THE CONNECT THAT ALREADY HAPPENED — `DOD-M15-SALTANNOUNCE-LATE-1`.
4028
+ *
4029
+ * `onPeerConnect` above is `addEventListener("peer:connect", …)` (`core/transport/src/node.ts`),
4030
+ * and **an event listener cannot fire for a connection that predates it.** On the
4031
+ * `reuseStandingReceiver` path this session does not build a node — it TAKES the standing
4032
+ * receiver's, which has been listening all along. So the ordinary sequence is:
4033
+ *
4034
+ * 1. `#tryCreateStandingReceiver` starts the node listening. It never calls this method, so
4035
+ * there is no handler yet.
4036
+ * 2. The counterparty connects. `peer:connect` fires into nothing.
4037
+ * 3. The session promotes that same node and registers the handler — one step too late.
4038
+ *
4039
+ * The handler then never runs, and everything hanging off it is silently skipped: **the salt is
4040
+ * never announced** (`no_agreement_started`, the sender salts, the receiver holds none, and every
4041
+ * message between them is refused) **and the counterparty's address is never learned or
4042
+ * refreshed.** Measured live: `j-documents` 7 of 12 red, every failure a document update that
4043
+ * never arrived, with no error shown to either operator.
4044
+ *
4045
+ * ⚠️ THE COMMENT ON THE HANDLER ABOVE NAMES THE OPPOSITE HAZARD, AND IT IS ALSO RIGHT: *"a send
4046
+ * placed at `createSessionNode` would be an announcement to a peer that is not attached yet."*
4047
+ * Both are real, which is why this is a SWEEP AFTER REGISTERING rather than a move. Too-early
4048
+ * stays impossible — the sweep runs at the same point the handler is armed — and too-late stops
4049
+ * being invisible, because an already-open connection is now looked at instead of waited for.
4050
+ *
4051
+ * Idempotent by construction: it invokes the SAME handler the event would have, so a peer that
4052
+ * connects normally is unaffected, and a peer seen twice re-announces — which the announce path
4053
+ * already tolerates (*"we re-announce on every reconnect"*).
4054
+ */
4055
+ try {
4056
+ // `getConnections()` returns CONNECTIONS, and one peer can hold several — dedupe, or a peer
4057
+ // with two open connections would run the attach path twice for no reason.
4058
+ const attachedPeers = new Set(node.getConnections().map((c) => c.peerId));
4059
+ for (const peerId of attachedPeers) {
4060
+ if (!isCounterparty(peerId))
4061
+ continue;
4062
+ this.#logger.info("session.liveness.peer_already_attached", {
4063
+ agentName, sessionId, peerId, correlationId,
4064
+ 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.",
4065
+ });
4066
+ onCounterpartyAttached(peerId);
4067
+ }
4068
+ }
4069
+ catch (err) {
4070
+ // Never let the sweep cost the caller its session: the handler is already armed, so a failure
4071
+ // here degrades to exactly the behaviour that shipped before this fix.
4072
+ this.#logger.warn("session.liveness.attached_sweep.failed", {
4073
+ agentName, sessionId, correlationId, error: extractErrorMessage(err),
4074
+ 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.",
4075
+ });
4076
+ }
3311
4077
  node.onPeerDisconnect((peerId) => {
3312
4078
  if (!isCounterparty(peerId)) {
3313
4079
  // Not silence: a relay link dropping is a real event, it is simply not a
@@ -3606,6 +4372,9 @@ export class SessionNodeManager {
3606
4372
  // Remove this agent's standing receiver from the slot and add to active map. The handed-off
3607
4373
  // node keeps its AutoNAT service (it continues to surface dialability).
3608
4374
  this.#standingReceivers.delete(agentName);
4375
+ // 006-CRYPTO: the hand-off path. A session promoted out of the standing receiver is as new as
4376
+ // one opened outbound, so it mints here too.
4377
+ this.#mintSessionEphemeral(agentName, sessionId);
3609
4378
  this.#activeNodes.set(this.#k(agentName, sessionId), {
3610
4379
  node,
3611
4380
  agentName,
@@ -3845,6 +4614,13 @@ export class SessionNodeManager {
3845
4614
  this.#saltContributions.delete(key);
3846
4615
  this.#sessionSalts.delete(key);
3847
4616
  this.#saltRepairedAgainst.delete(key);
4617
+ this.#saltRepairedAgainstFingerprint.delete(key);
4618
+ this.#saltPeerClosedLabel.delete(key);
4619
+ // The throwaway secret is destroyed where the `#activeNodes` entry is DROPPED, not here — see
4620
+ // `#destroySessionEphemeralFor`. This call is the belt to that braces: both teardown paths that
4621
+ // evict have already dropped the entry, so it is a no-op for them, and it is what catches any
4622
+ // future path that evicts without going through one of those.
4623
+ this.#destroySessionEphemeralFor(agentName, sessionId);
3848
4624
  /**
3849
4625
  * B2b-2's three, and the pending one is SETTLED rather than dropped.
3850
4626
  *
@@ -3861,6 +4637,10 @@ export class SessionNodeManager {
3861
4637
  */
3862
4638
  this.#settleSaltPending(agentName, sessionId, "closed");
3863
4639
  this.#hashedWithoutSalt.delete(key);
4640
+ // DOD-M15-SALTSPLIT-1 HIGH-2: goes with its mirror. The session is being torn down, so there is
4641
+ // no discard decision left for it to protect.
4642
+ this.#hashedWithSalt.delete(key);
4643
+ this.#saltSuspended.delete(key);
3864
4644
  this.#unsaltedAnnounced.delete(key);
3865
4645
  this.#saltLastOutcome.delete(key);
3866
4646
  // HELD CONTENT IS LOST HERE, AND IT MUST SAY SO.
@@ -4017,6 +4797,35 @@ export class SessionNodeManager {
4017
4797
  for (const timer of this.#lingeringStreams)
4018
4798
  clearTimeout(timer);
4019
4799
  this.#lingeringStreams.clear();
4800
+ /**
4801
+ * DOD-M15-RELAYLEAK-1 — **CLOSE THE RELAY CLIENTS. Shutdown never did.**
4802
+ *
4803
+ * This method stops every session NODE and left `#relayClients` untouched — verified by reading:
4804
+ * the whole of `gracefulShutdown` referenced `relayClient` zero times. Each cached client holds
4805
+ * an authenticated libp2p stream to a relay and a reader loop, so a `cello logout` left them
4806
+ * open until the process itself exited.
4807
+ *
4808
+ * That is a real cost rather than untidiness: the relay counts a reservation per client and its
4809
+ * slots are finite, so a daemon that restarts repeatedly consumes them faster than they are
4810
+ * released, which is the "agents cannot get a reservation" failure the relay's own limits note
4811
+ * describes from the other side.
4812
+ *
4813
+ * Best-effort and individually caught: teardown must not be the thing that throws. One client
4814
+ * that refuses to close must not prevent the next from being released.
4815
+ */
4816
+ for (const [key, client] of this.#relayClients) {
4817
+ try {
4818
+ client.close();
4819
+ }
4820
+ catch (err) {
4821
+ this.#logger.warn("session.relay_client.close_failed", {
4822
+ relayClientKey: key,
4823
+ reason: err instanceof Error ? err.message : String(err),
4824
+ impact: "one cached relay client did not close cleanly on shutdown; the rest are still released",
4825
+ });
4826
+ }
4827
+ }
4828
+ this.#relayClients.clear();
4020
4829
  // Cancel every armed awaiting-ACK timer so an un-acked send (e.g. a rejected /
4021
4830
  // tampered frame that never produced a `persisted` ACK) does not leave a 20s
4022
4831
  // timer pinning the content + this manager in memory past teardown (review M1).
@@ -4124,6 +4933,14 @@ export class SessionNodeManager {
4124
4933
  for (const identity of this.#sessionSeeds.values())
4125
4934
  identity.seed.fill(0);
4126
4935
  this.#sessionSeeds.clear();
4936
+ // 006-CRYPTO: the per-session throwaway secrets belong in the same sentence, for the same
4937
+ // reason and with the same measured cause — shutdown marks rows `interrupted` by direct SQL, so
4938
+ // no per-session teardown fires for them, and this process is known to linger (a `cello logout`
4939
+ // was still alive 30+ seconds later). Without this, every live session's key survives the
4940
+ // shutdown in memory for as long as it lingers.
4941
+ for (const ephemeral of this.#sessionEphemerals.values())
4942
+ destroySessionEphemeral(ephemeral);
4943
+ this.#sessionEphemerals.clear();
4127
4944
  // Stop ALL per-agent standing receivers (DOD-LOOP-1). In PARALLEL and BOUNDED: this was a
4128
4945
  // sequential await per agent with no deadline, so five agents meant five chances for one stuck
4129
4946
  // libp2p teardown to hold the exit — and it sits between the operator being told the daemon is
@@ -4520,11 +5337,39 @@ export class SessionNodeManager {
4520
5337
  * BLOB to a listing surface that has no use for it; the boolean is the whole question a reader of
4521
5338
  * this list is asking, and shipping key material to answer a yes/no is not a trade worth making.
4522
5339
  */
4523
- #saltStatusOf(row) {
5340
+ /**
5341
+ * ⚠️ THE STORED COLUMN IS NOT THE ANSWER ON ITS OWN — 006-CRYPTO finding 3.
5342
+ *
5343
+ * A SUSPENDED salt keeps its bytes on disk deliberately (`DOD-M15-SALTSPLIT-1`: a salt kept is
5344
+ * recoverable, a salt erased is not), while `#saltForHashing` returns null for it and every
5345
+ * message goes out `sha256`. Reading the column alone therefore reported `true` at the exact
5346
+ * moment the session had STOPPED salting — and because the field is emitted only when `false`,
5347
+ * the agent saw nothing at all, which reads as "not unsalted".
5348
+ *
5349
+ * That is precisely the case this field was added for. Its own note above says it exists to tell
5350
+ * *"unsalted because this build predates the feature"* from *"unsalted because adoption was
5351
+ * refused"*, and the refused case was the one it could not report.
5352
+ */
5353
+ #saltStatusOf(row, agentName) {
4524
5354
  const { content_salt, ...rest } = row;
5355
+ const stored = content_salt != null && content_salt.length > 0;
5356
+ const suspended = agentName !== null && this.#saltSuspended.has(this.#k(agentName, String(row.session_id)));
5357
+ /**
5358
+ * 007-CRYPTO: the REAL state, not a constant. In 006 this was hardcoded `false` with a single
5359
+ * reason, because nothing exchanged keys and saying so was the honest answer. It exchanges now,
5360
+ * so a hardcode would be the stale claim that unit existed to remove.
5361
+ *
5362
+ * A row with no live agent name cannot be looked up in memory — an orphaned session, whose key
5363
+ * (if it ever had one) died with the process. Reported as not-agreed rather than guessed at.
5364
+ */
5365
+ const enc = agentName === null
5366
+ ? { key: null, reason: CONTENT_ENCRYPTION_REASONS.NOT_YET_AGREED }
5367
+ : this.#contentEncryptionState(agentName, String(row.session_id));
4525
5368
  return {
4526
5369
  ...rest,
4527
- content_hashes_salted: content_salt != null && content_salt.length > 0,
5370
+ content_hashes_salted: stored && !suspended,
5371
+ content_encrypted: enc.key !== null,
5372
+ ...(enc.key === null ? { content_encryption_reason: enc.reason } : {}),
4528
5373
  };
4529
5374
  }
4530
5375
  getSessionsForAgent(agentName) {
@@ -4536,7 +5381,7 @@ export class SessionNodeManager {
4536
5381
  const rows = this.#db
4537
5382
  .prepare("SELECT * FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC")
4538
5383
  .all(this.#requireAgentId(agentName));
4539
- return rows.map((r) => ({ ...this.#saltStatusOf(r), agent_name: agentName }));
5384
+ return rows.map((r) => ({ ...this.#saltStatusOf(r, agentName), agent_name: agentName }));
4540
5385
  }
4541
5386
  /**
4542
5387
  * Every persisted session across ALL agents, most-recently-updated first. Backs the daemon-wide
@@ -4554,7 +5399,12 @@ export class SessionNodeManager {
4554
5399
  FROM sessions s LEFT JOIN agents a ON a.agent_id = s.agent_id
4555
5400
  ORDER BY s.updated_at DESC`)
4556
5401
  .all()
4557
- .map((r) => this.#saltStatusOf(r));
5402
+ // The joined display name is what `#saltSuspended` is keyed on. NULL only where the agent row
5403
+ // is missing — an orphaned session, which has no live in-memory state to be suspended in.
5404
+ .map((r) => {
5405
+ const row = r;
5406
+ return this.#saltStatusOf(row, row.agent_name ?? null);
5407
+ });
4558
5408
  }
4559
5409
  /**
4560
5410
  * M7-SESSION-004 (AC-005): persist the seal certificate's legibility object with the
@@ -4694,9 +5544,17 @@ export class SessionNodeManager {
4694
5544
  * @param messageCount Number of message leaves at interruption
4695
5545
  * @param source 'relay_frame' | 'stream_close'
4696
5546
  */
4697
- async markInterruptedWithDetails(agentName, sessionId, messageCount, source) {
4698
- if (!this.#db)
4699
- return;
5547
+ async markInterruptedWithDetails(agentName, sessionId, messageCount,
5548
+ /**
5549
+ * WHAT ACTUALLY HAPPENED, and it is written to the row — review F3.
5550
+ *
5551
+ * `key_refused` is its own source rather than a borrowed `stream_close`, because the row's
5552
+ * `interrupted_by` is what an operator reads days later: labelling a key-authentication refusal
5553
+ * `relay_stream_close` sends them to the relay fleet for a fault in the payload.
5554
+ */
5555
+ source) {
5556
+ if (!this.#db)
5557
+ return false;
4700
5558
  // H-3 SECURITY: only an 'active' session may transition to 'interrupted'.
4701
5559
  // A late or forged relay frame must NOT revert a 'sealed', 'seal_interrupted_pending',
4702
5560
  // or already-'interrupted' session back to 'interrupted'. This mirrors the
@@ -4709,7 +5567,8 @@ export class SessionNodeManager {
4709
5567
  currentStatus: existing?.status ?? "absent",
4710
5568
  reason: "session_not_active",
4711
5569
  });
4712
- return;
5570
+ // FALSE, not void — the caller needs to know nothing was torn down (review F11).
5571
+ return false;
4713
5572
  }
4714
5573
  const now = Date.now();
4715
5574
  const interruptedAt = new Date(now).toISOString();
@@ -4739,7 +5598,7 @@ export class SessionNodeManager {
4739
5598
  // `relay_stream_close` is its own label and STILL COUNTS (the bound excuses only 'local'),
4740
5599
  // because an attacker who can disturb our relay link must not get a free cap reset. It is
4741
5600
  // 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'`)
5601
+ `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
5602
  .run(now, authoritativeCount, interruptedAt, this.#requireAgentId(agentName), sessionId);
4744
5603
  }
4745
5604
  catch (err) {
@@ -4767,6 +5626,21 @@ export class SessionNodeManager {
4767
5626
  // Fall through — still remove from active map
4768
5627
  }
4769
5628
  this.#activeNodes.delete(this.#k(agentName, sessionId));
5629
+ /**
5630
+ * THE SECRET GOES WITH THE ENTRY — 006-CRYPTO, review pass 2 finding 2.
5631
+ *
5632
+ * This is the path an interrupted session actually takes, and it is the ORDINARY way a
5633
+ * session ends badly: a relay blip, a closed stream, a sleeping laptop. Because it does not
5634
+ * evict (see below) the secret used to survive here, and when the session later sealed
5635
+ * `destroySessionNode` returned at its `if (!entry) return` without evicting either — so the
5636
+ * receipt landed, the session was over, and the key stayed resident until the process exited.
5637
+ *
5638
+ * The reasons below for KEEPING the other caches do not transfer to key material: buffered
5639
+ * plaintext must stay drainable and TTF timers must stay armed, whereas a secret nothing
5640
+ * reads must not stay alive. A revived session mints a fresh one and re-keys, which is
5641
+ * Decisions Carried #5 and is only true because of this line.
5642
+ */
5643
+ this.#destroySessionEphemeralFor(agentName, sessionId, entry.correlationId);
4770
5644
  this.#logger.info("session.node.destroyed", {
4771
5645
  sessionId,
4772
5646
  agentName,
@@ -4801,6 +5675,7 @@ export class SessionNodeManager {
4801
5675
  error: err instanceof Error ? err.message : String(err),
4802
5676
  });
4803
5677
  }
5678
+ return true;
4804
5679
  }
4805
5680
  /**
4806
5681
  * M7-SESSION-001 (H-1): persist a verified bilateral SEAL-INTERRUPTED
@@ -5125,6 +6000,10 @@ export class SessionNodeManager {
5125
6000
  if (viaRelay)
5126
6001
  entry.gater.setAllowedOutboundPeer(viaRelay[1]);
5127
6002
  }
6003
+ // DOD-M15-RELAYAUTH-1 review H1: the RELAY's gater must also admit this dial, and it only does
6004
+ // so once it holds the assignment. Await that here — see the method's own comment for why the
6005
+ // counterparty presenting it cannot be relied on.
6006
+ await this.#authorizeCircuitDialsToCounterparty(agentName, sessionId, entry, addrs);
5128
6007
  let lastError = "";
5129
6008
  for (const addr of addrs) {
5130
6009
  try {
@@ -5155,6 +6034,112 @@ export class SessionNodeManager {
5155
6034
  });
5156
6035
  return { ok: false, reason: "counterparty_dial_failed", error: lastError };
5157
6036
  }
6037
+ /**
6038
+ * DOD-M15-RELAYAUTH-1 review H1 — **THE GATE WAS DENYING THE LEGITIMATE DIAL, AND USUALLY.**
6039
+ *
6040
+ * With the gater installed, a relay refuses a circuit dial unless it already holds a
6041
+ * directory-signed assignment naming both transport peer ids. Both parties get that assignment
6042
+ * from the directory independently, and until this method existed, each only presented it to
6043
+ * relays IT had chosen — so whether a dial was allowed came down to which of two independent
6044
+ * network races finished first:
6045
+ *
6046
+ * 1. WE connect to the witness relay, then dial the counterparty's circuit address. ~2 RTT.
6047
+ * 2. THEY connect to their witness relay, then — unawaited, on a fresh dial + auth + record —
6048
+ * tell their RESERVATION relay about the session. ~3–4 RTT.
6049
+ *
6050
+ * Nothing sequenced (1) against (2), and (1) is shorter, so we usually arrived first and were
6051
+ * refused. The session still opened and reported `transportMode: "relay"`, so the failure was
6052
+ * invisible: every message for the life of that conversation quietly took the store-and-forward
6053
+ * park path, and the only trace was a denial logged on a third machine nobody tails.
6054
+ *
6055
+ * The fix is to stop racing. Whoever is about to dial presents the assignment to the relay that
6056
+ * will gate that dial, and WAITS for the relay to confirm it recorded it. The ordering becomes
6057
+ * local to one thread of execution, so there is nothing left to lose.
6058
+ *
6059
+ * Safe by construction: we are a participant the assignment names, so the relay's own participant
6060
+ * check passes; and the assignment is self-authenticating (a directory signature the relay
6061
+ * verifies against its consortium set), so presenting it more widely grants nothing that forging
6062
+ * it would not already require.
6063
+ *
6064
+ * Best-effort by design — a relay we cannot reach must not stop us from dialling. If the record
6065
+ * fails we dial anyway: a dial that might be refused is strictly better than no dial.
6066
+ */
6067
+ async #authorizeCircuitDialsToCounterparty(agentName, sessionId, entry, addrs) {
6068
+ const assignment = entry.relayAssignment;
6069
+ if (!assignment || !entry.relaySessionIdBytes)
6070
+ return; // direct/legacy/persisted: no credential to present
6071
+ const sessionIdHex = Buffer.from(entry.relaySessionIdBytes).toString("hex");
6072
+ // One presentation per distinct relay, not per address: a counterparty commonly advertises
6073
+ // several circuit addresses on the SAME relay.
6074
+ const seen = new Set();
6075
+ for (const addr of addrs) {
6076
+ const relayPeerId = /\/p2p\/([^/]+)\/p2p-circuit/.exec(addr)?.[1];
6077
+ if (!relayPeerId || seen.has(relayPeerId))
6078
+ continue;
6079
+ seen.add(relayPeerId);
6080
+ // The witness relay already has it — registerSession presented it when the session was wired.
6081
+ if (relayPeerId === entry.relayPeerId)
6082
+ continue;
6083
+ const baseRelayAddr = addr.split("/p2p-circuit")[0] ?? addr;
6084
+ try {
6085
+ const clientKey = `${agentName}::${relayPeerId}`;
6086
+ let client = this.#relayClients.get(clientKey);
6087
+ if (!client) {
6088
+ if (!this.#relayReceiptStore && this.#db)
6089
+ this.#relayReceiptStore = new RelayReceiptStore(this.#db, this.#logger);
6090
+ if (!this.#sealLeafStore && this.#db)
6091
+ this.#sealLeafStore = new SessionSealLeafStore(this.#db, this.#logger);
6092
+ client = this.#detachedRelayClientBuilder?.(agentName, relayPeerId, [baseRelayAddr], {
6093
+ receiptStore: this.#relayReceiptStore ?? undefined,
6094
+ sealLeafStore: this.#sealLeafStore ?? undefined,
6095
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
6096
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
6097
+ });
6098
+ if (!client) {
6099
+ this.#logger.warn("session.transport.dial_authorization.no_builder", {
6100
+ sessionId,
6101
+ relayPeerId,
6102
+ 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",
6103
+ correlationId: entry.correlationId,
6104
+ });
6105
+ continue;
6106
+ }
6107
+ this.#relayClients.set(clientKey, client);
6108
+ // Review M4: remember it so teardown releases it — this is a SECOND client for the
6109
+ // session, and detach only knows about the witness one.
6110
+ entry.extraRelayClientKeys = [...(entry.extraRelayClientKeys ?? []), clientKey];
6111
+ }
6112
+ // No leaf handler: this relay is not witnessing the session, it only needs the binding.
6113
+ client.registerSession(sessionIdHex, entry.node, undefined, assignment);
6114
+ const recorded = await client.recordAssignmentAndWait(entry.node, sessionIdHex);
6115
+ if (recorded) {
6116
+ this.#logger.info("session.transport.dial_authorized", {
6117
+ sessionId,
6118
+ relayPeerId,
6119
+ impact: "the relay that gates this circuit dial now holds the assignment authorizing it",
6120
+ correlationId: entry.correlationId,
6121
+ });
6122
+ }
6123
+ else {
6124
+ this.#logger.warn("session.transport.dial_authorization.not_recorded", {
6125
+ sessionId,
6126
+ relayPeerId,
6127
+ impact: "the relay did not confirm the assignment; the dial below may be refused and messages would fall to the park path",
6128
+ correlationId: entry.correlationId,
6129
+ });
6130
+ }
6131
+ }
6132
+ catch (err) {
6133
+ this.#logger.warn("session.transport.dial_authorization.failed", {
6134
+ sessionId,
6135
+ relayPeerId,
6136
+ error: extractErrorMessage(err),
6137
+ impact: "could not tell the relay that gates this dial about the session; the dial is attempted anyway",
6138
+ correlationId: entry.correlationId,
6139
+ });
6140
+ }
6141
+ }
6142
+ }
5158
6143
  /**
5159
6144
  * Send content over the session node's direct P2P content stream.
5160
6145
  * On a dead/missing stream this returns a NAMED, diagnosable failure — never a silent success
@@ -5235,6 +6220,15 @@ export class SessionNodeManager {
5235
6220
  // the leaf_deliver witness stream / arrival order.
5236
6221
  let orderingS1;
5237
6222
  let orderingS2;
6223
+ /**
6224
+ * DOD-M15-SEALWIRE-1 bullet 5, SENT half. Our own Ed25519 signature over `orderingS1`.
6225
+ *
6226
+ * The submit path already computes this — `keyProvider.sign(structure1)` — and puts it on the
6227
+ * wire as `sender_signature`. It was simply never handed back, which is the whole of the defect:
6228
+ * a RECEIVED row could prove its author to a third party and a SENT row could not, so half the
6229
+ * transcript was provable and half was assertion.
6230
+ */
6231
+ let sentAuthorship;
5238
6232
  // DOD-M12B-INDEX-1: the relay's answer to "where does this message go", carried to the caller.
5239
6233
  let assignedSeq;
5240
6234
  // DOD-MP-SESSION-RETIRE-1 — the relay's answer SURVIVES to the caller even when the direct send
@@ -5254,6 +6248,101 @@ export class SessionNodeManager {
5254
6248
  if (witnessed.ok) {
5255
6249
  orderingS1 = witnessed.structure1_cbor;
5256
6250
  orderingS2 = witnessed.structure2_cbor;
6251
+ /**
6252
+ * PAIRED WITH THE BYTES IT SIGNS, in one place, so the two can never be assigned apart.
6253
+ *
6254
+ * ⚠️ THE PUBKEY COMES FROM INSIDE `structure1_cbor`, NOT FROM AN AGENT LOOKUP — the same
6255
+ * source the RECEIVED half uses. A verifier checks the signature against the key in the
6256
+ * signed bytes; storing any other key would produce a row that looks checkable and fails,
6257
+ * and a lookup could drift from what was actually signed (a rotated identity, the wrong
6258
+ * agent resolved by name). Taking it from the signed bytes makes that class impossible.
6259
+ */
6260
+ if (witnessed.structure1_cbor && witnessed.sender_signature) {
6261
+ /**
6262
+ * ⚠️ THE DROP IS SOFT, BUT IT MUST NOT BE SILENT — review pass 1, F2.
6263
+ *
6264
+ * My comment here claimed the resulting row is "distinguishable from one that carries a
6265
+ * signature". True, and it misses the comparison that matters: **it is byte-identical to
6266
+ * an UNWITNESSED send** — `self_authored`, both proof columns NULL. So the record cannot
6267
+ * tell "the relay never witnessed this" from "we witnessed it, held the proof, and
6268
+ * dropped it decoding our own bytes."
6269
+ *
6270
+ * And the asymmetry with the received half is the argument. `#recordFrameOrdering` is
6271
+ * soft because the COUNTERPARTY supplied those bytes — an absence we cannot resolve.
6272
+ * Here **we produced them**, in `session-relay-client.ts`, moments earlier. A failure
6273
+ * means our own encoder and decoder disagree: an internal invariant break that would
6274
+ * strip authorship from every sent row for the life of the process. Soft is still right
6275
+ * — throwing would lose a delivered message over a missing attestation — but soft and
6276
+ * unannounced is the silent-fallback pattern this milestone exists to find.
6277
+ */
6278
+ const dropAuthorship = (reason, error, extra) => {
6279
+ this.#logger.warn("session.sent.authorship.unavailable", {
6280
+ sessionId,
6281
+ agentName,
6282
+ reason,
6283
+ // WHICH ROW lost its proof — review pass 2, M2. Without the sequence an operator knows
6284
+ // a message is unproven and not which one, in a transcript of hundreds.
6285
+ ...(witnessed.ok ? { relaySequence: witnessed.sequence_number } : {}),
6286
+ ...(extra ?? {}),
6287
+ ...(error === undefined ? {} : { error: error instanceof Error ? error.message : String(error) }),
6288
+ impact: "this sent message is recorded with attribution 'self_authored' and NO signature, so the row " +
6289
+ "asserts its author rather than proving one. It is indistinguishable in the database from a " +
6290
+ "send the relay never witnessed — this log line is the only thing that tells them apart.",
6291
+ guidance: "We produced these bytes ourselves, so a decode or shape failure here means this daemon's own " +
6292
+ "encoder and decoder disagree. Treat it as an internal invariant break, not a peer problem.",
6293
+ correlationId,
6294
+ });
6295
+ };
6296
+ try {
6297
+ // Structure 1 = [1, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp]
6298
+ // — the same decode `#recordFrameOrdering` does for the received half, index 2.
6299
+ const s1 = decode(witnessed.structure1_cbor);
6300
+ const pk = s1[2];
6301
+ // The SIGNATURE is length-checked too (review F2): the guard checked the pubkey's 32
6302
+ // bytes and only truthiness on the signature, so a zero-length one would have stored an
6303
+ // uncheckable BLOB. Not reachable today — `sign()` returns 64 — and the asymmetry is
6304
+ // the kind that stops being unreachable quietly.
6305
+ /**
6306
+ * ⚠️ VERIFIED BEFORE IT IS STORED, NOT SHAPE-CHECKED — review pass 2, H2, and this is
6307
+ * worth more than any test of it.
6308
+ *
6309
+ * This used to accept the pair on 32 bytes and 64 bytes. A shape check cannot tell a
6310
+ * real proof from 96 bytes that resemble one, and **nothing downstream ever checks
6311
+ * either**: not at write time, and not at read time, because no production reader of
6312
+ * these columns exists yet. So a wrong Structure-1 index, a wrong key, or a pair from
6313
+ * two different submits would all have been persisted as a row that **looks checkable
6314
+ * to an auditor and fails** — strictly worse than the honest unproven row it replaced.
6315
+ *
6316
+ * The received half has always done this (`#recordFrameOrdering` verifies before
6317
+ * storing and treats a failure as fatal). The sent half did not, and every ingredient
6318
+ * was already in scope on this line.
6319
+ *
6320
+ * What it buys over a test: a wrong index becomes **impossible to persist**. The row
6321
+ * gets NULL, the warn below fires, and it happens in production on the machine that
6322
+ * caused it — not in a suite someone has to remember to write.
6323
+ *
6324
+ * NOT fatal, unlike the received half, and the asymmetry is deliberate: there the
6325
+ * failure means a COUNTERPARTY sent something that does not verify, which is an
6326
+ * identity problem. Here it means our own encoder and decoder disagree — bad, but it
6327
+ * must not cost the operator a delivered message.
6328
+ */
6329
+ if (!(pk instanceof Uint8Array) || pk.length !== 32) {
6330
+ dropAuthorship("pubkey_shape", undefined, { pubkeyLen: pk instanceof Uint8Array ? pk.length : -1 });
6331
+ }
6332
+ else if (witnessed.sender_signature.length !== 64) {
6333
+ dropAuthorship("signature_shape", undefined, { sigLen: witnessed.sender_signature.length });
6334
+ }
6335
+ else if (!verify(pk, witnessed.structure1_cbor, witnessed.sender_signature)) {
6336
+ dropAuthorship("pair_does_not_verify");
6337
+ }
6338
+ else {
6339
+ sentAuthorship = { senderPubkey: pk, senderSig: witnessed.sender_signature };
6340
+ }
6341
+ }
6342
+ catch (err) {
6343
+ dropAuthorship("structure1_decode_failed", err);
6344
+ }
6345
+ }
5257
6346
  // 1-BASED → 0-BASED. The relay numbers the first leaf of a session 1
5258
6347
  // (`relay-node.ts`: `const seq = state.seq_counter + 1`), and this tree is 0-indexed.
5259
6348
  // Every RECEIVE path in this file normalises with -1 and says so; the send path took the
@@ -5385,6 +6474,22 @@ export class SessionNodeManager {
5385
6474
  // connection). See the note on #handleContentStream's finally.
5386
6475
  let sendStream;
5387
6476
  try {
6477
+ /**
6478
+ * 🚨 NO KEY, NO DIRECT SEND — `DOD-M15-EPHEMERAL-AUTH-1`, and there is no plaintext fallback.
6479
+ *
6480
+ * Throwing here rather than sending in the open, because the catch below PARKS the content —
6481
+ * and the mailbox copy is sealed to the counterparty's long-term identity key, so the message
6482
+ * still travels encrypted and still arrives. The failure mode is a delay, never an exposure.
6483
+ *
6484
+ * A fallback to plaintext would be a thing an attacker steers a session into: strip the key
6485
+ * frame and a system that "carries on, degraded" gives up the body while the operator reads a
6486
+ * warning they have learned to scroll past. That is why this is a throw and not a warning.
6487
+ */
6488
+ const encState = this.#contentEncryptionState(agentName, sessionId);
6489
+ if (encState.key === null) {
6490
+ throw new Error(`content_not_encryptable: ${encState.reason} — ${CONTENT_ENCRYPTION_GUIDANCE[encState.reason]}`);
6491
+ }
6492
+ const sessionKey = encState.key;
5388
6493
  const stream = await this.#openContentStream(agentName, sessionId, entry, correlationId);
5389
6494
  sendStream = stream;
5390
6495
  // AC-001/AC-003: arm the TTF tracking BEFORE the frame goes on the wire. The
@@ -5395,12 +6500,32 @@ export class SessionNodeManager {
5395
6500
  // resolves it (content.delivery.acked) and TTF expiry hands it to the park
5396
6501
  // backstop. The correlationId rides in the frame so the receiver's
5397
6502
  // session.content.received shares ONE flow id with the sender.
6503
+ /**
6504
+ * ⚠️ THE TRACKER KEEPS THE PLAINTEXT, AND THAT IS DELIBERATE — 007-CRYPTO.
6505
+ *
6506
+ * When live delivery times out, THIS is the copy the park backstop seals into the relay
6507
+ * mailbox, under `sealToRecipient` to the counterparty's long-term identity key. Encrypting
6508
+ * `content` in place would put a body locked under a session key — one about to be destroyed —
6509
+ * inside the mailbox envelope, and the recipient would open the outer seal onto bytes nothing
6510
+ * can read. It passes every live-delivery test and fails only for messages that park.
6511
+ *
6512
+ * So the session key encrypts the copy that goes ON THE WIRE, below, and nothing else.
6513
+ */
5398
6514
  this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, orderingS1, orderingS2, contentHashAlg);
6515
+ /**
6516
+ * THE WIRE COPY. `content_hash` above was computed over the PLAINTEXT and stays that way: the
6517
+ * transcript, the seal and the salted hash all depend on it meaning what it means today, and
6518
+ * the receiver decrypts before it verifies.
6519
+ */
6520
+ const wireBody = sealSessionContent(sessionKey, content);
5399
6521
  const frame = encodeCbor({
5400
6522
  type: "content_frame",
5401
6523
  session_id: sessionId,
5402
6524
  content_hash: contentHash,
5403
- content_bytes: content,
6525
+ content_bytes: wireBody,
6526
+ // WHICH scheme the body is under. Present because the receiver must not have to guess from
6527
+ // a length, and absent is not a valid state — a frame without it is refused, not read raw.
6528
+ content_encryption: SESSION_CONTENT_ENCRYPTION_V1,
5404
6529
  correlation_id: correlationId,
5405
6530
  // DOD-MSG-4 (self-ordering): the relay's signed ordering record, so the receiver verifies +
5406
6531
  // orders from the frame ALONE (no dependence on the separate leaf_deliver witness timing).
@@ -5436,7 +6561,7 @@ export class SessionNodeManager {
5436
6561
  // on the content hash. A false delivered costs the message.
5437
6562
  await stream.close();
5438
6563
  this.#clearSessionImpairment(agentName, sessionId, "direct_send", correlationId);
5439
- return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
6564
+ return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
5440
6565
  }
5441
6566
  catch (err) {
5442
6567
  this.#markSessionImpaired(agentName, sessionId, { cause: "direct_send", error: err instanceof Error ? err.message : String(err), correlationId });
@@ -5483,7 +6608,7 @@ export class SessionNodeManager {
5483
6608
  const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2, contentHashAlg);
5484
6609
  if (attempt.outcome === "parked") {
5485
6610
  this.#noteImpairmentRetention(agentName, sessionId, "parked");
5486
- return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
6611
+ return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
5487
6612
  }
5488
6613
  // M12-P12: the deposit was refused, and #untrackAwaitingAck above already dropped the
5489
6614
  // in-memory entry — so without this, NOTHING holds the content and the TTF timer that would
@@ -5575,6 +6700,8 @@ export class SessionNodeManager {
5575
6700
  // Carried on the failure path too: a DURABLY QUEUED message still owns the position the
5576
6701
  // relay witnessed for it before delivery was attempted, and its leaf must go there.
5577
6702
  ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }),
6703
+ // …and so does its PROOF, for the same reason. It was signed before the hand-off failed.
6704
+ ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }),
5578
6705
  // M12-P13: the machine-readable half of the distinction below. M12-P12 shipped it in the
5579
6706
  // guidance SENTENCE only, so the callers that have to ACT on it — commit the leaf for a
5580
6707
  // queued message, never for a lost one — would have had to substring-match English. None
@@ -5599,11 +6726,7 @@ export class SessionNodeManager {
5599
6726
  * This is the reason `cause` had to become a code first: the distinction is unbranchable
5600
6727
  * while the field holds an English paragraph.
5601
6728
  */
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.",
6729
+ guidance: parkRefusalGuidance(attempt.cause, durable, attempt.retryAfterMs),
5607
6730
  };
5608
6731
  }
5609
6732
  }
@@ -5624,110 +6747,153 @@ export class SessionNodeManager {
5624
6747
  if ("error" in transport)
5625
6748
  return { ok: false, reason: transport.error };
5626
6749
  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",
6750
+ /**
6751
+ * DOD-M15-RELAYLEAK-1 release a DETACHED seal transport when this submission is done.
6752
+ *
6753
+ * Only the detached branch registers a session here, so this can never remove a live one. The
6754
+ * client is closed only when it has no sessions left and the cache still holds THIS client —
6755
+ * the same two guards `#detachSessionRelay` uses, and for the same reason: a racing teardown
6756
+ * must not close a freshly-built replacement for the same key.
6757
+ */
6758
+ const releaseDetached = () => {
6759
+ if (transport.releaseOnDone !== true)
6760
+ return;
6761
+ try {
6762
+ entry.relayClient.unregisterSession(sessionId);
6763
+ if (!entry.relayClient.hasSessions()) {
6764
+ for (const [key, cached] of this.#relayClients) {
6765
+ if (cached === entry.relayClient) {
6766
+ cached.close();
6767
+ this.#relayClients.delete(key);
6768
+ break;
6769
+ }
6770
+ }
6771
+ }
6772
+ }
6773
+ catch (err) {
6774
+ this.#logger.warn("session.seal.transport.release_failed", {
6775
+ agentName,
6776
+ sessionId,
6777
+ reason: err instanceof Error ? err.message : String(err),
6778
+ impact: "a detached seal relay client could not be released; it is held until process exit",
5655
6779
  });
5656
- this.#responderSealSubmitted.set(sealKey, durable);
5657
6780
  }
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.
6781
+ };
6782
+ /**
6783
+ * ⚠️ try/finally, NOT a call at each return. There are three exits today and adding a release
6784
+ * to each would be a hand-kept list the shape this milestone has been bitten by repeatedly,
6785
+ * where the FOURTH exit added later quietly leaks. Here the release cannot be bypassed by a new
6786
+ * return, and it runs on the throw path too, which is where a leak matters most.
6787
+ */
5677
6788
  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).
6789
+ // M7-UPGRADE-002 idempotency: this party submits its responder SEAL leaf AT MOST ONCE per
6790
+ // session. BOTH cello_close_session and the auto-acknowledge path call here; the first to reach
6791
+ // this point wins, the second short-circuits. The check+set is SYNCHRONOUS (before any await) so
6792
+ // two near-simultaneous triggers (e.g. B's own close racing A's delivered SEAL ctrl leaf) cannot
6793
+ // both submit. Cleared below on a relay submit failure so a genuine retry can proceed.
6794
+ // DOD-M12B-INTERRUPTED-ESCALATE-1 — THE MARK MUST SURVIVE A RESTART, or one automatic retry
6795
+ // permanently forfeits the receipt.
6796
+ //
6797
+ // `#responderSealSubmitted` is in memory. A session whose close was in flight when the daemon
6798
+ // stopped already has our SEAL ctrl leaf in the relay log — and on the next boot the mark is
6799
+ // empty, so the restart-seal resolver's automatic close would submit a SECOND one. The
6800
+ // directory requires exactly one ctrl leaf (`ctrlLeaves.length !== 1 → unilateral_seal_leaf_invalid`)
6801
+ // and the carry is durable, so every future attempt would carry both and be refused forever.
6802
+ //
6803
+ // The durable evidence already exists and was simply not consulted: our own ctrl leaf is in
6804
+ // `session_seal_leaves`. Recover the escalation values from it instead of submitting again.
6805
+ if (!this.#responderSealSubmitted.has(sealKey)) {
6806
+ const durable = this.#recoverOwnSealCtrlLeaf(agentName, sessionId);
6807
+ if (durable === "unknown") {
6808
+ // REFUSE, do not submit. A second ctrl leaf makes the session unsealable forever, and the
6809
+ // question "is one already there?" just failed to answer. Refusing costs this close; a
6810
+ // second leaf costs the receipt permanently.
6811
+ return { ok: false, reason: "seal_leaf_recovery_unavailable" };
6812
+ }
6813
+ if (durable !== "none") {
6814
+ this.#logger.info("session.seal.leaf.already_submitted.recovered", {
6815
+ sessionId, agentName, sequenceNumber: durable.sequenceNumber,
6816
+ impact: "our SEAL ctrl leaf is already in the relay log from a previous run; submitting a second would make this session unsealable forever",
6817
+ });
6818
+ this.#responderSealSubmitted.set(sealKey, durable);
6819
+ }
6820
+ }
6821
+ if (this.#responderSealSubmitted.has(sealKey)) {
6822
+ // M8B FINDING-1: carry the FIRST submit's reported root/sequence so a retry close can
6823
+ // still escalate to a unilateral seal. A null value means that submit is still in
6824
+ // flight — return the bare reason and let the caller fall back to the pending path.
6825
+ const prior = this.#responderSealSubmitted.get(sealKey);
6826
+ return prior
6827
+ ? {
6828
+ ok: false,
6829
+ reason: "responder_seal_already_submitted",
6830
+ reportedRootHex: prior.reportedRootHex,
6831
+ sequenceNumber: prior.sequenceNumber,
6832
+ }
6833
+ : { ok: false, reason: "responder_seal_already_submitted" };
6834
+ }
6835
+ this.#responderSealSubmitted.set(sealKey, null);
6836
+ // A throw anywhere before the mark is finalized would strand the null in-flight marker
6837
+ // and lock every future close out of escalation (a FINDING-1-shaped deadlock via a
6838
+ // different trigger) — clear the mark on any unexpected exception.
6839
+ try {
6840
+ const finalRootHex = this.getSessionTreeRootHex(agentName, sessionId);
6841
+ const sealPayload = encodeSealPayload({
6842
+ session_id: entry.relaySessionIdBytes,
6843
+ final_root: new Uint8Array(Buffer.from(finalRootHex, "hex")),
6844
+ close_timestamp: Date.now(),
6845
+ attestation: "PENDING",
6846
+ });
6847
+ // content_hash = SHA-256(0x02 || seal_payload) — the ctrl leaf kind byte is 0x02.
6848
+ const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(sealPayload).digest());
6849
+ /**
6850
+ * ⚠️ `sealPayload` IS PASSED, AND ITS ABSENCE WAS THE WHOLE DEFECT — `DOD-M15-SEALWIRE-1`
6851
+ * bullets 3+4, review pass 1, F1.
6852
+ *
6853
+ * These exact bytes were computed two lines above, hashed, and then dropped: `submitLeaf` had
6854
+ * no parameter for them. So the directory received a SHA-256 pre-image nobody transmitted, and
6855
+ * the client's SIGNED `final_root` — the one value in the seal the relay cannot produce — was
6856
+ * unrecoverable. Four legs of this line shipped and were reviewed green while the head of the
6857
+ * chain did not exist.
6858
+ *
6859
+ * The payload and the hash MUST come from the same derivation. If they ever diverge the
6860
+ * directory reports `seal_payload_unbound`, whose guidance says *"someone between them and here
6861
+ * altered or fabricated the payload — the relay is the only party on that path"* — a correct
6862
+ * relay accused by name, in an error written to sound like an attack, for a mismatch made here.
6863
+ */
6864
+ const result = await entry.relayClient.submitLeaf(entry.node, entry.relaySessionIdBytes, contentHash, LEAF_KIND_CTRL, sealPayload);
6865
+ if (!result.ok) {
6866
+ // Clear the idempotency mark so a genuine retry (agent close / reconnect) can proceed (DB-001).
6867
+ this.#responderSealSubmitted.delete(sealKey);
6868
+ this.#logger.warn("session.seal.leaf.submit.failed", { sessionId, reason: result.reason, correlationId });
6869
+ return { ok: false, reason: result.reason };
6870
+ }
6871
+ // SESSION-002: the reported_root for a unilateral seal is the content-hash root the
6872
+ // local tree WOULD have with this SEAL ctrl leaf appended — the same root the directory
6873
+ // rebuilds from the relay's content-hash chain (the relay records the identical
6874
+ // content_hash for this ctrl leaf). Computed without mutating the durable tree /
6875
+ // message_count, so the bilateral + interrupted seal paths are unaffected.
6876
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
6877
+ const reportedRootHex = this.getSessionTree(agentName, sessionId).rootWithAppendedHex(contentHashHex);
6878
+ // M8B FINDING-1: durably associate the submit's escalation values with the idempotency
6879
+ // mark, so any LATER close call can retrieve them via the already-submitted result.
6880
+ this.#responderSealSubmitted.set(sealKey, { reportedRootHex, sequenceNumber: result.sequence_number });
6881
+ this.#logger.info("session.seal.leaf.submitted", {
6882
+ sessionId,
6883
+ sequenceNumber: result.sequence_number,
6884
+ correlationId,
6885
+ });
6886
+ // M7-UPGRADE-002: #responderSealSubmitted was set synchronously at the top of this method —
6887
+ // the guard now blocks any second submit (auto-ack OR a redelivered counterparty SEAL ctrl leaf).
6888
+ return { ok: true, sequenceNumber: result.sequence_number, reportedRootHex };
6889
+ }
6890
+ catch (err) {
5705
6891
  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 };
6892
+ throw err;
6893
+ }
5727
6894
  }
5728
- catch (err) {
5729
- this.#responderSealSubmitted.delete(sealKey);
5730
- throw err;
6895
+ finally {
6896
+ releaseDetached();
5731
6897
  }
5732
6898
  }
5733
6899
  /**
@@ -6240,7 +7406,12 @@ export class SessionNodeManager {
6240
7406
  // one that does not repair: this side declined the salt permanently for this session, so
6241
7407
  // waiting for a reconnect is exactly the wrong advice. Leaving it out of this list would
6242
7408
  // 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.",
7409
+ // DOD-M15-SALTSPLIT-1 review MEDIUM-3: `session.salt.discarded` is the FIFTH cause, and it
7410
+ // was added by the discard without appearing in this tree. Without it an operator whose salt
7411
+ // was deliberately dropped is sent to look for three events that will not be there and then
7412
+ // told a fifth thing that is false — the agreement DID complete here, and was then undone on
7413
+ // purpose.
7414
+ 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
7415
  });
6245
7416
  // DOD-M15-REFUSED-INBOUND-SILENT-1 — and this branch needed it MORE than the two that had it.
6246
7417
  //
@@ -6258,7 +7429,7 @@ export class SessionNodeManager {
6258
7429
  // copy is a second thing to keep true, and the log's version is the one that gets maintained.
6259
7430
  this.noteContentRefusal(agentName, sessionId, "content_hash_salt_unavailable", {
6260
7431
  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.",
7432
+ 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
7433
  });
6263
7434
  return { ok: false, reason: "content_hash_salt_unavailable" };
6264
7435
  }
@@ -7128,14 +8299,39 @@ export class SessionNodeManager {
7128
8299
  // Two correct changes, each fine alone, that break where they meet. Caught by running the
7129
8300
  // live enforcers straight after merging main rather than trusting a green unit suite.
7130
8301
  this.#witnessedSeq.get(this.#k(agentName, sessionId))?.delete(contentHashHex);
8302
+ /**
8303
+ * ⚠️ THIS LINE USED TO LOG `ok: routed.ok` AND `reason: routed.reason`, AND NEITHER CAN EVER
8304
+ * BE PRESENT HERE. Removed rather than left, because their absence was read as evidence.
8305
+ *
8306
+ * The producer is `DocumentFrameRouter.routeSync`, and it has four returns — `unshaped`,
8307
+ * `undecodable`, `owner_unresolved`, and the normal path — **none of which sets either
8308
+ * field.** It cannot: the normal path is `void this.#enqueue(...)`, fire-and-forget, so at the
8309
+ * instant this line is written the frame has been CLASSIFIED and QUEUED and nothing has yet
8310
+ * decided whether it will be accepted. The verdict is genuinely not knowable here.
8311
+ *
8312
+ * **What that cost:** `j-stale-session` reported `framesReceived=3 inbound=0`, and the
8313
+ * investigation recorded that `ok` and `reason` were "ABSENT from every line in the run — so
8314
+ * the router returned neither, which is itself the next thread to pull: a routing result that
8315
+ * reports no outcome cannot say whether it accepted or dropped the frame." That thread leads
8316
+ * nowhere. The router did not fail to report an outcome; **it has no outcome to report at this
8317
+ * point in the flow**, and a JSON logger omits an `undefined` field, so a structural absence
8318
+ * looked exactly like a fault. A field that can never be populated is worse than no field.
8319
+ *
8320
+ * **Where the verdict actually lands**, named here so the next reader does not have to find it
8321
+ * the hard way: a refusal is `document.frame.refused` (warn, carrying `kind` + `reason`,
8322
+ * emitted from `#enqueue`'s continuation under the same `correlationId`). Acceptance is
8323
+ * silent on this event. So "was this frame ingested?" is answered by joining on
8324
+ * `correlationId`, never by reading this line alone.
8325
+ */
7131
8326
  this.#logger.info("session.document.received", {
7132
8327
  sessionId,
7133
8328
  senderPubkey,
7134
8329
  contentHashHex,
7135
8330
  sequenceNumber: leafIndex,
7136
8331
  kind: routed.kind,
7137
- ok: routed.ok,
7138
- reason: routed.reason,
8332
+ // The verdict is asynchronous. Stated positively so absence is not mistaken for silence.
8333
+ dispatch: "queued",
8334
+ verdictEvent: "document.frame.refused",
7139
8335
  correlationId,
7140
8336
  });
7141
8337
  return { leafIndex };
@@ -7481,7 +8677,35 @@ export class SessionNodeManager {
7481
8677
  * and it appends in arrival order as before: with no position there is no discipline to enforce,
7482
8678
  * and refusing would take messaging down whenever the relay is unreachable.
7483
8679
  */
7484
- placeOwnLeaf(agentName, sessionId, contentHashHex, sentBytes, assignedSeq, correlationId, kind = "msg") {
8680
+ placeOwnLeaf(agentName, sessionId, contentHashHex, sentBytes, assignedSeq, correlationId,
8681
+ /**
8682
+ * ⚠️ NO DEFAULT, for the same reason `authorship` has none.
8683
+ *
8684
+ * `kind = "msg"` meant a caller writing a `doc` or a `ctrl` leaf got a `msg` leaf by saying
8685
+ * nothing, and the tree recorded a leaf kind the author never chose. TypeScript also forbids a
8686
+ * required parameter after a defaulted one, so leaving the default here would have forced
8687
+ * `authorship` back to optional — which is the defect above. Every one of the seven call sites
8688
+ * already passed a kind or wanted "msg"; making it explicit cost nothing and removes a second
8689
+ * silent answer from the same signature.
8690
+ */
8691
+ kind,
8692
+ /**
8693
+ * DOD-M15-SEALWIRE-1 bullet 5 — the proof for THIS send, so a held row keeps it.
8694
+ *
8695
+ * ⚠️ REQUIRED, AND `undefined` IS A VALID ANSWER — the two are not the same thing.
8696
+ *
8697
+ * This was `authorship?:` for exactly one review cycle, and in that cycle THREE of the seven
8698
+ * call sites omitted it: `daemon.ts` 1440, 1671, 1685 — the away-reply path, which is the
8699
+ * highest-traffic sent-writer in the daemon and the one with no human watching it. All three
8700
+ * had the proof **already in a local variable one line below**, handed to
8701
+ * `recordTranscriptMessage` and not to this method. Nothing went red, because an optional
8702
+ * parameter's whole behaviour on omission is to look deliberate.
8703
+ *
8704
+ * An unwitnessed send genuinely has no proof, so absence must stay expressible. Requiring the
8705
+ * parameter keeps that while making the caller SAY it: omission is now a type error, and
8706
+ * `undefined` is a claim the author made rather than one the signature made for them.
8707
+ */
8708
+ authorship) {
7485
8709
  // Hydrate before reading the frontier: a durable hold this process has not read back yet would
7486
8710
  // make the tree look further along than it is.
7487
8711
  this.#ensureHeldRestored(agentName, sessionId);
@@ -7551,7 +8775,7 @@ export class SessionNodeManager {
7551
8775
  held = new Map();
7552
8776
  this.#heldContent.set(key, held);
7553
8777
  }
7554
- held.set(assignedSeq, { content: sentBytes, contentHashHex, correlationId, origin: "sent", kind });
8778
+ held.set(assignedSeq, { content: sentBytes, contentHashHex, correlationId, origin: "sent", kind, ...(authorship ? { authorship } : {}) });
7555
8779
  this.#persistHeldContent(agentName, sessionId, assignedSeq, sentBytes, sentBytes, contentHashHex, false, correlationId, "sent", kind);
7556
8780
  this.#logger.info("session.content.held", {
7557
8781
  sessionId, canonicalSeq: assignedSeq, nextExpected, gap: assignedSeq - nextExpected,
@@ -7731,6 +8955,24 @@ export class SessionNodeManager {
7731
8955
  ...(row.screened_out ? { screenedOut: true } : {}),
7732
8956
  ...(row.origin === "sent" ? { origin: "sent" } : {}),
7733
8957
  ...(row.leaf_kind === "doc" ? { kind: "doc" } : {}),
8958
+ /**
8959
+ * ⚠️ `restoredAcrossRestart` EXISTS TO NAME A LOSS, NOT TO CHANGE BEHAVIOUR — review pass 2, H1.
8960
+ *
8961
+ * `held_content` has no authorship columns, so a SENT message held behind a gap and released
8962
+ * after a daemon restart comes back with **no signature** — its transcript row records
8963
+ * `self_authored` with no proof, indistinguishable from an unwitnessed send. That is the
8964
+ * defect bullet 5 exists to end, reappearing on the recovery path, and it was silent.
8965
+ *
8966
+ * The proof cannot be reconstructed here: it was made over Structure-1 bytes this process no
8967
+ * longer holds. **So the honest move is to say so, not to fabricate one** — a proof that
8968
+ * cannot be checked, presented as one that can, is worse than the absence.
8969
+ *
8970
+ * Two BLOB columns would close it properly (this table already carries two `ALTER TABLE …
8971
+ * ADD COLUMN` migrations, so the pattern exists). Under a frozen gate a log is additive and
8972
+ * tightenable where a schema change is neither, so this announces the loss now and
8973
+ * `DOD-M15-HELD-AUTHORSHIP-1` carries the column.
8974
+ */
8975
+ restoredAcrossRestart: true,
7734
8976
  });
7735
8977
  restored++;
7736
8978
  }
@@ -7794,7 +9036,25 @@ export class SessionNodeManager {
7794
9036
  // OBSERVED, not assumed — the received path already does this. The leaf commits either way,
7795
9037
  // so a dropped transcript write means the operator's OWN message is missing from their own
7796
9038
  // 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)) {
9039
+ // bullet 5: the proof was captured at submit time and rides the held entry — see #heldContent.
9040
+ /**
9041
+ * THE RESTART LOSS, ANNOUNCED — review pass 2, H1. Only for an entry that actually crossed a
9042
+ * restart: an in-memory held entry carries its proof, and an unwitnessed send legitimately
9043
+ * has none, so warning on every absent proof would fire on a designed benign state and bury
9044
+ * the one occurrence that means something.
9045
+ */
9046
+ if (entry.restoredAcrossRestart === true && entry.authorship === undefined) {
9047
+ this.#logger.warn("session.content.released.authorship.lost", {
9048
+ agentName, sessionId, sequenceNumber: nextExpected, correlationId: entry.correlationId,
9049
+ impact: "this message was held behind a gap, survived a daemon restart, and is now committed with " +
9050
+ "attribution 'self_authored' and NO signature. Its transcript row asserts its author rather " +
9051
+ "than proving one, and is indistinguishable from a send the relay never witnessed.",
9052
+ guidance: "Not recoverable after the fact — the signature covered Structure-1 bytes this process no longer " +
9053
+ "holds, and fabricating one would be worse than the absence. Tracked as DOD-M15-HELD-AUTHORSHIP-1: " +
9054
+ "held_content needs the two proof columns so a restart carries them.",
9055
+ });
9056
+ }
9057
+ if (!this.recordTranscriptMessage(agentName, sessionId, nextExpected, "sent", entry.content, entry.correlationId, entry.authorship)) {
7798
9058
  this.#logger.error("session.content.released.transcript.failed", {
7799
9059
  agentName, sessionId, sequenceNumber: nextExpected, correlationId: entry.correlationId,
7800
9060
  impact: "this side's own message is committed to the chain but missing from its transcript",
@@ -8269,13 +9529,22 @@ export class SessionNodeManager {
8269
9529
  * is correct today rather than an oversight.
8270
9530
  *
8271
9531
  * 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.
9532
+ * `sha256`. In part B1 that was exactly right and provably so: no sender salted, so every parked
9533
+ * entry in existence had been hashed unsalted.
8274
9534
  *
8275
9535
  * ✅ FIXED IN PART B2a, at BOTH sites: here, and the independent verifier in `content-park.ts`.
8276
9536
  * 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.
9537
+ * `sha256` — which is what a peer predating the field actually used.
9538
+ *
9539
+ * > **⛔ THE LAST SENTENCE HERE READ "Every envelope this build emits is still v2, because
9540
+ * > nothing salts yet." THAT IS FALSE NOW.** B2b-2 turned salting on: a session holding an
9541
+ * > agreed salt hashes under `hmac-sha256-salt-v1`, so this build DOES emit v3 envelopes.
9542
+ * > Rewritten rather than deleted, per `DOD-M15-CLAIM-COMMENTS-1` — the sentence is why the
9543
+ * > staleness survived, and an absence would read as deliberate.
9544
+ * >
9545
+ * > The consequence is not theoretical: a v2 envelope carrying a SALTED hash recomputes unsalted
9546
+ * > at the far end and reports `content_hash_mismatch` — a false tamper claim on honest content,
9547
+ * > which also blocks auto-co-sign at seal. Measured on 2026-08-24.
8279
9548
  *
8280
9549
  * ─── AND THE REFUSAL DOES NOT HOLD — review F2 ────────────────────────────────────────────
8281
9550
  *
@@ -8491,67 +9760,570 @@ export class SessionNodeManager {
8491
9760
  * obvious place to put one. A session that shipped it there could not be repaired: the relay
8492
9761
  * would already hold the salt and every hash it protects.
8493
9762
  */
8494
- #saltContributionFor(agentName, sessionId) {
9763
+ /**
9764
+ * MINT THIS SESSION'S THROWAWAY KEYPAIR — once, at the moment the session becomes active.
9765
+ *
9766
+ * Idempotent on purpose. Three paths make a session active (open, hand-off from the standing
9767
+ * receiver, revive) and a reconnect can re-enter them; minting a second keypair mid-session would
9768
+ * leave the two sides deriving against a moving value, and the symptom — a session that reconnects
9769
+ * and still cannot agree — reads as a network fault rather than as a bug here. `#saltContributionFor`
9770
+ * mints once for exactly the same reason.
9771
+ *
9772
+ * A REVIVED session is not an exception, and this guard is why it took a fix to make that true.
9773
+ * Revival requires status `interrupted`, and the producer of `interrupted` drops the entry without
9774
+ * evicting — so while the secret outlived that path, this guard found it still present and
9775
+ * silently kept a key that had been resident for hours, on the one path where re-keying was
9776
+ * explicitly decided. The interrupt path now destroys it, so the map really is empty by the time
9777
+ * a revival reaches here and it mints fresh (Decisions Carried #5).
9778
+ */
9779
+ #mintSessionEphemeral(agentName, sessionId, correlationId) {
8495
9780
  const key = this.#k(agentName, sessionId);
8496
- let contribution = this.#saltContributions.get(key);
8497
- if (!contribution) {
8498
- contribution = generateSaltContribution();
8499
- this.#saltContributions.set(key, contribution);
8500
- }
8501
- return contribution;
9781
+ if (this.#sessionEphemerals.has(key))
9782
+ return;
9783
+ this.#sessionEphemerals.set(key, generateSessionEphemeral());
9784
+ this.#logger.debug("session.ephemeral.minted", {
9785
+ agentName, sessionId, correlationId,
9786
+ // The PUBLIC half only, and only a prefix of it. The secret must never reach a log line, and
9787
+ // an operator correlating two daemons needs an identifier rather than the value.
9788
+ publicKeyPrefix: Buffer.from(this.#sessionEphemerals.get(key).publicKey.subarray(0, 8)).toString("hex"),
9789
+ });
8502
9790
  }
8503
9791
  /**
8504
- * Our half for a session, **without minting one** review F1, and the distinction is the whole
8505
- * safety of the repair.
9792
+ * DESTROY THIS SESSION'S THROWAWAY SECRET006-CRYPTO, and this is what forward secrecy IS.
8506
9793
  *
8507
- * A session that already holds a salt must never mint a fresh half. If it did, the repair would
8508
- * offer the peer a half the stored salt was NOT derived from, they would compute a different salt,
8509
- * and both sides would believe they had agreed silently, which is the one outcome worse than
8510
- * refusing. So `null` from here means exactly "we hold a salt and the half behind it is gone",
8511
- * and that is the only state the agreement is allowed to call unrepairable.
9794
+ * ⚠️ CALLED FROM EVERY SITE THAT DROPS THE `#activeNodes` ENTRY, not from `#evictSessionCaches`,
9795
+ * and review pass 2 finding 2 is why. The first version rode the evict, which sounded right and
9796
+ * was wrong on the path an interrupted session actually takes:
9797
+ *
9798
+ * `markInterruptedWithDetails` drops the entry and DELIBERATELY does not evict the received
9799
+ * plaintext has to stay drainable and the TTF park timers have to stay armed. So the secret
9800
+ * survived. Then, when that session later sealed, `destroySessionNode` returned at its
9801
+ * `if (!entry) return` — the entry was already gone — and never reached the evict either. The
9802
+ * receipt landed, the session was over, and the secret stayed resident until the process exited.
9803
+ *
9804
+ * A relay blip is the ORDINARY way a session ends badly, so that was the common path, not a corner.
9805
+ * The evict's reasons for keeping the other caches are real and do not transfer: buffered plaintext
9806
+ * must stay readable, and a secret nothing reads must not stay alive.
9807
+ *
9808
+ * Zero THEN drop. Dropping alone leaves the bytes wherever the collector last moved them;
9809
+ * `destroySessionEphemeral` overwrites the one copy this process controls.
8512
9810
  */
8513
- #ownSaltHalf(agentName, sessionId) {
8514
- return this.#saltContributions.get(this.#k(agentName, sessionId)) ?? null;
9811
+ #destroySessionEphemeralFor(agentName, sessionId, correlationId) {
9812
+ const key = this.#k(agentName, sessionId);
9813
+ // 007-CRYPTO: the AGREED KEY goes with the ephemeral it was derived from. It is the same secret
9814
+ // one step on — leaving it behind would keep the thing the destruction exists to remove.
9815
+ const agreed = this.#sessionContentKeys.get(key);
9816
+ if (agreed) {
9817
+ agreed.fill(0);
9818
+ this.#sessionContentKeys.delete(key);
9819
+ }
9820
+ this.#sessionContentKeyPeerHalf.delete(key);
9821
+ this.#contentEncryptionReasons.delete(key);
9822
+ const ephemeral = this.#sessionEphemerals.get(key);
9823
+ if (!ephemeral)
9824
+ return;
9825
+ destroySessionEphemeral(ephemeral);
9826
+ this.#sessionEphemerals.delete(key);
9827
+ this.#logger.debug("session.ephemeral.destroyed", { agentName, sessionId, correlationId });
8515
9828
  }
8516
9829
  /**
8517
- * Test seam: force this session's own salt half, so the LOCAL-defect path is reachable.
9830
+ * AN INBOUND SIGNED EPHEMERAL verify, THEN derive. Never the other way round.
8518
9831
  *
8519
- * `generateSaltContribution` cannot produce a degenerate half, which is the point of it so the
8520
- * only way to exercise "our own random source is broken" end-to-end is to stand in for the broken
8521
- * source. Named `…ForTest` like every other seam in this file, and it writes the same map
8522
- * production writes rather than a parallel one, so a test cannot pass against state the daemon
8523
- * never reads.
9832
+ * 🚨 A FAILED VERIFICATION IS A SECURITY EVENT AND IT STOPS THE SESSION. It is not a degradation
9833
+ * to unencrypted, and the difference is the whole unit: an unsigned or wrongly-signed key is what
9834
+ * a relay substituting its own looks like, and carrying on unencrypted would hand that relay
9835
+ * exactly the plaintext it was reaching for. Missing, malformed and mismatched take this same
9836
+ * path — a check that is lenient about a missing proof is a check an attacker skips.
9837
+ *
9838
+ * Contrast with a peer that says NOTHING at all: that is an old build, it is not evidence of
9839
+ * anything about them, and it is recorded as `PEER_SILENT` — which still blocks sending, because
9840
+ * there is no unencrypted path to fall back to.
9841
+ */
9842
+ async #handleEphemeralFrame(agentName, sessionId, frame, correlationId) {
9843
+ const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
9844
+ if (!entry)
9845
+ return;
9846
+ /**
9847
+ * THE IDENTITY WE EXPECT is the session's own counterparty record — and its provenance differs
9848
+ * by side, which review F10 measured and an earlier version of this comment overstated.
9849
+ *
9850
+ * INITIATOR: what the OPERATOR asked for (`initiate-session-handler` takes `target_pubkey`).
9851
+ * RESPONDER: the initiator identity the DIRECTORY attested in the offer/assignment.
9852
+ *
9853
+ * So this binds the ephemeral to that identity, whichever it is. The attack it closes is the
9854
+ * RELAY substituting its own key — a different actor from the directory — and that is closed in
9855
+ * both directions. What it does NOT do is move the responder's trust off the directory; the
9856
+ * inbound path says as much itself ("a single compromised directory still controls both frames
9857
+ * here"), and that is a separate line.
9858
+ *
9859
+ * The distinction is written down rather than smoothed over because this is a public repo and
9860
+ * the sentence it replaces — "never from anything the directory handed back" — was absolute and
9861
+ * false on one of the two sides.
9862
+ */
9863
+ const expected = Buffer.from(entry.counterpartyPubkey, "hex");
9864
+ const sessionIdBytes = Buffer.from(sessionId, "hex");
9865
+ const verdict = verifySessionEphemeral({
9866
+ expectedIdentityPublic: new Uint8Array(expected),
9867
+ sessionId: sessionIdBytes,
9868
+ peerEphemeralPublic: frame.ephemeralPublic,
9869
+ peerSignature: frame.signature,
9870
+ });
9871
+ if (!verdict.ok) {
9872
+ this.#logger.error("session.key.refused", {
9873
+ agentName, sessionId, correlationId,
9874
+ reason: verdict.reason,
9875
+ detail: verdict.detail,
9876
+ guidance: "STOPPED ON PURPOSE. The session key your counterparty sent could not be tied to them, so " +
9877
+ "this session has been stopped rather than continued in the open. The ordinary cause is a " +
9878
+ "build mismatch; the one that matters is something in the middle of your connection " +
9879
+ "substituting its own key so it can read what you send. Confirm with your counterparty OUT " +
9880
+ "OF BAND — not over CELLO — before opening another session with them.",
9881
+ });
9882
+ // Session-ending, not per-message: one proven wrong signer is evidence about the CONNECTION,
9883
+ // not about the frame that carried it.
9884
+ await this.#freezeSessionForKeyRefusal(agentName, sessionId, verdict.reason, correlationId);
9885
+ return;
9886
+ }
9887
+ /**
9888
+ * ALREADY AGREED WITH **THIS** PEER HALF — idempotence keyed on the bytes, not on presence.
9889
+ *
9890
+ * ⚠️ KEYING IT ON PRESENCE WAS A DEFECT, and a routine relay roll was enough to trigger it.
9891
+ * Only the side whose witness stream closed interrupts, so only that side destroys its key and
9892
+ * re-keys on revival. The OTHER side is never torn down — nothing else clears this map — so it
9893
+ * saw the peer's NEW ephemeral, found a key already present, and kept the old one.
9894
+ *
9895
+ * Two different keys, and the damage is worse than a dead path: every message then fails GCM,
9896
+ * and the receiving daemon reports *"the message did not decrypt — it was modified in flight, or
9897
+ * encrypted under a different key"* and tells the operator to confirm OUT OF BAND. Nothing was
9898
+ * modified. Two people have a security conversation about a local key skew.
9899
+ *
9900
+ * I MEASURED THIS TWICE AND CALLED IT A HARNESS QUIRK — the notes in `seam-4` and `m9-core-001`
9901
+ * about seeding "before the settle" leaving the two ends on different keys are this defect,
9902
+ * observed and worked around instead of read.
9903
+ *
9904
+ * So: the same half re-announced on every connect is still a no-op, and a DIFFERENT half — which
9905
+ * only a re-keying peer sends — is adopted. The peer is identity-authenticated by the time we
9906
+ * get here, so letting them move the key is not a new capability.
9907
+ */
9908
+ const peerHalfHex = Buffer.from(frame.ephemeralPublic).toString("hex");
9909
+ if (this.#sessionContentKeyPeerHalf.get(this.#k(agentName, sessionId)) === peerHalfHex)
9910
+ return;
9911
+ const ownEphemeral = this.#sessionEphemeralFor(agentName, sessionId);
9912
+ if (!ownEphemeral) {
9913
+ this.#logger.error("session.key.refused", {
9914
+ agentName, sessionId, correlationId, reason: "no_local_ephemeral",
9915
+ 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.",
9916
+ });
9917
+ this.#noteContentEncryptionReason(agentName, sessionId, CONTENT_ENCRYPTION_REASONS.OUR_ANNOUNCE_FAILED);
9918
+ return;
9919
+ }
9920
+ try {
9921
+ const secrets = deriveSessionSecrets({
9922
+ ownEphemeralSecret: ownEphemeral.secretKey,
9923
+ peerEphemeralPublic: frame.ephemeralPublic,
9924
+ sessionId: sessionIdBytes,
9925
+ });
9926
+ const prior = this.#sessionContentKeyPeerHalf.get(this.#k(agentName, sessionId));
9927
+ this.#sessionContentKeys.set(this.#k(agentName, sessionId), secrets.contentKey);
9928
+ this.#sessionContentKeyPeerHalf.set(this.#k(agentName, sessionId), peerHalfHex);
9929
+ this.#contentEncryptionReasons.delete(this.#k(agentName, sessionId));
9930
+ this.#logger.info("session.key.agreed", {
9931
+ agentName, sessionId, correlationId,
9932
+ // A RE-KEY is a different event from a first agreement and an operator correlating two
9933
+ // daemons needs to tell them apart: a re-key means the other side restarted.
9934
+ rekey: prior !== undefined && prior !== peerHalfHex,
9935
+ 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",
9936
+ });
9937
+ }
9938
+ catch (err) {
9939
+ // The primitive owns every rule about the peer's half — a degenerate point, a non-canonical
9940
+ // encoding, a reflection — and owns the WORDING. Substituting a code of our own here would
9941
+ // destroy the only explanation that exists at the only moment anyone reads it.
9942
+ this.#logger.error("session.key.refused", {
9943
+ agentName, sessionId, correlationId, reason: "derivation_failed",
9944
+ detail: err instanceof Error ? err.message : String(err),
9945
+ });
9946
+ await this.#freezeSessionForKeyRefusal(agentName, sessionId, "derivation_failed", correlationId);
9947
+ }
9948
+ }
9949
+ /**
9950
+ * Stop a session whose counterparty's key could not be tied to them.
9951
+ *
9952
+ * Reuses the identity-freeze machinery rather than inventing a second way for a session to stop:
9953
+ * the operator-facing shape, the refusal-to-revive, and the status write are already right there,
9954
+ * and a second mechanism is a second thing to keep correct.
8524
9955
  */
9956
+ async #freezeSessionForKeyRefusal(agentName, sessionId, reason, correlationId) {
9957
+ /**
9958
+ * THE REASON IS RECORDED BEFORE THE TEARDOWN, AND SURVIVES IT — review F2.
9959
+ *
9960
+ * The teardown destroys this session's key material and used to clear the reason with it, so the
9961
+ * listing recomputed `NOT_YET_AGREED` and the agent was told *"still agreeing its key, sending is
9962
+ * held"* — a reassurance, for the one detection in this unit that means someone may be
9963
+ * substituting keys on the connection. Its only real consumer was a log line.
9964
+ */
9965
+ this.#noteContentEncryptionReason(agentName, sessionId, CONTENT_ENCRYPTION_REASONS.KEY_REFUSED);
9966
+ /**
9967
+ * ⚠️ `"key_refused"`, NOT `"stream_close"` — review F3, and this is error substitution of the
9968
+ * exact kind Invariant 3 names. `stream_close` is written to the row as
9969
+ * `interrupted_by = 'relay_stream_close'`, so a key-authentication refusal was durably recorded
9970
+ * as a relay problem and an operator debugging it would go and look at the relay fleet.
9971
+ */
9972
+ const stopped = await this.markInterruptedWithDetails(agentName, sessionId, 0, "key_refused");
9973
+ /**
9974
+ * OBSERVE THE OUTCOME rather than asserting it — review F11. `markInterruptedWithDetails`
9975
+ * returns early when the row is not `active`, so claiming "the session was stopped" here
9976
+ * unconditionally would state something that did not happen.
9977
+ */
9978
+ this.#logger.error("session.key.session_stopped", {
9979
+ agentName, sessionId, correlationId, reason,
9980
+ stopped,
9981
+ impact: stopped
9982
+ ? "the session was stopped rather than continued unencrypted; a substituted key would otherwise have been handed exactly the plaintext it was reaching for"
9983
+ : "the session was already not active, so nothing was torn down here — the refusal stands and no content was accepted",
9984
+ guidance: CONTENT_ENCRYPTION_GUIDANCE[CONTENT_ENCRYPTION_REASONS.KEY_REFUSED],
9985
+ });
9986
+ }
8525
9987
  /**
8526
- * Test seam: run the auto-acknowledge gate, exactly as the counterparty's SEAL ctrl leaf does.
9988
+ * Test seam: see every decoded inbound content frame, as it arrived.
8527
9989
  *
8528
- * `DOD-M15-SEALWIRE-1` part B1, review F-B. The gate has ONE production call site inside the
8529
- * relay leaf handler, behind `leaf_kind === CTRL && !authored_by_us` so reaching it from a test
8530
- * needs a live relay client delivering a real ctrl leaf. The consequence was measured: my
8531
- * "tampered never downgrades" test wrapped its decisive assertion in
8532
- * `if (skipped.length > 0)`, which was ALWAYS FALSE, so the whole `content_tamper` vs
8533
- * `content_verification_unavailable` branch had no coverage anywhere in the repo and two mutants
8534
- * on it survived the full gate.
9990
+ * Review F4. The "bytes on the wire are ciphertext" claim needs the ACTUAL frame; asserting on a
9991
+ * freshly sealed stand-in tests the crypto primitive and stays green when the send path is
9992
+ * reverted to putting plaintext on the wire. There is no other way to reach the decoded frame from
9993
+ * outside the handler consumes it and hands ingest the plaintext.
8535
9994
  *
8536
- * It calls the REAL private method rather than reproducing its logic, so a test cannot pass
8537
- * against a decision production does not make.
9995
+ * Read-only by construction: the callback receives the frame and cannot influence routing.
8538
9996
  */
8539
- runAutoAcknowledgeGateForTest(agentName, sessionId, correlationId = "test") {
8540
- this.#maybeAutoAcknowledgeSeal(agentName, sessionId, correlationId);
9997
+ observeInboundContentFramesForTest(cb) {
9998
+ this.#inboundFrameObserver = cb;
8541
9999
  }
8542
- setSaltContributionForTest(agentName, sessionId, contribution) {
8543
- this.#saltContributions.set(this.#k(agentName, sessionId), contribution);
10000
+ /** Injected by the daemon once its per-agent key providers exist. See `#keyProviderResolver`. */
10001
+ setKeyProviderResolver(resolver) {
10002
+ this.#keyProviderResolver = resolver;
8544
10003
  }
8545
10004
  /**
8546
- * Test seam: drop this session's own half while leaving the stored salt in place — the state every
8547
- * teardown produces, because `#evictSessionCaches` clears the map and the row survives.
10005
+ * The agreed content key for a session, or `null` with the reason there is none.
8548
10006
  *
8549
- * It clears the SAME map the eviction clears rather than a stand-in, so a test cannot pass against
8550
- * a state the daemon never reaches. Reproducing it through a real teardown/revive would also drag
8551
- * in node rebuild and relay reconnection, none of which this is about.
10007
+ * ONE place decides this, so the send path, the receive path and the status surface cannot
10008
+ * disagree about whether a session is encrypted the failure `wire-content-hash.ts` already
10009
+ * records for the hash, where the expression was written out at five call sites and the two added
10010
+ * last got it wrong.
8552
10011
  */
8553
- forgetSaltContributionForTest(agentName, sessionId) {
8554
- this.#saltContributions.delete(this.#k(agentName, sessionId));
10012
+ #contentEncryptionState(agentName, sessionId) {
10013
+ const k = this.#k(agentName, sessionId);
10014
+ const agreed = this.#sessionContentKeys.get(k);
10015
+ if (agreed)
10016
+ return { key: agreed };
10017
+ // No recorded fault means the exchange simply has not finished yet — the ordinary state in the
10018
+ // instant between a session opening and its first connect completing.
10019
+ return { key: null, reason: this.#contentEncryptionReasons.get(k) ?? CONTENT_ENCRYPTION_REASONS.NOT_YET_AGREED };
10020
+ }
10021
+ #noteContentEncryptionReason(agentName, sessionId, reason) {
10022
+ const k = this.#k(agentName, sessionId);
10023
+ // FIRST reason wins. A later, vaguer one must not overwrite the specific cause already recorded
10024
+ // — "they never answered" written over "we could not sign" points the operator at the wrong
10025
+ // machine, which is the substitution this closed set exists to end.
10026
+ if (!this.#contentEncryptionReasons.has(k))
10027
+ this.#contentEncryptionReasons.set(k, reason);
10028
+ }
10029
+ /**
10030
+ * ANNOUNCE THIS SIDE'S SIGNED EPHEMERAL — `DOD-M15-EPHEMERAL-AUTH-1`.
10031
+ *
10032
+ * 🚨 ON THE PEER-TO-PEER CONTENT STREAM ONLY, exactly like the salt contribution and for the same
10033
+ * unrepairable reason: it rides circuit-relay-v2 carrying its own Noise session, so a forwarding
10034
+ * relay sees ciphertext. It must NEVER be added to `session_offer` / `session_offer_accept` or
10035
+ * anything a DIRECTORY brokers — and that is the trap, because the only round trip at session open
10036
+ * is the directory's signaling stream.
10037
+ *
10038
+ * Fire-and-forget on the connect handler, like the salt: a failed announcement must not turn a
10039
+ * peer-connect handler into a rejected promise, and we re-announce on the next connect.
10040
+ */
10041
+ async #sendEphemeralFrame(agentName, sessionId, correlationId) {
10042
+ const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
10043
+ if (!entry)
10044
+ return;
10045
+ const ephemeral = this.#sessionEphemeralFor(agentName, sessionId);
10046
+ if (!ephemeral) {
10047
+ this.#logger.error("session.key.announce.failed", {
10048
+ agentName, sessionId, correlationId, reason: "no_ephemeral",
10049
+ impact: "this session is active with no throwaway keypair, so there is nothing to announce; content stays unencrypted by CELLO",
10050
+ });
10051
+ this.#noteContentEncryptionReason(agentName, sessionId, CONTENT_ENCRYPTION_REASONS.OUR_ANNOUNCE_FAILED);
10052
+ return;
10053
+ }
10054
+ const signer = this.#keyProviderResolver?.(agentName);
10055
+ if (!signer) {
10056
+ /**
10057
+ * A LOCAL fault, and it is named as one. Without this branch the peer simply never hears from
10058
+ * us and blames a build that is fine — the operator whose machine cannot sign reads a message
10059
+ * about their counterparty, which is the exact substitution the salt work already paid for.
10060
+ */
10061
+ this.#logger.error("session.key.announce.failed", {
10062
+ agentName, sessionId, correlationId, reason: "no_identity_key",
10063
+ 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",
10064
+ guidance: CONTENT_ENCRYPTION_GUIDANCE[CONTENT_ENCRYPTION_REASONS.NO_LOCAL_IDENTITY],
10065
+ });
10066
+ this.#noteContentEncryptionReason(agentName, sessionId, CONTENT_ENCRYPTION_REASONS.NO_LOCAL_IDENTITY);
10067
+ return;
10068
+ }
10069
+ let stream = null;
10070
+ try {
10071
+ const sessionIdBytes = Buffer.from(sessionId, "hex");
10072
+ const signature = await signSessionEphemeral(signer, sessionIdBytes, ephemeral.publicKey);
10073
+ stream = await entry.node.newStream(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
10074
+ stream.send(lp.encode.single(encodeCbor({
10075
+ type: "session_key_agreement",
10076
+ session_id: sessionId,
10077
+ ephemeral_public: ephemeral.publicKey,
10078
+ ephemeral_sig: signature,
10079
+ })).subarray());
10080
+ await stream.close();
10081
+ this.#logger.debug("session.key.announced", {
10082
+ agentName, sessionId, correlationId,
10083
+ publicKeyPrefix: Buffer.from(ephemeral.publicKey.subarray(0, 8)).toString("hex"),
10084
+ });
10085
+ }
10086
+ catch (err) {
10087
+ // The frame never left. Say so as a LOCAL fault rather than letting the session look like a
10088
+ // counterparty on an old build — a re-announce rides the next connect.
10089
+ this.#logger.error("session.key.announce.failed", {
10090
+ agentName, sessionId, correlationId, reason: "stream_failed",
10091
+ error: err instanceof Error ? err.message : String(err),
10092
+ impact: "this side's half of the session key never reached the counterparty, so content stays unencrypted by CELLO until a later connect succeeds",
10093
+ });
10094
+ this.#noteContentEncryptionReason(agentName, sessionId, CONTENT_ENCRYPTION_REASONS.OUR_ANNOUNCE_FAILED);
10095
+ /**
10096
+ * ABORT, don't just close — review F13. A `close()` on a broken stream can itself fail and
10097
+ * leave the slot held, which is the per-protocol stream-cap failure this file's longest
10098
+ * comment documents. Every other failed write on this protocol aborts.
10099
+ */
10100
+ if (stream) {
10101
+ try {
10102
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
10103
+ }
10104
+ catch { /* already gone */ }
10105
+ }
10106
+ /**
10107
+ * AND RETRY — review F5, and without it a single failed stream open kills encryption for the
10108
+ * life of the session.
10109
+ *
10110
+ * The announce otherwise rides `onPeerConnect` only. If the connection then stays up there is
10111
+ * no next connect, so nothing re-announces, every send parks forever, and the guidance's
10112
+ * "it re-announces on the next connect" names an event that never arrives. It also compounds
10113
+ * the re-key path: a revived session announces its FRESH half exactly once, and if that one
10114
+ * attempt loses the race with the reconnect, the two ends sit on different keys.
10115
+ *
10116
+ * Bounded and self-cancelling: it stops when the session is no longer active, when a key has
10117
+ * been agreed, and after `SESSION_KEY_ANNOUNCE_RETRIES` attempts.
10118
+ */
10119
+ this.#retryEphemeralAnnounce(agentName, sessionId, correlationId, 1);
10120
+ }
10121
+ }
10122
+ /**
10123
+ * Re-announce this side's ephemeral after a failed attempt — review F5.
10124
+ *
10125
+ * Backs off, and gives up rather than looping: a peer that is simply gone must not have a timer
10126
+ * chasing it for the life of the process.
10127
+ */
10128
+ #retryEphemeralAnnounce(agentName, sessionId, correlationId, attempt) {
10129
+ if (attempt > SESSION_KEY_ANNOUNCE_RETRIES) {
10130
+ this.#logger.warn("session.key.announce.gave_up", {
10131
+ agentName, sessionId, correlationId, attempts: SESSION_KEY_ANNOUNCE_RETRIES,
10132
+ 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",
10133
+ guidance: CONTENT_ENCRYPTION_GUIDANCE[CONTENT_ENCRYPTION_REASONS.OUR_ANNOUNCE_FAILED],
10134
+ });
10135
+ return;
10136
+ }
10137
+ const timer = setTimeout(() => {
10138
+ // Stop if the session went away, or if a key has since been agreed by any route.
10139
+ if (!this.#activeNodes.has(this.#k(agentName, sessionId)))
10140
+ return;
10141
+ if (this.#sessionContentKeys.has(this.#k(agentName, sessionId)))
10142
+ return;
10143
+ void this.#sendEphemeralFrame(agentName, sessionId, correlationId);
10144
+ }, SESSION_KEY_ANNOUNCE_RETRY_MS * attempt);
10145
+ // Never hold the process open for a retry.
10146
+ if (typeof timer.unref === "function")
10147
+ timer.unref();
10148
+ }
10149
+ /**
10150
+ * Our throwaway keypair for a session, WITHOUT minting one — the read-only counterpart.
10151
+ *
10152
+ * `null` means the session is not active here. It never means "mint one now": minting outside
10153
+ * `#mintSessionEphemeral` is how a second keypair appears mid-session.
10154
+ */
10155
+ #sessionEphemeralFor(agentName, sessionId) {
10156
+ return this.#sessionEphemerals.get(this.#k(agentName, sessionId)) ?? null;
10157
+ }
10158
+ #saltContributionFor(agentName, sessionId) {
10159
+ const key = this.#k(agentName, sessionId);
10160
+ let contribution = this.#saltContributions.get(key);
10161
+ if (!contribution) {
10162
+ contribution = generateSaltContribution();
10163
+ this.#saltContributions.set(key, contribution);
10164
+ }
10165
+ return contribution;
10166
+ }
10167
+ /**
10168
+ * Our half for a session, **without minting one** — review F1, and the distinction is the whole
10169
+ * safety of the repair.
10170
+ *
10171
+ * A session that already holds a salt must never mint a fresh half. If it did, the repair would
10172
+ * offer the peer a half the stored salt was NOT derived from, they would compute a different salt,
10173
+ * and both sides would believe they had agreed — silently, which is the one outcome worse than
10174
+ * refusing. So `null` from here means exactly "we hold a salt and the half behind it is gone",
10175
+ * and that is the only state the agreement is allowed to call unrepairable.
10176
+ */
10177
+ #ownSaltHalf(agentName, sessionId) {
10178
+ return this.#saltContributions.get(this.#k(agentName, sessionId)) ?? null;
10179
+ }
10180
+ /**
10181
+ * Test seam: force this session's own salt half, so the LOCAL-defect path is reachable.
10182
+ *
10183
+ * `generateSaltContribution` cannot produce a degenerate half, which is the point of it — so the
10184
+ * only way to exercise "our own random source is broken" end-to-end is to stand in for the broken
10185
+ * source. Named `…ForTest` like every other seam in this file, and it writes the same map
10186
+ * production writes rather than a parallel one, so a test cannot pass against state the daemon
10187
+ * never reads.
10188
+ */
10189
+ /**
10190
+ * Test seam: run the auto-acknowledge gate, exactly as the counterparty's SEAL ctrl leaf does.
10191
+ *
10192
+ * `DOD-M15-SEALWIRE-1` part B1, review F-B. The gate has ONE production call site — inside the
10193
+ * relay leaf handler, behind `leaf_kind === CTRL && !authored_by_us` — so reaching it from a test
10194
+ * needs a live relay client delivering a real ctrl leaf. The consequence was measured: my
10195
+ * "tampered never downgrades" test wrapped its decisive assertion in
10196
+ * `if (skipped.length > 0)`, which was ALWAYS FALSE, so the whole `content_tamper` vs
10197
+ * `content_verification_unavailable` branch had no coverage anywhere in the repo and two mutants
10198
+ * on it survived the full gate.
10199
+ *
10200
+ * It calls the REAL private method rather than reproducing its logic, so a test cannot pass
10201
+ * against a decision production does not make.
10202
+ */
10203
+ runAutoAcknowledgeGateForTest(agentName, sessionId, correlationId = "test") {
10204
+ this.#maybeAutoAcknowledgeSeal(agentName, sessionId, correlationId);
10205
+ }
10206
+ setSaltContributionForTest(agentName, sessionId, contribution) {
10207
+ this.#saltContributions.set(this.#k(agentName, sessionId), contribution);
10208
+ }
10209
+ /**
10210
+ * Test seams: re-enter the mint path, and read back the PUBLIC half — 006-CRYPTO.
10211
+ *
10212
+ * `#mintSessionEphemeral` is idempotent because a reconnect can re-enter an activation path, and a
10213
+ * second keypair mid-session would leave the two sides deriving against a moving value. Proving
10214
+ * that needs the path called TWICE, and the alternative — driving a real reconnect — drags in node
10215
+ * rebuild and relay reconnection, none of which the property is about. Same justification as
10216
+ * `forgetSaltContributionForTest` above.
10217
+ *
10218
+ * It calls the REAL private method, so a test cannot pass against a decision production does not
10219
+ * make. The reader returns the public half ONLY: a seam that could hand out the secret is a way
10220
+ * for the secret to leave this object, which is the one thing the whole unit is about.
10221
+ */
10222
+ mintSessionEphemeralForTest(agentName, sessionId) {
10223
+ this.#mintSessionEphemeral(agentName, sessionId);
10224
+ }
10225
+ sessionEphemeralPublicForTest(agentName, sessionId) {
10226
+ const e = this.#sessionEphemeralFor(agentName, sessionId);
10227
+ return e ? Uint8Array.from(e.publicKey) : null;
10228
+ }
10229
+ /**
10230
+ * Test seam: INSTALL a keypair the caller already holds — the only way to prove ZEROING.
10231
+ *
10232
+ * Presence is easy to assert and is not the property. `destroySessionEphemeral` overwrites the
10233
+ * buffer before the entry is dropped, and a mutant that drops without overwriting leaves the
10234
+ * secret wherever the collector last moved it while passing every presence check — which is
10235
+ * exactly what happened: the shutdown zeroing shipped with a surviving mutant, and the transport
10236
+ * seeds four lines above it have the same untested gap today.
10237
+ *
10238
+ * The direction matters. Nothing here HANDS OUT a secret — the test supplies an object it already
10239
+ * owns and then inspects its own reference. A reader that returned the live keypair would be a
10240
+ * path for the secret to leave this object, which is the one thing this unit exists to prevent.
10241
+ */
10242
+ setSessionEphemeralForTest(agentName, sessionId, ephemeral) {
10243
+ this.#sessionEphemerals.set(this.#k(agentName, sessionId), ephemeral);
10244
+ }
10245
+ /**
10246
+ * Test seam: put a session into the state a COMPLETED exchange leaves it in — 007-CRYPTO.
10247
+ *
10248
+ * A live send now requires an agreed key, because there is no plaintext path to fall back to. In
10249
+ * production the exchange completes on connect, before any send. A fixture with no real peer never
10250
+ * completes it, so without this every content test in the repo would be exercising the refusal
10251
+ * path instead of the thing it was written for.
10252
+ *
10253
+ * ⚠️ IT SHORT-CIRCUITS HOW THE KEY GOT THERE, NEVER WHAT THE KEY IS FOR. The state it produces —
10254
+ * a session holding an agreed content key — is exactly the production state, which is what makes
10255
+ * it legitimate; `setSaltContributionForTest` exists for the same reason. Tests of the EXCHANGE
10256
+ * itself drive the real signed frames and must not use this.
10257
+ */
10258
+ setSessionContentKeyForTest(agentName, sessionId, key) {
10259
+ this.#sessionContentKeys.set(this.#k(agentName, sessionId), Uint8Array.from(key));
10260
+ this.#contentEncryptionReasons.delete(this.#k(agentName, sessionId));
10261
+ }
10262
+ /**
10263
+ * Test seam: drop the agreed key while leaving the session up — the state before an exchange
10264
+ * completes, and after a teardown evicts one. Its mirror above is what a completed exchange
10265
+ * leaves; both are needed, or a status field stuck in one position passes either test alone.
10266
+ */
10267
+ forgetSessionContentKeyForTest(agentName, sessionId) {
10268
+ this.#sessionContentKeys.delete(this.#k(agentName, sessionId));
10269
+ }
10270
+ /**
10271
+ * Test seam: produce THIS side's signed ephemeral, using the manager's own identity resolver.
10272
+ *
10273
+ * For harnesses whose connectivity is one-directional — one side dials, so only one announce ever
10274
+ * lands. Carrying the other side's half across with a REAL signature is what completes the
10275
+ * exchange, and it beats seeding a key: a seeded key has no peer half recorded against it, so the
10276
+ * first genuine announce replaces it and the two ends drift apart (which is correct behaviour —
10277
+ * see the re-key guard — and exactly what made seeding fragile here).
10278
+ *
10279
+ * It signs with the same provider production signs with, so a test cannot pass against a signature
10280
+ * production would have refused.
10281
+ */
10282
+ async signOwnEphemeralForTest(agentName, sessionId) {
10283
+ const eph = this.#sessionEphemeralFor(agentName, sessionId);
10284
+ const signer = this.#keyProviderResolver?.(agentName);
10285
+ if (!eph || !signer)
10286
+ return null;
10287
+ const signature = await signSessionEphemeral(signer, Buffer.from(sessionId, "hex"), eph.publicKey);
10288
+ return { ephemeralPublic: Uint8Array.from(eph.publicKey), signature };
10289
+ }
10290
+ /**
10291
+ * Test seam: deliver a peer's signed ephemeral, exactly as the content-stream decoder does.
10292
+ *
10293
+ * For harnesses whose connectivity is one-directional — one side dials, so only one announce ever
10294
+ * lands — this is what completes the exchange instead of stuffing a key in. It runs the REAL
10295
+ * verification and the REAL derivation, so a test cannot pass against a signature production would
10296
+ * have refused.
10297
+ */
10298
+ async handleEphemeralFrameForTest(agentName, sessionId, frame, correlationId = "test") {
10299
+ await this.#handleEphemeralFrame(agentName, sessionId, frame, correlationId);
10300
+ }
10301
+ /**
10302
+ * Test seam: deliver an inbound salt frame, exactly as the content-stream decoder does.
10303
+ *
10304
+ * 006-CRYPTO finding 2. WHICH of the four reasons the peer gave decides what the operator is told,
10305
+ * and reaching that decision from a test otherwise needs a second live daemon that has closed
10306
+ * adoption for a specific reason — which is not something a counterparty can be asked to do on
10307
+ * demand. The four labels are the whole point of the finding, so they need to be reachable.
10308
+ *
10309
+ * It calls the REAL private handler rather than reproducing its routing, so a test cannot pass
10310
+ * against a decision production does not make. It takes the DECODED frame, so it deliberately
10311
+ * does NOT stand in for the decoder above it — the length and vocabulary checks there have their
10312
+ * own tests driving `handleContentFrameForTest`.
10313
+ */
10314
+ async handleSaltFrameForTest(agentName, sessionId, frame, correlationId = "test") {
10315
+ await this.#handleSaltFrame(agentName, sessionId, frame, correlationId);
10316
+ }
10317
+ /**
10318
+ * Test seam: drop this session's own half while leaving the stored salt in place — the state every
10319
+ * teardown produces, because `#evictSessionCaches` clears the map and the row survives.
10320
+ *
10321
+ * It clears the SAME map the eviction clears rather than a stand-in, so a test cannot pass against
10322
+ * a state the daemon never reaches. Reproducing it through a real teardown/revive would also drag
10323
+ * in node rebuild and relay reconnection, none of which this is about.
10324
+ */
10325
+ forgetSaltContributionForTest(agentName, sessionId) {
10326
+ this.#saltContributions.delete(this.#k(agentName, sessionId));
8555
10327
  }
8556
10328
  /**
8557
10329
  * The pair the agreement reasons over: our salt, and the half that goes with it.
@@ -8599,6 +10371,26 @@ export class SessionNodeManager {
8599
10371
  async contentHashForSession(agentName, sessionId, content) {
8600
10372
  const { salt, reason } = await this.#saltForHashing(agentName, sessionId);
8601
10373
  if (salt !== null) {
10374
+ /**
10375
+ * ⚠️ THE SALTED HASH MARKS ITSELF SPENT — `DOD-M15-SALTSPLIT-1` review pass 1, HIGH-2.
10376
+ *
10377
+ * The unsalted branch below has counted itself since review pass 2 F1, for a reason stated
10378
+ * there in full: between hashing and `#trackAwaitingAck` there is a relay round trip, and in
10379
+ * that window leaves, held content and awaiting-ack ALL read zero. **The salted direction was
10380
+ * left with no counterpart**, which was harmless while nothing acted on the answer — and
10381
+ * `#discardUnspentSalt` is the first code that acts on it destructively.
10382
+ *
10383
+ * Without this, a peer's `adoption_closed` frame arriving inside that window finds adoption
10384
+ * "open", discards the salt, and the message already on the wire carries
10385
+ * `content_hash_alg: hmac-salt-v1` with a hash **nobody — including this daemon — can ever
10386
+ * recompute**. The alg is copied verbatim into the parked envelope on TTF expiry, so it
10387
+ * survives the round trip that would otherwise have hidden it.
10388
+ *
10389
+ * A COUNT, not a bit, for the same reason the unsalted side is a count: two connections can be
10390
+ * mid-send at once, and one finishing must not clear the claim the other is still relying on.
10391
+ */
10392
+ const key = this.#k(agentName, sessionId);
10393
+ this.#hashedWithSalt.set(key, (this.#hashedWithSalt.get(key) ?? 0) + 1);
8602
10394
  const alg = CONTENT_HASH_ALGS.HMAC_SALT_V1;
8603
10395
  return { hash: contentHashFor(content, { alg, salt }), alg };
8604
10396
  }
@@ -8625,6 +10417,58 @@ export class SessionNodeManager {
8625
10417
  * run, and the entries then outlive the session they describe. There is also nothing to protect:
8626
10418
  * a session that no longer exists cannot adopt a salt or split a transcript.
8627
10419
  */
10420
+ /**
10421
+ * ⚠️ THE DEFERRED ERASE — `DOD-M15-SALTSPLIT-1`. This is the moment a suspended salt becomes both
10422
+ * harmless to erase and NECESSARY to erase, and it must run BEFORE the count below.
10423
+ *
10424
+ * Harmless: this session has hashed nothing under the salt, which is what let it be suspended.
10425
+ * Necessary: we are about to hash unsalted, and a salt left on disk reads back fine after a
10426
+ * restart — so the next process would hash salted and the transcript would be split down the
10427
+ * middle by a reboot rather than by any frame.
10428
+ *
10429
+ * **Before the `#hashedWithoutSalt` increment on purpose.** `#discardUnspentSalt` refuses to erase
10430
+ * once adoption is closed, and that counter is one of the things that closes it — increment first
10431
+ * and the erase we just decided is correct gets refused by our own guard, leaving exactly the
10432
+ * split this ordering exists to prevent.
10433
+ */
10434
+ if (reason !== UNSALTED_REASONS.SESSION_TORN_DOWN && this.#saltSuspended.has(this.#k(agentName, sessionId))) {
10435
+ /**
10436
+ * ⚠️ GOING UNSALTED AND ERASING THE SALT ARE ONE DECISION — pass 2, F2 (HIGH), and this is my
10437
+ * regression, not a pre-existing one.
10438
+ *
10439
+ * The note above claimed the ordering was sufficient because `#hashedWithoutSalt` is what
10440
+ * closes adoption. **It is one of FOUR contributors.** Leaves, held rows and awaiting-ack close
10441
+ * it too — and the most ordinary event in the protocol closes it: *the peer sends us its next
10442
+ * message.* Reproduced through the real inbound path: suspend, peer's message lands as leaf 0,
10443
+ * we hash `sha256`, and the erase is REFUSED with `already_hashing` while the bytes stay on
10444
+ * disk. One teardown-and-revive later — no process restart required — we hash `hmac` again.
10445
+ * That is the split transcript, produced by the fix for the split transcript.
10446
+ *
10447
+ * Worth naming precisely: **the immediate-erase design this replaced could NOT produce it.**
10448
+ * There, a refused discard simply kept the session salted — one rule throughout, and loud.
10449
+ * Suspension is what made "unsalted now, salted later" reachable. Same shape as pass 1: the fix
10450
+ * worse than the defect on one path.
10451
+ *
10452
+ * So the two are atomic. If the salt cannot be erased, we do **not** go unsalted — we keep
10453
+ * hashing under the held salt, which is one rule for the whole session, and say so at ERROR.
10454
+ * The counterparty may refuse those messages, and that is the honest failure: a dead session
10455
+ * beats a transcript no single rule can verify. The durable column remains the real answer.
10456
+ */
10457
+ if (!this.#discardUnspentSalt(agentName, sessionId)) {
10458
+ const stillHeld = this.#getSessionSalt(agentName, sessionId);
10459
+ if (stillHeld !== null) {
10460
+ const key = this.#k(agentName, sessionId);
10461
+ this.#logger.error("session.salt.split", {
10462
+ agentName, sessionId, reason: "suspended_but_unerasable",
10463
+ 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.",
10464
+ 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.",
10465
+ });
10466
+ this.#hashedWithSalt.set(key, (this.#hashedWithSalt.get(key) ?? 0) + 1);
10467
+ const alg = CONTENT_HASH_ALGS.HMAC_SALT_V1;
10468
+ return { hash: contentHashFor(content, { alg, salt: stillHeld }), alg };
10469
+ }
10470
+ }
10471
+ }
8628
10472
  if (reason !== UNSALTED_REASONS.SESSION_TORN_DOWN) {
8629
10473
  /**
8630
10474
  * ⚠️ A COUNT, NOT A BIT — review pass 2, F1 (HIGH). It was a `Set`, and that made it ONE FLAG
@@ -8675,10 +10519,25 @@ export class SessionNodeManager {
8675
10519
  * bound expires.
8676
10520
  */
8677
10521
  async #saltForHashing(agentName, sessionId) {
10522
+ const key = this.#k(agentName, sessionId);
8678
10523
  const held = this.#getSessionSalt(agentName, sessionId);
8679
- if (held !== null)
10524
+ if (held !== null) {
10525
+ /**
10526
+ * SUSPENDED BEATS HELD — `DOD-M15-SALTSPLIT-1`. The peer has said it can never hold a salt, so
10527
+ * hashing under ours produces a message it must refuse. We hold one and deliberately do not
10528
+ * use it.
10529
+ *
10530
+ * ⚠️ An earlier note here said `PEER_CLOSED_ADOPTION` "already carries exactly the right
10531
+ * guidance, so no new reason is needed and none is invented." That was right about not
10532
+ * inventing a reason and wrong about which one applies: the peer can suspend us for any of
10533
+ * four reasons, and the one hardcoded here asserted the most flattering of them. It now asks
10534
+ * the same mapping every other closed path asks (006-CRYPTO finding 2).
10535
+ */
10536
+ if (this.#saltSuspended.has(key)) {
10537
+ return { salt: null, reason: this.#peerClosedReason(key) };
10538
+ }
8680
10539
  return { salt: held };
8681
- const key = this.#k(agentName, sessionId);
10540
+ }
8682
10541
  if (this.#saltAdoptionClosed(agentName, sessionId).closed) {
8683
10542
  return { salt: null, reason: UNSALTED_REASONS.ADOPTION_CLOSED_LOCALLY };
8684
10543
  }
@@ -8688,7 +10547,7 @@ export class SessionNodeManager {
8688
10547
  // "your counterparty was not connected", and only an absent entry means it.
8689
10548
  const last = this.#saltLastOutcome.get(key);
8690
10549
  if (last !== undefined)
8691
- return { salt: null, reason: this.#reasonForOutcome(last) };
10550
+ return { salt: null, reason: this.#reasonForOutcome(key, last) };
8692
10551
  return { salt: null, reason: UNSALTED_REASONS.NO_AGREEMENT_STARTED };
8693
10552
  }
8694
10553
  const settled = await pending.settled;
@@ -8795,15 +10654,41 @@ export class SessionNodeManager {
8795
10654
  * ONE mapping from a settled outcome to the operator-facing reason, so the send that WAITED and the
8796
10655
  * send that arrived afterwards cannot disagree about what happened.
8797
10656
  */
8798
- #reasonForOutcome(outcome) {
10657
+ #reasonForOutcome(key, outcome) {
8799
10658
  if (outcome === "announce_failed")
8800
10659
  return UNSALTED_REASONS.ANNOUNCE_FAILED;
8801
10660
  if (outcome === "persist_failed")
8802
10661
  return UNSALTED_REASONS.OUR_PERSIST_FAILED;
8803
10662
  if (outcome === "closed")
8804
- return UNSALTED_REASONS.PEER_CLOSED_ADOPTION;
10663
+ return this.#peerClosedReason(key);
8805
10664
  return UNSALTED_REASONS.AGREEMENT_TIMED_OUT;
8806
10665
  }
10666
+ /**
10667
+ * WHICH of the four terminal answers the peer actually gave — 006-CRYPTO finding 2.
10668
+ *
10669
+ * The default is the NON-ASSERTING reason, not the most common one. An unknown label means a build
10670
+ * we do not understand, and rendering that as "they had already hashed messages" states something
10671
+ * about a counterparty that may be untrue — which is what sends an operator to raise a
10672
+ * non-problem with them. The label is peer-supplied, so nothing outside the known set is repeated
10673
+ * back as our own diagnosis.
10674
+ *
10675
+ * A missing entry maps to the already-hashing case: `PEER_CLOSED_FIRST` and an absent label both
10676
+ * mean the peer is answering a closure of OURS, and `#saltForHashing` answers that with
10677
+ * `ADOPTION_CLOSED_LOCALLY` one branch earlier — this is only the fallback if it did not.
10678
+ */
10679
+ #peerClosedReason(key) {
10680
+ const label = this.#saltPeerClosedLabel.get(key);
10681
+ if (label === undefined || label === SALT_ADOPTION_LABELS.PEER_CLOSED_FIRST) {
10682
+ return UNSALTED_REASONS.PEER_CLOSED_ADOPTION;
10683
+ }
10684
+ if (label === SALT_ADOPTION_LABELS.ALREADY_HASHING)
10685
+ return UNSALTED_REASONS.PEER_CLOSED_ADOPTION;
10686
+ if (label === SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE)
10687
+ return UNSALTED_REASONS.PEER_FRONTIER_UNREADABLE;
10688
+ if (label === SALT_ADOPTION_LABELS.EXCHANGE_STALLED)
10689
+ return UNSALTED_REASONS.PEER_EXCHANGE_STALLED;
10690
+ return UNSALTED_REASONS.PEER_CLOSED_UNSPECIFIED;
10691
+ }
8807
10692
  /** Resolve a pending agreement. Idempotent: the first outcome wins and the timer is cleared. */
8808
10693
  #settleSaltPending(agentName, sessionId, outcome) {
8809
10694
  const key = this.#k(agentName, sessionId);
@@ -8889,6 +10774,24 @@ export class SessionNodeManager {
8889
10774
  * Read-only and cache-backed, so exposing it adds no way to CHANGE the salt from outside — the
8890
10775
  * only writer remains `#persistSessionSalt`, behind the one-salt-per-session predicate.
8891
10776
  */
10777
+ /**
10778
+ * IS THIS SESSION ACTUALLY PROTECTED BY ITS SALT RIGHT NOW — pass 2, F3.
10779
+ *
10780
+ * Distinct from `getSessionContentSalt`, which is POSSESSION and is what the verifier needs: a
10781
+ * message parked before suspension was hashed under this salt and must still be checkable against
10782
+ * it, so that accessor must keep answering with the bytes.
10783
+ *
10784
+ * This one answers the OPERATOR's question, and it is a different question. A suspended session
10785
+ * holds a salt it will not use, so every hash it produces is `sha256` — reporting `contentSalted:
10786
+ * true` there is not a gap, it is an affirmatively false security claim on the surface whose own
10787
+ * comment reads *"a security property must not be inferable from a gap."* Same predicate
10788
+ * `#saltForHashing` uses, so the flag cannot drift from the behaviour it describes.
10789
+ */
10790
+ isContentSaltActive(agentName, sessionId) {
10791
+ if (this.#saltSuspended.has(this.#k(agentName, sessionId)))
10792
+ return false;
10793
+ return this.#getSessionSalt(agentName, sessionId) !== null;
10794
+ }
8892
10795
  getSessionContentSalt(agentName, sessionId) {
8893
10796
  return this.#getSessionSalt(agentName, sessionId);
8894
10797
  }
@@ -9129,6 +11032,214 @@ export class SessionNodeManager {
9129
11032
  return false;
9130
11033
  }
9131
11034
  }
11035
+ /**
11036
+ * DROP AN UNSPENT SALT — `DOD-M15-SALTSPLIT-1`. The second writer of `content_salt`, and the only
11037
+ * one that clears it.
11038
+ *
11039
+ * Reached when the counterparty tells us it can never adopt a salt for this session. Keeping ours
11040
+ * would mean every message we send from here is refused by them with
11041
+ * `content_hash_salt_unavailable` — a conversation that dies while looking merely quiet, which is
11042
+ * the failure this exists to prevent.
11043
+ *
11044
+ * ⚠️ THE ADOPTION CHECK IS REPEATED HERE ON PURPOSE, not because the caller is untrusted.
11045
+ *
11046
+ * The caller has already computed `adoption`, so this looks redundant — and it is, for today's one
11047
+ * call site. It stays because the cost of a future caller getting it wrong is a transcript that no
11048
+ * single rule can verify: leaves hashed under a salt that has just been erased, with nothing
11049
+ * recording that they were. A guard whose failure mode is silent and permanent belongs next to the
11050
+ * destructive act, not only at the place that currently decides to perform it. Same reasoning that
11051
+ * made `placeOwnLeaf`'s authorship parameter required rather than optional.
11052
+ *
11053
+ * Returns true only if a salt was actually cleared.
11054
+ */
11055
+ /**
11056
+ * SUSPEND, don't destroy — `DOD-M15-SALTSPLIT-1`, the authorization argument. Returns true if a
11057
+ * salt is now suspended (or already was).
11058
+ *
11059
+ * This is the frame handler's entry point. It runs the same two refusals as the erase below —
11060
+ * a spent salt and one mid-flight are not ours to set aside either, because the messages already
11061
+ * hashed under them would become unverifiable the moment we stop using it — and where they do not
11062
+ * fire it records the suspension instead of doing anything irreversible.
11063
+ */
11064
+ #suspendSalt(agentName, sessionId, correlationId) {
11065
+ const key = this.#k(agentName, sessionId);
11066
+ if (this.#getSessionSalt(agentName, sessionId) === null)
11067
+ return false;
11068
+ if (this.#saltSuspended.has(key))
11069
+ return true;
11070
+ const inFlight = this.#hashedWithSalt.get(key) ?? 0;
11071
+ const adoption = this.#saltAdoptionClosed(agentName, sessionId);
11072
+ if (inFlight > 0 || adoption.closed) {
11073
+ /**
11074
+ * SPENT, or mid-send. Suspending is not destructive, but it IS a split: content already hashed
11075
+ * under this salt stays hashed under it while everything after would be hashed the other way,
11076
+ * in one session, with nothing recording where the change happened. That is the one thing
11077
+ * Decision #8 forbids outright, so the salt keeps being used and the session stays honestly
11078
+ * broken rather than becoming dishonestly half-verifiable.
11079
+ */
11080
+ this.#logger.info("session.salt.suspend.refused", {
11081
+ agentName, sessionId, correlationId,
11082
+ reason: inFlight > 0 ? "salted_hash_in_flight" : adoption.label,
11083
+ ...(inFlight > 0 ? { inFlight } : { frontier: adoption.why }),
11084
+ 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.",
11085
+ });
11086
+ return false;
11087
+ }
11088
+ this.#saltSuspended.add(key);
11089
+ this.#logger.info("session.salt.suspended", {
11090
+ agentName, sessionId, correlationId,
11091
+ 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.",
11092
+ 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.",
11093
+ });
11094
+ return true;
11095
+ }
11096
+ /** Un-suspend: the peer answered with a fingerprint matching the salt we kept. */
11097
+ #resumeSalt(agentName, sessionId, correlationId) {
11098
+ const key = this.#k(agentName, sessionId);
11099
+ if (!this.#saltSuspended.has(key))
11100
+ return;
11101
+ /**
11102
+ * ⚠️ REFUSE THE RESUME IF THIS SESSION HAS ALREADY HASHED UNSALTED — pass 2, F1 (HIGH).
11103
+ *
11104
+ * `#resumeSalt` deleted the mark unconditionally, and the reviewer produced the counter-example
11105
+ * in ONE process with no restart: suspend, the peer keeps talking so a leaf lands, we send `m1`
11106
+ * under `sha256`, the peer's frontier recovers and announces `fingerprint(S)`, we resume, and
11107
+ * `m2` goes out under `hmac`. Two rules, one session — and `session.salt.resumed` asserted
11108
+ * *"No message was hashed while suspended, so the transcript is uniform"* while it was happening.
11109
+ * **The code never checked the thing its own log line claimed**, which is this milestone's
11110
+ * signature defect committed inside the fix for it.
11111
+ *
11112
+ * `#unsaltedAnnounced` is exactly that fact and is already maintained, so the check costs a
11113
+ * lookup. Once it is set the salt can never be used again, so it is erased here rather than left
11114
+ * to be found by a later restart.
11115
+ */
11116
+ if (this.#unsaltedAnnounced.has(key)) {
11117
+ this.#logger.warn("session.salt.resume.refused", {
11118
+ agentName, sessionId, correlationId,
11119
+ 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.",
11120
+ 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.",
11121
+ });
11122
+ this.#saltSuspended.delete(key);
11123
+ this.#discardUnspentSalt(agentName, sessionId, correlationId);
11124
+ return;
11125
+ }
11126
+ this.#saltSuspended.delete(key);
11127
+ /**
11128
+ * THE RECOVERY THE ERASE MADE IMPOSSIBLE. Keeping the bytes is what allows this line to exist:
11129
+ * the peer's earlier terminal frame was wrong (a frontier it could not read for a moment), it can
11130
+ * read again, and the fingerprints match — so the session resumes salted with nothing lost. An
11131
+ * erased salt cannot be re-derived from one side.
11132
+ */
11133
+ this.#logger.info("session.salt.resumed", {
11134
+ agentName, sessionId, correlationId,
11135
+ 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.",
11136
+ });
11137
+ }
11138
+ #discardUnspentSalt(agentName, sessionId, correlationId) {
11139
+ const held = this.#getSessionSalt(agentName, sessionId);
11140
+ if (held === null)
11141
+ return false;
11142
+ /**
11143
+ * ⚠️ IN-FLIGHT FIRST — `DOD-M15-SALTSPLIT-1` review HIGH-2. `#saltAdoptionClosed` cannot see a
11144
+ * hash that has been computed under the salt but has not yet become a leaf, a hold or an
11145
+ * awaiting-ack entry, and that gap is a full relay round trip wide.
11146
+ */
11147
+ /**
11148
+ * ⚠️ MEASURED UNREACHABLE FROM TODAY'S CALLERS, AND KEPT ANYWAY — pass 2 test-teeth, survivor 2.
11149
+ *
11150
+ * Deleting this block leaves the whole salt suite GREEN. That is the definition this unit has
11151
+ * used all along for *"not a guard, a comment that happens to execute"*, so it is labelled rather
11152
+ * than quietly left to look load-bearing. `#suspendSalt` refuses on `inFlight > 0` before a
11153
+ * session can ever be marked, and both callers of this method require the mark — so the deferred
11154
+ * erase cannot observe a non-zero count.
11155
+ *
11156
+ * It stays for one reason: **it sits at an irreversible write.** The earlier instance of this
11157
+ * question in this same unit was resolved by making the guard the actual decision-maker, and that
11158
+ * option does not exist here — `#suspendSalt` genuinely must refuse early, so the duplication is
11159
+ * structural rather than a mistake about where responsibility lives. For a destructive act, the
11160
+ * safe direction is to keep a check that cannot fire over removing one that turns out it could.
11161
+ *
11162
+ * What must NOT happen is claiming it as coverage. It is not tested and it is not testable from
11163
+ * outside; if a third caller ever reaches this method without the suspension mark, this becomes
11164
+ * reachable and needs a test in the same commit.
11165
+ */
11166
+ const inFlight = this.#hashedWithSalt.get(this.#k(agentName, sessionId)) ?? 0;
11167
+ if (inFlight > 0) {
11168
+ this.#logger.info("session.salt.discard.refused", {
11169
+ agentName, sessionId, correlationId, reason: "salted_hash_in_flight", inFlight,
11170
+ 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.",
11171
+ });
11172
+ return false;
11173
+ }
11174
+ const adoption = this.#saltAdoptionClosed(agentName, sessionId);
11175
+ if (adoption.closed) {
11176
+ /**
11177
+ * SPENT. Something is already hashed under this salt, so it is not ours to drop.
11178
+ *
11179
+ * INFO, not ERROR, and the level is a judgement rather than a downgrade: this is the guard
11180
+ * doing its job correctly, and the FAILURE it accompanies — the session is split and unusable
11181
+ * — is reported by `session.salt.split` at ERROR from the caller that has the operator-facing
11182
+ * detail. Two ERRORs for one condition trains people to read neither. This line stays so the
11183
+ * refusal itself is correlatable when someone asks why the salt is still on disk.
11184
+ */
11185
+ this.#logger.info("session.salt.discard.refused", {
11186
+ agentName, sessionId, correlationId, reason: adoption.label, frontier: adoption.why,
11187
+ 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.",
11188
+ });
11189
+ return false;
11190
+ }
11191
+ if (!this.#db) {
11192
+ this.#logger.error("session.salt.discard.failed", {
11193
+ agentName, sessionId, correlationId, reason: "db_closed",
11194
+ 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.",
11195
+ });
11196
+ return false;
11197
+ }
11198
+ try {
11199
+ const cleared = this.#db
11200
+ .prepare("UPDATE sessions SET content_salt = NULL WHERE agent_id = ? AND session_id = ?")
11201
+ .run(this.#requireAgentId(agentName), sessionId);
11202
+ if (Number(cleared.changes) !== 1) {
11203
+ // The row-count check that `#persistSessionSalt` learned the hard way: an UPDATE matching no
11204
+ // row does not throw, and reporting success here would leave the durable salt in place while
11205
+ // the cache said otherwise — salted after a restart, unsalted before one.
11206
+ this.#logger.error("session.salt.discard.failed", {
11207
+ agentName, sessionId, correlationId, changes: Number(cleared.changes), reason: "no_session_row",
11208
+ 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",
11209
+ });
11210
+ return false;
11211
+ }
11212
+ }
11213
+ catch (err) {
11214
+ this.#logger.error("session.salt.discard.failed", {
11215
+ agentName, sessionId, correlationId, error: extractErrorMessage(err),
11216
+ 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",
11217
+ });
11218
+ return false;
11219
+ }
11220
+ /**
11221
+ * CACHE AFTER ROW, and both or the session is worse off than before.
11222
+ *
11223
+ * `#saltForHashing` reads the cache on its first line and never consults the row, so clearing
11224
+ * one without the other produces a session that hashes one way in this process and the other way
11225
+ * in the next — the split transcript, arriving at a daemon restart instead of at a frame.
11226
+ */
11227
+ this.#sessionSalts.delete(this.#k(agentName, sessionId));
11228
+ /**
11229
+ * ⚠️ THE MARK GOES WITH THE BYTES — pass 2, F5. Leaving the key in `#saltSuspended` after a
11230
+ * successful erase means a LATER agreed salt is silently never used: `#persistSessionSalt`'s
11231
+ * predicate explicitly allows a write when the column is NULL, and `abandonUnsaltedHash` can
11232
+ * re-open adoption — so the session would log `session.salt.agreed`, surface as protected, and
11233
+ * hash `sha256` for the rest of its life. A stale suppression is indistinguishable from a
11234
+ * feature that does not work.
11235
+ */
11236
+ this.#saltSuspended.delete(this.#k(agentName, sessionId));
11237
+ this.#logger.info("session.salt.discarded", {
11238
+ agentName, sessionId, correlationId,
11239
+ 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.",
11240
+ });
11241
+ return true;
11242
+ }
9132
11243
  /**
9133
11244
  * Announce our state to the counterparty: a contribution if we hold no salt, a fingerprint if we
9134
11245
  * do. Called on every counterparty connect — first connection, reconnect and revival alike — and
@@ -9260,6 +11371,13 @@ export class SessionNodeManager {
9260
11371
  async #handleSaltFrame(agentName, sessionId, frame, correlationId) {
9261
11372
  const key = this.#k(agentName, sessionId);
9262
11373
  const peerHalfHex = frame.contribution ? Buffer.from(frame.contribution).toString("hex") : null;
11374
+ const peerFingerprintHex = frame.fingerprint ? Buffer.from(frame.fingerprint).toString("hex") : null;
11375
+ // WHY the peer closed, kept for the operator-facing reason — 006-CRYPTO finding 2. Recorded here
11376
+ // rather than in the `adoption_closed` handler because that action fires for OUR closure too,
11377
+ // and only the frame says what the PEER said.
11378
+ if (typeof frame.adoptionClosed === "string") {
11379
+ this.#saltPeerClosedLabel.set(key, frame.adoptionClosed);
11380
+ }
9263
11381
  const adoption = this.#saltAdoptionClosed(agentName, sessionId);
9264
11382
  const action = onPeerSaltFrame({
9265
11383
  ...this.#saltState(agentName, sessionId),
@@ -9272,9 +11390,15 @@ export class SessionNodeManager {
9272
11390
  // Keyed on the peer's BYTES, not on a repair counter: a genuinely NEW half from the peer must
9273
11391
  // still get our contribution back, and only an identical re-offer is the loop (review F14).
9274
11392
  alreadyRepairedAgainstPeerHalf: peerHalfHex !== null && this.#saltRepairedAgainst.get(key) === peerHalfHex,
11393
+ // The mirror, 006-CRYPTO finding 1: without it a saltless side answers a latched holder's
11394
+ // fingerprint forever. Same keying rule — an identical re-offer is the loop, a new one is not.
11395
+ alreadyRepairedAgainstPeerFingerprint: peerFingerprintHex !== null && this.#saltRepairedAgainstFingerprint.get(key) === peerFingerprintHex,
9275
11396
  frame,
9276
11397
  });
9277
11398
  if (action.action === "confirmed") {
11399
+ // DOD-M15-SALTSPLIT-1: the peer confirms the salt we KEPT while suspended — resume before logging
11400
+ // agreement, so a resumed session is never reported as agreed while still suspended.
11401
+ this.#resumeSalt(agentName, sessionId, correlationId);
9278
11402
  this.#logger.info("session.salt.agreed", {
9279
11403
  agentName, sessionId, correlationId, via: "fingerprint_match",
9280
11404
  });
@@ -9339,9 +11463,44 @@ export class SessionNodeManager {
9339
11463
  * is nothing for its operator to do. Logging that at WARN would fire on the innocent side of
9340
11464
  * every such session and train them to ignore the name.
9341
11465
  */
11466
+ /**
11467
+ * DOD-M15-SALTSPLIT-1 — ONE PLACE DECIDES WHETHER THE SALT GOES, and it is not here.
11468
+ *
11469
+ * ⚠️ THIS CALL WAS INSIDE THE `else` BELOW, AND THE REVERT TEST CAUGHT IT.
11470
+ *
11471
+ * Guarding it by `adoption.closed` here meant `#discardUnspentSalt`'s own adoption check could
11472
+ * never be reached, so deleting that check left all three tests GREEN — the survivor. A guard
11473
+ * nothing can redden is not a guard; it is a comment that happens to execute, which is the
11474
+ * shape this milestone keeps finding.
11475
+ *
11476
+ * Called unconditionally now. The method owns the spent/unspent decision, both outcomes run
11477
+ * through it, and deleting its check reddens the spent test immediately. That also removes the
11478
+ * duplicated condition: two places deciding the same thing is one place being wrong later.
11479
+ */
11480
+ /**
11481
+ * The return is CONSUMED, not decorative — review LOW-5. `true` means a salt was actually
11482
+ * cleared, which settles the question below without a second read; `false` is ambiguous (we
11483
+ * held none, or we refused to drop one), so that case still asks.
11484
+ */
11485
+ const suspended = this.#suspendSalt(agentName, sessionId, correlationId);
11486
+ /**
11487
+ * "Still holds a salt it is USING" — suspension is what settles it, not possession. A suspended
11488
+ * session keeps the bytes on disk deliberately, and reporting that as an unrecoverable split
11489
+ * would fire the ERROR below on the one case that recovers by itself.
11490
+ */
11491
+ const stillHoldsSalt = !suspended && this.#getSessionSalt(agentName, sessionId) !== null;
9342
11492
  const shared = {
9343
11493
  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.",
11494
+ /**
11495
+ * ⚠️ *"no message is affected"* IS FALSE WHEN WE ARE STILL HOLDING A SALT — review MEDIUM-4,
11496
+ * second instance. The sentence was written for a session where neither side ever adopted
11497
+ * one, and it stayed attached to a branch that now also covers the case where this side
11498
+ * kept a spent salt and every message it sends is about to be refused. Two log lines from
11499
+ * one event contradicting each other is worse than either alone.
11500
+ */
11501
+ impact: stillHoldsSalt
11502
+ ? "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."
11503
+ : "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
11504
  };
9346
11505
  if (adoption.closed) {
9347
11506
  /**
@@ -9366,8 +11525,67 @@ export class SessionNodeManager {
9366
11525
  });
9367
11526
  }
9368
11527
  else {
11528
+ /**
11529
+ * DOD-M15-SALTSPLIT-1 — CARRY OUT THE CLAIM ABOVE INSTEAD OF ONLY STATING IT.
11530
+ *
11531
+ * `shared.impact` says *"neither side will use a content salt for this session, and both now
11532
+ * know it."* Nothing made that true: a salt already agreed on this side stayed on disk and in
11533
+ * the cache, and `#saltForHashing` returns it before it ever looks at adoption. Our adoption
11534
+ * is still open here, so nothing has been hashed under it and dropping it is free.
11535
+ *
11536
+ * Ordering matters — discard BEFORE the log, so the line cannot claim an outcome that the
11537
+ * write then failed to produce.
11538
+ */
9369
11539
  this.#logger.info("session.salt.adoption.closed", shared);
9370
11540
  }
11541
+ /**
11542
+ * ⚠️ OUTSIDE THE ADOPTION BRANCH — pass 2, F4. This used to live inside `if (adoption.closed)`,
11543
+ * so the one case that needed it most never got it: suspension refused for
11544
+ * `salted_hash_in_flight` while adoption is still OPEN leaves us holding a salt the peer can
11545
+ * never accept, and it took the `else` path. Measured on that exact scenario:
11546
+ * `suspend.refused = 1`, `adoption.closed = 1`, **`split = 0`** — while two other log lines
11547
+ * told the operator to *"see session.salt.split on the next line"*, a line that was never
11548
+ * written. Guidance pointing at an event that does not fire is worse than no guidance: it
11549
+ * spends the reader's trust and their time.
11550
+ *
11551
+ * The condition was always `stillHoldsSalt`; only its placement disagreed.
11552
+ *
11553
+ * ─── What this event means, moved here with the code it describes ─────────────────────────
11554
+ *
11555
+ * We hold a salt AND the peer has told us it can never hold one. Either our frontier closed
11556
+ * with the salt already spent, or a salted hash is mid-flight — both mean the salt cannot be
11557
+ * released, so the peer will refuse every message we send with `content_hash_salt_unavailable`.
11558
+ *
11559
+ * `session.salt.adoption.refused` may fire alongside, saying *"nothing is degraded relative to
11560
+ * any shipped release, and no message is affected"* — true for the ordinary refusal and FALSE
11561
+ * here, at the exact moment every message stops being accepted. Hence its own event at ERROR
11562
+ * rather than a tightened sentence on that one: an operator filtering for the refusal is
11563
+ * looking at a benign condition, and this is not it.
11564
+ */
11565
+ if (stillHoldsSalt) {
11566
+ /**
11567
+ * ⚠️ TWO REASONS REACH `adoption.closed`, AND ONLY ONE IS ABOUT CONTENT — review MEDIUM-4.
11568
+ *
11569
+ * This fired for both with a single impact asserting *"content here is already hashed
11570
+ * under a salt"*. For `frontier_unreadable` that is a claim about content made from a
11571
+ * database read that FAILED — we do not know what was hashed; that is the whole condition.
11572
+ *
11573
+ * The WARN twenty lines above was explicitly corrected for this exact collapse — its
11574
+ * comment reads *"TWO REFUSALS, TWO DIFFERENT THINGS TO DO — and this used to report both
11575
+ * as `already_hashing`"* — and I reintroduced it one severity level up, with the guidance
11576
+ * that WARN was fixed to stop giving. Branching on the label the way it already does.
11577
+ */
11578
+ const unreadable = adoption.label === SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE;
11579
+ this.#logger.error("session.salt.split", {
11580
+ agentName, sessionId, correlationId, reason: adoption.label, frontier: adoption.why,
11581
+ impact: unreadable
11582
+ ? "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."
11583
+ : "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.",
11584
+ guidance: unreadable
11585
+ ? "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."
11586
+ : "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.",
11587
+ });
11588
+ }
9371
11589
  if (action.announce) {
9372
11590
  void this.#sendSaltFrame(agentName, sessionId, correlationId, action.announce);
9373
11591
  }
@@ -9387,10 +11605,16 @@ export class SessionNodeManager {
9387
11605
  answeredWith: action.frame.contribution ? "contribution" : "fingerprint",
9388
11606
  });
9389
11607
  // 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.
11608
+ // offer must not repeat (review F14).
9392
11609
  if (peerHalfHex && action.frame.contribution)
9393
11610
  this.#saltRepairedAgainst.set(key, peerHalfHex);
11611
+ // AND THE MIRROR (006-CRYPTO finding 1): we answered the peer's FINGERPRINT with our half. An
11612
+ // earlier note here said recording this "says nothing, that branch is already terminal for the
11613
+ // peer" — it is terminal only for a peer that HOLDS a salt, and the loop is the case where we
11614
+ // do not. A second identical fingerprint now closes adoption instead of repairing again.
11615
+ if (peerFingerprintHex && action.frame.contribution) {
11616
+ this.#saltRepairedAgainstFingerprint.set(key, peerFingerprintHex);
11617
+ }
9394
11618
  void this.#sendSaltFrame(agentName, sessionId, correlationId, action.frame);
9395
11619
  return;
9396
11620
  }
@@ -9742,6 +11966,23 @@ export class SessionNodeManager {
9742
11966
  * by name — so a non-Uint8Array in either slot must arrive at that function as ABSENT, not as
9743
11967
  * a present-but-wrong value it would then try to use.
9744
11968
  */
11969
+ /**
11970
+ * 007-CRYPTO — the peer's SIGNED ephemeral.
11971
+ *
11972
+ * Fields are read defensively rather than cast, exactly like the salt frame below: an inbound
11973
+ * value is whatever a peer chose to encode, and `verifySessionEphemeral` refuses a missing or
11974
+ * wrong-width one BY NAME — so a non-`Uint8Array` must arrive there as ABSENT rather than as a
11975
+ * present-but-wrong value it would try to use.
11976
+ */
11977
+ if (frame["type"] === "session_key_agreement") {
11978
+ const ephemeralPublic = frame["ephemeral_public"];
11979
+ const signature = frame["ephemeral_sig"];
11980
+ await this.#handleEphemeralFrame(agentName, sessionId, {
11981
+ ...(ephemeralPublic instanceof Uint8Array ? { ephemeralPublic } : {}),
11982
+ ...(signature instanceof Uint8Array ? { signature } : {}),
11983
+ }, correlationId);
11984
+ return;
11985
+ }
9745
11986
  if (frame["type"] === "session_salt_agreement") {
9746
11987
  const contribution = frame["contribution"];
9747
11988
  const fingerprint = frame["fingerprint"];
@@ -9751,7 +11992,14 @@ export class SessionNodeManager {
9751
11992
  ...(fingerprint instanceof Uint8Array ? { fingerprint } : {}),
9752
11993
  // A non-string stays ABSENT rather than being coerced, exactly like the other two: the
9753
11994
  // decision function refuses a shape it cannot read, and must never be handed a `"42"`.
9754
- ...(typeof adoptionClosed === "string" && adoptionClosed.length > 0 ? { adoptionClosed } : {}),
11995
+ //
11996
+ // TRUNCATED AT THE BOUNDARY — 006-CRYPTO finding 6. Every label CELLO sends is under
11997
+ // twenty characters, and this one is chosen entirely by the peer. Cutting it here means
11998
+ // no unbounded peer string is stored, logged or rendered anywhere downstream; the
11999
+ // rendering that keeps it out of our own sentences is `renderPeerAdoptionLabel`.
12000
+ ...(typeof adoptionClosed === "string" && adoptionClosed.length > 0
12001
+ ? { adoptionClosed: adoptionClosed.slice(0, SALT_ADOPTION_LABEL_MAX) }
12002
+ : {}),
9755
12003
  }, correlationId);
9756
12004
  return;
9757
12005
  }
@@ -9771,6 +12019,9 @@ export class SessionNodeManager {
9771
12019
  // field was PRESENT and wrong, so omitting it passed. Its own sibling twenty lines up already
9772
12020
  // refused absence, with a comment saying treating a missing field as agreement is how a guard
9773
12021
  // stops guarding. Same file, same switch, opposite conclusion.
12022
+ // Review F4: hand the DECODED frame to a test observer before anything consumes it. Absent in
12023
+ // production — the field is null unless a test installs one.
12024
+ this.#inboundFrameObserver?.(frame);
9774
12025
  const contentBytes = frame["content_bytes"];
9775
12026
  const contentHash = frame["content_hash"];
9776
12027
  if (!(contentBytes instanceof Uint8Array) || !(contentHash instanceof Uint8Array)) {
@@ -9784,6 +12035,61 @@ export class SessionNodeManager {
9784
12035
  });
9785
12036
  return;
9786
12037
  }
12038
+ /**
12039
+ * 🚨 DECRYPT BEFORE ANYTHING ELSE READS THE BODY — `DOD-M15-EPHEMERAL-AUTH-1`.
12040
+ *
12041
+ * `content_hash` is over the PLAINTEXT, so the hash check, the transcript, the seal and the
12042
+ * salted hash all keep meaning exactly what they mean today — but only if the body is put back
12043
+ * before any of them run.
12044
+ *
12045
+ * ⚠️ ABSENT IS NOT A PASS. A frame with no `content_encryption` is refused rather than read as
12046
+ * plaintext. There is no unencrypted sender to be compatible with, and treating a missing
12047
+ * marker as "this one is in the clear" is precisely the downgrade an attacker asks for: strip
12048
+ * one field and the receiver reads the body raw. Missing and unknown take the same path as a
12049
+ * failed decrypt, for the reason that runs through this whole unit — a check lenient about an
12050
+ * absent proof is a check that gets skipped.
12051
+ */
12052
+ const declaredEncryption = frame["content_encryption"];
12053
+ const encState = this.#contentEncryptionState(agentName, sessionId);
12054
+ let plaintextBody;
12055
+ if (declaredEncryption !== SESSION_CONTENT_ENCRYPTION_V1) {
12056
+ this.#logger.error("session.content.refused", {
12057
+ agentName, sessionId, correlationId,
12058
+ reason: "content_encryption_absent_or_unknown",
12059
+ declared: typeof declaredEncryption === "string" ? declaredEncryption : "(absent)",
12060
+ 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.",
12061
+ guidance: "STOPPED ON PURPOSE. A message arrived that was not encrypted under this session's key. " +
12062
+ "This build never sends one, so either something between you rewrote the frame, or your " +
12063
+ "counterparty is running something that is not CELLO. Confirm with them OUT OF BAND " +
12064
+ "before opening another session.",
12065
+ });
12066
+ return;
12067
+ }
12068
+ if (encState.key === null) {
12069
+ this.#logger.error("session.content.refused", {
12070
+ agentName, sessionId, correlationId,
12071
+ reason: "no_session_key",
12072
+ detail: encState.reason,
12073
+ 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.",
12074
+ guidance: CONTENT_ENCRYPTION_GUIDANCE[encState.reason],
12075
+ });
12076
+ return;
12077
+ }
12078
+ const opened = openSessionContent(encState.key, contentBytes);
12079
+ if (opened === null) {
12080
+ // GCM's tag is the only thing separating "not for us" from "modified in flight", and this
12081
+ // side must not branch on which — that would be branching on attacker-controlled input.
12082
+ this.#logger.error("session.content.refused", {
12083
+ agentName, sessionId, correlationId,
12084
+ reason: "decrypt_failed",
12085
+ 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.",
12086
+ guidance: "STOPPED ON PURPOSE. Nothing was shown and nothing was stored. A message that fails this " +
12087
+ "check has either been altered on its way to you or was not encrypted for this session. " +
12088
+ "Confirm with your counterparty OUT OF BAND, then start a new session.",
12089
+ });
12090
+ return;
12091
+ }
12092
+ plaintextBody = opened;
9787
12093
  // DOD-MSG-4 (self-ordering content frame): if the frame carries the relay's signed ordering
9788
12094
  // record, verify the sender signature and record the canonical sequence FROM THE FRAME, BEFORE
9789
12095
  // ingest — so the strict-in-order gate has the position without waiting on the separate
@@ -9850,7 +12156,10 @@ export class SessionNodeManager {
9850
12156
  * Coercing here would collapse that distinction and turn a version skew into a tamper report.
9851
12157
  */
9852
12158
  const declaredAlg = frame["content_hash_alg"];
9853
- const ingest = await this.ingestReceivedContent(agentName, sessionId, contentBytes, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship);
12159
+ const ingest = await this.ingestReceivedContent(
12160
+ // THE DECRYPTED body — everything downstream (the hash cross-check, the leaf, the transcript,
12161
+ // the delivery buffer) works on plaintext, exactly as it did before this layer existed.
12162
+ agentName, sessionId, plaintextBody, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship);
9854
12163
  // AC-001: after the content is durably ingested AND its hash cross-check
9855
12164
  // succeeds, emit an unsigned `persisted` delivery ACK back to the sender. A
9856
12165
  // rejected ingest (tamper / not-active) produces NO ACK, so the sender's TTF
@@ -10071,6 +12380,46 @@ export class SessionNodeManager {
10071
12380
  * relay pool, and the only source a FRESH agent (no session history) has.
10072
12381
  */
10073
12382
  #directoryRelayEndpoints = new Map();
12383
+ /**
12384
+ * DOD-M15-RELAYSLOTS-1: the directory-issued online token per agent — the credential the relays
12385
+ * above now require before they will let this agent hold a circuit reservation slot.
12386
+ *
12387
+ * Arrives with `signaling_auth_ok`, on the same frame as the relay endpoints and on the same
12388
+ * cadence: every connect AND every reconnect. Held here rather than passed to a relay client at
12389
+ * construction because it expires within the hour and the client outlives it — the client reads
12390
+ * it through `getDirectoryOnlineToken` at each authentication.
12391
+ */
12392
+ #directoryOnlineTokens = new Map();
12393
+ /**
12394
+ * DOD-M15-RELAYSLOTS-1: accept the directory's online token for an agent. Called on every
12395
+ * signaling connect and reconnect, which is what keeps it fresh.
12396
+ */
12397
+ setDirectoryOnlineToken(agentName, token) {
12398
+ this.#directoryOnlineTokens.set(agentName, token);
12399
+ this.#directoryOnlineTokenAbsent.delete(agentName);
12400
+ }
12401
+ /**
12402
+ * DOD-M15-RELAYSLOTS-1 review M1: the directory issued no token, and this is which absence it was.
12403
+ *
12404
+ * Kept so the relay's eventual `online_token_required` refusal can be reported with the cause the
12405
+ * DIRECTORY knew and the relay never learns — most importantly `not_registered_here`, where the
12406
+ * generic advice ("check that you are reaching a directory") points at a connection that is
12407
+ * working and away from the actual problem.
12408
+ */
12409
+ #directoryOnlineTokenAbsent = new Map();
12410
+ setDirectoryOnlineTokenAbsent(agentName, reason) {
12411
+ this.#directoryOnlineTokens.delete(agentName);
12412
+ this.#directoryOnlineTokenAbsent.set(agentName, reason ?? "unstated");
12413
+ }
12414
+ /**
12415
+ * The current token, or `undefined` when the directory has not issued one — either no directory
12416
+ * connection yet, or this key has no agent profile there. Undefined is a real answer, not a
12417
+ * missing one: the relay refuses without a token, which is the intended outcome for a key the
12418
+ * directory does not recognise.
12419
+ */
12420
+ getDirectoryOnlineToken(agentName) {
12421
+ return this.#directoryOnlineTokens.get(agentName);
12422
+ }
10074
12423
  /**
10075
12424
  * DOD-NAT-REACHABILITY-1 (Phase 2): accept the directory's relay-pool endpoints
10076
12425
  * for an agent (arrives with signaling_auth_ok, i.e. on every connect AND every
@@ -10172,6 +12521,57 @@ export class SessionNodeManager {
10172
12521
  * of past sessions (getAgentRelayEndpoints — covers a directory that predates
10173
12522
  * the auth_ok extension).
10174
12523
  */
12524
+ /**
12525
+ * DOD-M15-RELAYSLOTS-1: relays this agent should skip, and until when — see the failover note in
12526
+ * `#reservationCircuitAddrs`. Keyed agent → relay peer id → expiry.
12527
+ *
12528
+ * Time-boxed rather than permanent because the fault is somebody else's to fix and we will not
12529
+ * hear when they have: an operator sets the missing directory key and restarts, and this agent
12530
+ * should find that relay again without needing its own restart.
12531
+ */
12532
+ #relayQuarantine = new Map();
12533
+ /**
12534
+ * Is this agent currently skipping this relay? The observable half of the failover decision — a
12535
+ * test that asserts only on the classifier's boolean proves nothing about what the daemon does.
12536
+ */
12537
+ isRelayQuarantined(agentName, relayPeerId) {
12538
+ return this.#relayQuarantineFor(agentName).has(relayPeerId);
12539
+ }
12540
+ /** Live quarantine entries for an agent, expired ones swept on read. */
12541
+ #relayQuarantineFor(agentName) {
12542
+ const byRelay = this.#relayQuarantine.get(agentName);
12543
+ if (!byRelay)
12544
+ return new Set();
12545
+ const now = Date.now();
12546
+ for (const [relayPeerId, expiresAt] of byRelay) {
12547
+ if (now >= expiresAt)
12548
+ byRelay.delete(relayPeerId);
12549
+ }
12550
+ if (byRelay.size === 0)
12551
+ this.#relayQuarantine.delete(agentName);
12552
+ return new Set(byRelay.keys());
12553
+ }
12554
+ /**
12555
+ * Skip this relay for this agent for a while. Called only for refusals the classifier marks
12556
+ * `tryAnotherRelay` — a fault of the relay's, not one that would follow us to the next one.
12557
+ */
12558
+ #quarantineRelay(agentName, relayPeerId, reason) {
12559
+ let byRelay = this.#relayQuarantine.get(agentName);
12560
+ if (!byRelay) {
12561
+ byRelay = new Map();
12562
+ this.#relayQuarantine.set(agentName, byRelay);
12563
+ }
12564
+ byRelay.set(relayPeerId, Date.now() + RELAY_QUARANTINE_MS);
12565
+ this.#logger.warn("session.standing_receiver.relay_quarantined", {
12566
+ agentName,
12567
+ relayPeerId,
12568
+ reason,
12569
+ forMs: RELAY_QUARANTINE_MS,
12570
+ impact: "this relay refused this agent for a fault of its own, so the agent will ask a " +
12571
+ "different relay for its reservation until the quarantine lapses. Its inbound reachability " +
12572
+ "is restored by moving, not by waiting for someone to fix that relay.",
12573
+ });
12574
+ }
10175
12575
  #reservationCircuitAddrs(agentName) {
10176
12576
  let persisted;
10177
12577
  try {
@@ -10193,9 +12593,33 @@ export class SessionNodeManager {
10193
12593
  if (!merged.has(ep.relayPeerId))
10194
12594
  merged.set(ep.relayPeerId, ep);
10195
12595
  }
12596
+ /**
12597
+ * DOD-M15-RELAYSLOTS-1 — **THE FAILOVER.** Skip relays that refused this agent for a fault of
12598
+ * their own (today: a relay holding no directory public key, which can verify nobody and is
12599
+ * refusing everyone). We run several relays precisely so one being broken is survivable.
12600
+ *
12601
+ * ⚠️ NEVER TO THE POINT OF HAVING NO RELAY AT ALL. If the quarantine would empty the candidate
12602
+ * list it is ignored wholesale: a relay that refuses is strictly better than no relay, because
12603
+ * the refusal at least has a cause the operator can read, while an agent with no candidates is
12604
+ * simply unreachable with nothing to show for it. This is the same "refusing too eagerly is the
12605
+ * failure mode" rule, applied to the client's own choice of where to ask.
12606
+ */
12607
+ const quarantined = this.#relayQuarantineFor(agentName);
12608
+ const eligible = [...merged.values()].filter((ep) => !quarantined.has(ep.relayPeerId));
12609
+ const usable = eligible.length > 0 ? eligible : [...merged.values()];
12610
+ if (eligible.length === 0 && quarantined.size > 0 && merged.size > 0) {
12611
+ this.#logger.warn("session.standing_receiver.relay_quarantine.ignored", {
12612
+ agentName,
12613
+ quarantined: [...quarantined],
12614
+ impact: "every known relay has refused this agent for a relay-side fault, so the quarantine " +
12615
+ "is being ignored and they are all being tried again. A relay that refuses with a cause " +
12616
+ "is better than no relay at all — but if this persists, every relay this agent knows " +
12617
+ "about is misconfigured, and that is the thing to look at.",
12618
+ });
12619
+ }
10196
12620
  const addrs = [];
10197
12621
  const relayPeerIds = [];
10198
- for (const ep of merged.values()) {
12622
+ for (const ep of usable) {
10199
12623
  const base = ep.relayAddrs[0];
10200
12624
  if (!base)
10201
12625
  continue;
@@ -10429,6 +12853,133 @@ export class SessionNodeManager {
10429
12853
  * Candidates are tried in order (directory pool first). The first that actually
10430
12854
  * grants a reservation wins; the rest are never touched.
10431
12855
  */
12856
+ /**
12857
+ * DOD-M15-RELAYSLOTS-1 — tell the relay this transport identity belongs to a registered agent, so
12858
+ * the reservation it just refused is granted on the next attempt.
12859
+ *
12860
+ * Returns `"proven"` on success, or the shape of the failure so the candidate loop can act on it.
12861
+ * It never throws: throwing would turn one unreachable relay into a failure to build a receiver
12862
+ * at all.
12863
+ *
12864
+ * ─── Why this returns a verdict instead of a boolean ──────────────────────────────────────────
12865
+ *
12866
+ * Review HIGH-1/HIGH-2. Putting the reservation behind a proof MOVED THE FIRST REFUSAL onto this
12867
+ * path. `#authenticateStandingReceiver` does everything right with a refusal — records it where
12868
+ * `cello_status` can read it, and quarantines a relay whose fault is its own — but it runs only
12869
+ * on a receiver that ALREADY HAS a reservation, so under the new gate a total failure never
12870
+ * reaches it. This method was logging `proven: false` and returning.
12871
+ *
12872
+ * What that cost the operator: an expired token, or an agent at its slot cap, refused by every
12873
+ * relay in the pool. `cello_status` shows an agent that is online and reachable by nobody, with
12874
+ * no cause anywhere the person will look — while the relay had computed the cause, the count, and
12875
+ * the next step, and put them on the wire. And `slot_cap_exceeded` is classified
12876
+ * `tryAnotherRelay: false` precisely so the client STOPS walking the fleet; without the verdict,
12877
+ * the loop walked it anyway, turning one client-side fault into what reads as a fleet outage.
12878
+ */
12879
+ async #proveToRelay(agentName, circuitAddr, node, correlationId,
12880
+ /**
12881
+ * Whether this proof is the STANDING RECEIVER's, and may therefore write the surface
12882
+ * `cello_status` reads as "your standing receiver was refused".
12883
+ *
12884
+ * A revival proves itself too, and its refusal is real — but it is not evidence about the
12885
+ * receiver. A receiver that proved thirty seconds ago and holds a slot, plus a revival refused
12886
+ * with `slot_cap_exceeded`, would otherwise have `cello_status` report a front door as refused
12887
+ * while it is open. The refusal is still logged and still steers the candidate loop; what it
12888
+ * does not do is claim to be about something it did not measure.
12889
+ */
12890
+ surfaceAsReceiverRefusal) {
12891
+ const relayPeerId = relayPeerIdOf(circuitAddr);
12892
+ const baseRelayAddr = circuitAddr.split("/p2p-circuit")[0];
12893
+ if (!relayPeerId || !baseRelayAddr) {
12894
+ this.#logger.warn("session.standing_receiver.prove.address_unreadable", {
12895
+ agentName,
12896
+ circuitAddr,
12897
+ correlationId,
12898
+ impact: "this candidate's circuit address does not name a relay peer, so there is nothing " +
12899
+ "to prove to and its reservation will stay refused. Skipped silently before — which made " +
12900
+ "a malformed address look identical to a relay that simply said no.",
12901
+ });
12902
+ return "unavailable";
12903
+ }
12904
+ /**
12905
+ * Declared out here so the `finally` can close it. Scoped inside the `try` before, so a throw
12906
+ * from `proveReservation` skipped the close and left the stream and its pending settles behind.
12907
+ */
12908
+ let client;
12909
+ try {
12910
+ client = this.#detachedRelayClientBuilder?.(agentName, relayPeerId, [baseRelayAddr], {
12911
+ receiptStore: this.#relayReceiptStore ?? undefined,
12912
+ sealLeafStore: this.#sealLeafStore ?? undefined,
12913
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
12914
+ });
12915
+ if (!client) {
12916
+ this.#logger.warn("session.standing_receiver.prove.no_builder", {
12917
+ agentName,
12918
+ relayPeerId,
12919
+ correlationId,
12920
+ impact: "no relay client could be built, so this receiver cannot prove itself and the " +
12921
+ "relay will refuse its reservation again. The agent is reachable only over a direct " +
12922
+ "connection until this is wired.",
12923
+ });
12924
+ return "unavailable";
12925
+ }
12926
+ const proven = await client.proveReservation(node);
12927
+ if (proven) {
12928
+ if (surfaceAsReceiverRefusal)
12929
+ this.#srRelayRefusal.delete(agentName);
12930
+ this.#logger.info("session.standing_receiver.prove.result", {
12931
+ agentName, relayPeerId, peerId: node.getPeerId(), proven: true, correlationId,
12932
+ });
12933
+ return "proven";
12934
+ }
12935
+ /**
12936
+ * The same two lines `#authenticateStandingReceiver` runs, for the same reason. The `else`
12937
+ * matters as much as the `if`: `proveReservation` also fails for transport reasons, which
12938
+ * leave `getLastAuthRefusal()` null, and leaving a PREVIOUS refusal in the map would have
12939
+ * `cello_status` explaining a cause that is no longer what is wrong.
12940
+ */
12941
+ const refusal = client.getLastAuthRefusal();
12942
+ if (surfaceAsReceiverRefusal) {
12943
+ if (refusal) {
12944
+ this.#srRelayRefusal.set(agentName, { ...this.#withDirectoryCause(agentName, refusal), relayPeerId });
12945
+ }
12946
+ else {
12947
+ this.#srRelayRefusal.delete(agentName);
12948
+ }
12949
+ }
12950
+ this.#logger.warn("session.standing_receiver.prove.result", {
12951
+ agentName,
12952
+ relayPeerId,
12953
+ peerId: node.getPeerId(),
12954
+ proven: false,
12955
+ refusalReason: refusal?.reason ?? "no_relay_verdict",
12956
+ tryAnotherRelay: refusal?.tryAnotherRelay ?? true,
12957
+ correlationId,
12958
+ impact: refusal?.advice ??
12959
+ "the relay would not accept this agent's proof and said nothing about why, which is what " +
12960
+ "a transport failure mid-handshake looks like. The candidate loop moves on to the next relay.",
12961
+ });
12962
+ if (refusal && !refusal.tryAnotherRelay)
12963
+ return "refused_this_agent";
12964
+ if (refusal?.tryAnotherRelay && !this.#shuttingDown) {
12965
+ this.#quarantineRelay(agentName, relayPeerId, refusal.reason);
12966
+ }
12967
+ return "refused_try_another_relay";
12968
+ }
12969
+ catch (err) {
12970
+ this.#logger.warn("session.standing_receiver.prove.failed", {
12971
+ agentName,
12972
+ correlationId,
12973
+ error: extractErrorMessage(err),
12974
+ impact: "this receiver could not prove itself, so its retry will be refused and the " +
12975
+ "candidate loop will try the next relay.",
12976
+ });
12977
+ return "unavailable";
12978
+ }
12979
+ finally {
12980
+ client?.close();
12981
+ }
12982
+ }
10432
12983
  async #startReceiverNode(agentName, sessionId, gater, candidateCircuitAddrs, correlationId) {
10433
12984
  for (const circuitAddr of candidateCircuitAddrs) {
10434
12985
  // DOD-M12B-SESSION-SEED-1: A SEED PER CANDIDATE, NOT ONE FOR THE LOOP.
@@ -10444,60 +12995,158 @@ export class SessionNodeManager {
10444
12995
  //
10445
12996
  // Nothing reads the seed before the winner is installed, so per-candidate costs nothing.
10446
12997
  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
- ]);
12998
+ /**
12999
+ * DOD-M15-RELAYSLOTS-1 — **TWO ATTEMPTS PER RELAY: ask, prove, ask again.**
13000
+ *
13001
+ * The relay now refuses a reservation from a peer that has not shown it belongs to a
13002
+ * registered agent. A brand-new receiver has shown nothing, so its FIRST ask is refused —
13003
+ * expected, not a failure. It then authenticates over `/cello/relay/1.0.0`, which tells the
13004
+ * relay this transport identity is a registered agent's, and asks again on a fresh connection
13005
+ * carrying the SAME identity (that is what reusing `candidateSeed` buys).
13006
+ *
13007
+ * ⚠️ It has to be two connections, and that was measured rather than chosen. Taking the
13008
+ * reservation by hand on the same connection as the proof DOES get a slot — and libp2p then
13009
+ * announces no circuit address for it, because it only announces addresses for reservations
13010
+ * its own relay-discovery made. The agent would hold a slot nobody could dial through.
13011
+ */
13012
+ let candidateNode;
13013
+ // Set when the relay refused the AGENT rather than being unwilling itself: every other relay
13014
+ // in the pool answers identically, so the walk ends here rather than reproducing it N times.
13015
+ let candidateRefusedAgent = false;
13016
+ for (let attempt = 0; attempt < 2; attempt++) {
13017
+ const candidate = await this.#createAgentNode(agentName, {
13018
+ sessionId,
13019
+ connectionGater: gater,
13020
+ nodeType: "standing_receiver",
13021
+ circuitRelayListenAddrs: [circuitAddr],
13022
+ transportPrivateKey: candidateSeed,
13023
+ });
13024
+ let timer;
13025
+ const timedOut = Symbol("reservation_timeout");
13026
+ let outcome = "failed";
13027
+ let error = "";
13028
+ try {
13029
+ outcome = await Promise.race([
13030
+ candidate.start().then(() => "started"),
13031
+ new Promise((resolve) => {
13032
+ timer = setTimeout(() => resolve(timedOut), this.#srReservationTimeoutMs);
13033
+ }),
13034
+ ]);
13035
+ }
13036
+ catch (err) {
13037
+ error = extractErrorMessage(err);
13038
+ }
13039
+ finally {
13040
+ if (timer !== undefined)
13041
+ clearTimeout(timer);
13042
+ }
13043
+ // The only proof that counts: the relay actually GRANTED the reservation.
13044
+ // start() resolving is not enough — a relay that is out of reservation slots
13045
+ // completes the handshake and simply grants nothing, leaving a node that looks
13046
+ // started and is reachable by nobody.
13047
+ if (outcome === "started" && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
13048
+ candidateNode = candidate;
13049
+ break;
13050
+ }
13051
+ /**
13052
+ * No reservation. On the FIRST attempt that is the expected answer for a receiver that has
13053
+ * not proved itself yet, so prove and go round once more. `proveReservation` opens its own
13054
+ * stream from this node, which is what binds this transport identity to the agent at the
13055
+ * relay; the relay remembers it across the reconnect below.
13056
+ */
13057
+ if (attempt === 0 && outcome === "started") {
13058
+ const verdict = await this.#proveToRelay(agentName, circuitAddr, candidate, correlationId, true);
13059
+ // AWAITED, not fire-and-forget: the retry rebuilds on this same transport identity, and two
13060
+ // live nodes sharing one peer id is the defect DOD-M12B-SESSION-SEED-1 exists to prevent.
13061
+ try {
13062
+ await candidate.stop();
13063
+ }
13064
+ catch { /* it may never have finished starting */ }
13065
+ /**
13066
+ * DOD-M15-RELAYSLOTS-1 clause 9 — **A CLIENT-SIDE REFUSAL ENDS THE WALK.**
13067
+ *
13068
+ * `slot_cap_exceeded` and an expired or missing token are classified `tryAnotherRelay:
13069
+ * false` because they reproduce on every relay in the pool: the cap is per AGENT, and the
13070
+ * token comes from the directory, not from here. Walking on costs a node build and two
13071
+ * dials per remaining relay to arrive at the same answer, and it makes one client-side
13072
+ * fault look like a fleet-wide outage in the logs. The refusal is already recorded where
13073
+ * `cello_status` reads it, so stopping is not silence.
13074
+ */
13075
+ if (verdict === "refused_this_agent") {
13076
+ this.#srLastRejectionReason.set(agentName, "relay_refused_this_agent");
13077
+ this.#logger.warn("session.standing_receiver.relay.rejected", {
13078
+ agentName,
13079
+ circuitAddr,
13080
+ reason: "relay_refused_this_agent",
13081
+ attempts: attempt + 1,
13082
+ correlationId,
13083
+ impact: "the relay refused this AGENT rather than this relay being unwilling or " +
13084
+ "unwell, so every other relay would refuse it identically. Stopped here; " +
13085
+ "cello_status carries the cause and what to do about it.",
13086
+ });
13087
+ candidateRefusedAgent = true;
13088
+ break;
13089
+ }
13090
+ /**
13091
+ * ⚠️ RETRY ONLY WHAT A PROOF CAN FIX. The second attempt exists because the relay now
13092
+ * remembers this transport identity; if the proof did not land, it remembers nothing and
13093
+ * the retry is a node build and a dial spent to be refused identically. Only `proven`
13094
+ * earns the retry — everything else moves to the next relay.
13095
+ */
13096
+ if (verdict !== "proven") {
13097
+ this.#srLastRejectionReason.set(agentName, "relay_proof_refused");
13098
+ this.#logger.warn("session.standing_receiver.relay.rejected", {
13099
+ agentName,
13100
+ circuitAddr,
13101
+ reason: "relay_proof_refused",
13102
+ attempts: attempt + 1,
13103
+ correlationId,
13104
+ impact: "this relay would not take the agent's proof, so it will refuse the retry the " +
13105
+ "same way. Moving to the next relay rather than asking this one twice.",
13106
+ });
13107
+ break;
13108
+ }
13109
+ continue;
13110
+ }
13111
+ const rejectionReason = outcome === "started"
13112
+ ? /**
13113
+ * ⚠️ Review MEDIUM-7 — **"STARTED" DOES NOT MEAN THE RELAY ANSWERED.** A circuit listen
13114
+ * entry sets `FaultTolerance.NO_FATAL`, and `start()` only throws when the DIRECT
13115
+ * listener fails, so a relay that is simply DOWN resolves `started` with no circuit
13116
+ * address — indistinguishable, here, from a relay that answered and granted nothing.
13117
+ * Reporting that as `relay_granted_no_reservation` sends the operator to look at relay
13118
+ * capacity for what is a network fault. An open connection to the relay peer is the
13119
+ * thing that separates them, and we have one to ask.
13120
+ */
13121
+ (candidate.getConnections().some((c) => c.peerId === relayPeerIdOf(circuitAddr))
13122
+ ? "relay_granted_no_reservation"
13123
+ : "relay_unreachable")
13124
+ : outcome === "failed"
13125
+ ? "relay_unreachable"
13126
+ : "reservation_did_not_complete_in_time";
13127
+ this.#srLastRejectionReason.set(agentName, rejectionReason);
13128
+ this.#logger.warn("session.standing_receiver.relay.rejected", {
13129
+ agentName,
13130
+ circuitAddr,
13131
+ reason: rejectionReason,
13132
+ attempts: attempt + 1,
13133
+ ...(error !== "" ? { error } : {}),
13134
+ correlationId,
13135
+ });
13136
+ // Abandon it. start() may still be parked on a dial, so stop() is best-effort
13137
+ // and must never block the fallback.
13138
+ void Promise.resolve()
13139
+ .then(() => candidate.stop())
13140
+ .catch(() => { });
13141
+ break;
10465
13142
  }
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(() => { });
13143
+ if (candidateNode)
13144
+ return { node: candidateNode, seed: candidateSeed };
13145
+ if (candidateRefusedAgent)
13146
+ break;
10498
13147
  }
10499
13148
  const plainSeed = randomBytes(32);
10500
- const plain = await this.#factory.createNode({
13149
+ const plain = await this.#createAgentNode(agentName, {
10501
13150
  sessionId,
10502
13151
  connectionGater: gater,
10503
13152
  nodeType: "standing_receiver",
@@ -10624,6 +13273,23 @@ export class SessionNodeManager {
10624
13273
  sessionPeerId: node.getPeerId(),
10625
13274
  correlationId,
10626
13275
  });
13276
+ // DOD-M15-RELAYAUTH-1: authenticate to the reservation relay NOW, not when a session first
13277
+ // needs one. The relay times out a reservation nobody has proven key possession for
13278
+ // (relay-connection-gater.ts, trustless-cello) — proving it here, instead of waiting for a
13279
+ // real session to exist, is what keeps this reservation alive past that grace window.
13280
+ // Best-effort and unawaited: a failure here costs nothing beyond the relay's own grace-window
13281
+ // revoke, which the reservation watchdog already treats as an ordinary lost reservation.
13282
+ if (reservedRelayPeerId !== undefined && heldCircuitAddr !== undefined) {
13283
+ void this.#authenticateStandingReceiver(agentName, node, reservedRelayPeerId, heldCircuitAddr, correlationId)
13284
+ .catch((err) => {
13285
+ this.#logger.warn("session.standing_receiver.relay_auth.failed", {
13286
+ agentName,
13287
+ relayPeerId: reservedRelayPeerId,
13288
+ error: extractErrorMessage(err),
13289
+ correlationId,
13290
+ });
13291
+ });
13292
+ }
10627
13293
  // DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come
10628
13294
  // up? circuitAddrs === 0 with reservations requested means every relay
10629
13295
  // refused/was unreachable — the agent is deaf to NAT'd initiators (public
@@ -10733,63 +13399,131 @@ export class SessionNodeManager {
10733
13399
  */
10734
13400
  async #buildRevivedNode(sessionId, gater, seed, candidateAddrs, agentName) {
10735
13401
  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(() => { }), () => { });
13402
+ /**
13403
+ * DOD-M15-RELAYSLOTS-1 — **A REVIVAL PROVES ITSELF TOO.**
13404
+ *
13405
+ * Review HIGH-3. The relay refuses a reservation to a peer that has not shown it belongs to
13406
+ * a registered agent, and it remembers a proof for two minutes. A revival is almost never
13407
+ * inside that window — the receiver last proved this peer id when the session was created,
13408
+ * possibly days ago — so without this loop every revived session was refused by every
13409
+ * candidate and came up on the plain floor: alive, `active`, and dialable by nobody, with
13410
+ * every message in both directions forced through the relay park route.
13411
+ *
13412
+ * Two attempts, exactly as `#startReceiverNode` does it, and for the same measured reason:
13413
+ * a reservation taken by hand on the same connection as the proof yields no dialable address.
13414
+ * The seed is fixed here that is what a revival IS so the second attempt necessarily
13415
+ * carries the identity the relay just recorded.
13416
+ */
13417
+ let revivedNode;
13418
+ let terminalRefusal = false;
13419
+ for (let attempt = 0; attempt < 2 && !terminalRefusal; attempt++) {
13420
+ const candidate = await this.#createAgentNode(agentName, {
13421
+ sessionId,
13422
+ connectionGater: gater,
13423
+ nodeType: "session",
13424
+ inboundReachable: true,
13425
+ transportPrivateKey: seed,
13426
+ circuitRelayListenAddrs: [circuitAddr],
13427
+ });
13428
+ // KEEP THE START PROMISE. Review HIGH-3: `libp2p.stop()` opens with
13429
+ // `if (this.status !== 'started') return`, and during the whole timeout window the status is
13430
+ // `'starting'` so awaiting `stop()` on a timed-out candidate stopped nothing and waited for
13431
+ // nothing. The abandoned `start()` stayed in flight, and if the relay answered late the node
13432
+ // went live holding THIS SESSION'S peer id, sharing the gater (so it admits the counterparty)
13433
+ // with no content handler registered, and with no reference left to stop it. Verified against
13434
+ // libp2p 3.3.2 rather than assumed.
13435
+ const startP = candidate.start();
13436
+ let startError;
13437
+ const started = await Promise.race([
13438
+ startP.then(() => true),
13439
+ new Promise((res) => setTimeout(() => res(false), REVIVE_RESERVATION_TIMEOUT_MS).unref?.()),
13440
+ ]).catch((err) => { startError = err; return false; });
13441
+ if (started && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
13442
+ this.#logger.info("session.revive.reservation.granted", { agentName, sessionId, attempts: attempt + 1 });
13443
+ revivedNode = candidate;
13444
+ break;
13445
+ }
13446
+ /**
13447
+ * No reservation on the first attempt is the EXPECTED answer for a peer whose proof has
13448
+ * aged out. Prove and go round once more.
13449
+ *
13450
+ * Only when `started` is true: `libp2p.stop()` opens with `if (this.status !== 'started')
13451
+ * return`, so a timed-out candidate cannot be torn down here and rebuilding on its seed
13452
+ * would put two live nodes on one peer id. That case falls through to the settlement-chained
13453
+ * teardown below, which is the only thing that reliably kills a still-starting node.
13454
+ */
13455
+ if (attempt === 0 && started) {
13456
+ const verdict = await this.#proveToRelay(agentName, circuitAddr, candidate, sessionId, false);
13457
+ try {
13458
+ await candidate.stop();
13459
+ }
13460
+ catch { /* best-effort */ }
13461
+ if (verdict === "refused_this_agent") {
13462
+ // The refusal is about this AGENT, so the remaining candidates would answer identically.
13463
+ terminalRefusal = true;
13464
+ this.#logger.warn("session.revive.reservation.declined", {
13465
+ agentName,
13466
+ sessionId,
13467
+ circuitAddr,
13468
+ reason: "relay_refused_this_agent",
13469
+ impact: "the relay refused this agent rather than being unwilling or unwell, so every " +
13470
+ "other relay refuses it the same way. The session comes up reachable only via the " +
13471
+ "relay park route; cello_status carries the cause.",
13472
+ });
13473
+ break;
13474
+ }
13475
+ // Only a landed proof earns the retry — see the same rule in `#startReceiverNode`.
13476
+ if (verdict !== "proven") {
13477
+ this.#logger.warn("session.revive.reservation.declined", {
13478
+ agentName,
13479
+ sessionId,
13480
+ circuitAddr,
13481
+ reason: "relay_proof_refused",
13482
+ impact: "this relay would not take the agent's proof, so asking it again would be " +
13483
+ "refused the same way. Trying the next relay.",
13484
+ });
13485
+ break;
13486
+ }
13487
+ continue;
13488
+ }
13489
+ // Started but granted nothing, or never started. Either way this node is not the one.
13490
+ //
13491
+ // Review MEDIUM-5: name WHICH of the three causes this was, the way `#startReceiverNode` does.
13492
+ // "declined" alone stood for a relay that is full, a relay that is unreachable, and a relay
13493
+ // that is merely slow — three different problems with three different responses, and the
13494
+ // thrown error was discarded entirely.
13495
+ const declineReason = started
13496
+ ? "relay_granted_no_reservation"
13497
+ : startError !== undefined
13498
+ ? "relay_unreachable"
13499
+ : "reservation_did_not_complete_in_time";
13500
+ const isLast = circuitAddr === candidateAddrs.slice(0, REVIVE_RESERVATION_CANDIDATES).at(-1);
13501
+ this.#logger.warn("session.revive.reservation.declined", {
13502
+ agentName,
13503
+ sessionId,
13504
+ circuitAddr,
13505
+ reason: declineReason,
13506
+ ...(startError !== undefined ? { error: extractErrorMessage(startError) } : {}),
13507
+ impact: isLast
13508
+ ? "no relay granted; the session comes up reachable only via the relay park route"
13509
+ : "trying the next relay",
13510
+ });
13511
+ // Teardown at SETTLEMENT, not now: a `stop()` issued while the node is still starting is a
13512
+ // no-op (see above), so the only way to guarantee this node dies is to wait for its own start
13513
+ // to finish first. Not awaited, so a hung start cannot hold the revival up — the point is that
13514
+ // the teardown eventually happens, not that it happens before the next candidate.
13515
+ void startP.then(() => candidate.stop().catch(() => { }), () => { });
13516
+ break;
13517
+ }
13518
+ if (revivedNode)
13519
+ return revivedNode;
13520
+ if (terminalRefusal)
13521
+ break;
10788
13522
  }
10789
13523
  // THE FLOOR. No reservation, so the counterparty cannot dial us directly — but their messages
10790
13524
  // park at the relay and drain, which is how every message in the 2026-08-18 test arrived. A
10791
13525
  // session usable one way beats a session that never comes back.
10792
- const plain = await this.#factory.createNode({
13526
+ const plain = await this.#createAgentNode(agentName, {
10793
13527
  sessionId,
10794
13528
  connectionGater: gater,
10795
13529
  nodeType: "session",
@@ -10855,6 +13589,8 @@ export class SessionNodeManager {
10855
13589
  client = this.#detachedRelayClientBuilder?.(agentName, ep.relayPeerId, [...ep.relayAddrs], {
10856
13590
  receiptStore: this.#relayReceiptStore ?? undefined,
10857
13591
  sealLeafStore: this.#sealLeafStore ?? undefined,
13592
+ // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
13593
+ onlineToken: () => this.getDirectoryOnlineToken(agentName),
10858
13594
  });
10859
13595
  if (!client) {
10860
13596
  this.#logger.warn("session.revive.relay.builder_absent", {
@@ -11130,6 +13866,24 @@ export class SessionNodeManager {
11130
13866
  // different `impact` text — one event name standing for two meanings, fired twice for a single
11131
13867
  // condition. `#reconnectRevivedSessionRelay` takes it as a parameter now.
11132
13868
  const persistedRelay = this.getPersistedRelayEndpoint(agentName, sessionId);
13869
+ // 006-CRYPTO: a REVIVED session mints a FRESH keypair and re-keys — Decisions Carried #5. That
13870
+ // holds because the interrupt path destroys the old secret when it drops the entry; until it
13871
+ // did, this call found the stale key still in the map and quietly kept it. The salt, which IS
13872
+ // persisted, is re-read from the row instead — opposite lifetimes, deliberately.
13873
+ this.#mintSessionEphemeral(agentName, sessionId);
13874
+ /**
13875
+ * AND ANNOUNCE IT — review F1, second half. Minting a fresh key achieves nothing on its own: the
13876
+ * COUNTERPARTY has to hear about it, and it is the side that did NOT restart, so it is not
13877
+ * tearing anything down or reconnecting. `#sendEphemeralFrame` otherwise rides `onPeerConnect`,
13878
+ * which does not fire again for a connection that never dropped — the ordinary shape when only
13879
+ * one end's witness stream closed, which is what a relay roll produces.
13880
+ *
13881
+ * Without this the two ends sit on different keys for the life of the session, every message
13882
+ * fails GCM, and the receiving operator is told the content may have been MODIFIED IN FLIGHT for
13883
+ * what is a local key skew. Deferred a tick so the revived node's handlers are registered before
13884
+ * the frame goes out.
13885
+ */
13886
+ setTimeout(() => { void this.#sendEphemeralFrame(agentName, sessionId, "revive-rekey"); }, 0);
11133
13887
  this.#activeNodes.set(key, {
11134
13888
  node,
11135
13889
  agentName,
@@ -11167,7 +13921,37 @@ export class SessionNodeManager {
11167
13921
  // `interrupted` leaves a live, talking session where REVIVAL-BOUND-1's sweep can seal or abandon
11168
13922
  // it. Failing here means tearing the node back down rather than running in that split state.
11169
13923
  if (!this.#updateSessionStatus(agentName, sessionId, "active")) {
13924
+ /**
13925
+ * DOD-M15-RELAYLEAK-1 (review MEDIUM-4) — **THIS TEARDOWN LEAKED THE EXACT THING THE LINE IS
13926
+ * ABOUT, THROUGH A DIFFERENT DOOR.**
13927
+ *
13928
+ * `#reconnectRevivedSessionRelay` above has already called `registerSession` on the cached
13929
+ * relay client and hung it on this entry. Deleting the map key and stopping the node released
13930
+ * the daemon's own objects and left that registration standing with **no owner** — and
13931
+ * `#detachSessionRelay` closes a client only when `!hasSessions()`, so the orphaned
13932
+ * registration held that predicate false for the life of the process. The client, its
13933
+ * authenticated stream and its relay-side reservation were unreachable and immortal.
13934
+ *
13935
+ * The shutdown loop this line added does sweep it at exit, which is precisely why it had to be
13936
+ * fixed here too: a leak that is only cleaned up by process death is still a leak for every
13937
+ * hour the daemon is up.
13938
+ */
13939
+ /**
13940
+ * ⚠️ Review MEDIUM-2 — **THE ENTRY IS MATCHED BY IDENTITY, NOT BY KEY.** `reviveSessionNode`
13941
+ * has no in-flight guard: its `if (live) return` is separated from `#activeNodes.set` by the
13942
+ * whole node build, so two revivals for one key can both reach the `set` and the second
13943
+ * overwrites the first. Looking up by key alone would then hand THIS failing revival the
13944
+ * OTHER one's live entry, and detaching it would unregister a running session's leaf handler
13945
+ * — closing the client that session is using if it was the last one on it. Comparing `node`
13946
+ * costs one token and makes "the entry I created" provable rather than assumed.
13947
+ */
13948
+ const revivedEntry = this.#activeNodes.get(key);
13949
+ if (revivedEntry?.node === node)
13950
+ this.#detachSessionRelay(revivedEntry);
11170
13951
  this.#activeNodes.delete(key);
13952
+ // 006-CRYPTO: the revival FAILED, so the key it just minted belongs to a session that never
13953
+ // came back. Dropping the entry without this would strand it for the daemon's lifetime.
13954
+ this.#destroySessionEphemeralFor(agentName, sessionId);
11171
13955
  try {
11172
13956
  await node.stop();
11173
13957
  }