@cello-protocol/daemon 0.0.194 → 0.0.195

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 (103) hide show
  1. package/dist/assignment-verify.d.ts +39 -11
  2. package/dist/assignment-verify.d.ts.map +1 -1
  3. package/dist/assignment-verify.js +133 -17
  4. package/dist/assignment-verify.js.map +1 -1
  5. package/dist/authorship-verification.d.ts +70 -0
  6. package/dist/authorship-verification.d.ts.map +1 -0
  7. package/dist/authorship-verification.js +441 -0
  8. package/dist/authorship-verification.js.map +1 -0
  9. package/dist/db-identity-store.d.ts +1 -0
  10. package/dist/db-identity-store.d.ts.map +1 -1
  11. package/dist/db-identity-store.js +19 -1
  12. package/dist/db-identity-store.js.map +1 -1
  13. package/dist/held-content.d.ts +141 -0
  14. package/dist/held-content.d.ts.map +1 -0
  15. package/dist/held-content.js +388 -0
  16. package/dist/held-content.js.map +1 -0
  17. package/dist/inbound-refusals.d.ts +283 -0
  18. package/dist/inbound-refusals.d.ts.map +1 -0
  19. package/dist/inbound-refusals.js +919 -0
  20. package/dist/inbound-refusals.js.map +1 -0
  21. package/dist/inbound-sessions.d.ts.map +1 -1
  22. package/dist/inbound-sessions.js +65 -27
  23. package/dist/inbound-sessions.js.map +1 -1
  24. package/dist/initiate-session-handler.d.ts.map +1 -1
  25. package/dist/initiate-session-handler.js +63 -0
  26. package/dist/initiate-session-handler.js.map +1 -1
  27. package/dist/outbound-sessions.d.ts.map +1 -1
  28. package/dist/outbound-sessions.js +108 -8
  29. package/dist/outbound-sessions.js.map +1 -1
  30. package/dist/park-recovery.d.ts +205 -0
  31. package/dist/park-recovery.d.ts.map +1 -0
  32. package/dist/park-recovery.js +600 -0
  33. package/dist/park-recovery.js.map +1 -0
  34. package/dist/refusal-notices.d.ts +196 -0
  35. package/dist/refusal-notices.d.ts.map +1 -0
  36. package/dist/refusal-notices.js +496 -0
  37. package/dist/refusal-notices.js.map +1 -0
  38. package/dist/refusal-reasons.d.ts +11 -0
  39. package/dist/refusal-reasons.d.ts.map +1 -1
  40. package/dist/refusal-reasons.js +18 -0
  41. package/dist/refusal-reasons.js.map +1 -1
  42. package/dist/registration-manager.d.ts.map +1 -1
  43. package/dist/registration-manager.js +126 -5
  44. package/dist/registration-manager.js.map +1 -1
  45. package/dist/registration-persistence.d.ts +18 -0
  46. package/dist/registration-persistence.d.ts.map +1 -1
  47. package/dist/registration-persistence.js +6 -0
  48. package/dist/registration-persistence.js.map +1 -1
  49. package/dist/session-assignment-parser.d.ts.map +1 -1
  50. package/dist/session-assignment-parser.js +23 -1
  51. package/dist/session-assignment-parser.js.map +1 -1
  52. package/dist/session-ceremony.d.ts +12 -7
  53. package/dist/session-ceremony.d.ts.map +1 -1
  54. package/dist/session-ceremony.js +12 -7
  55. package/dist/session-ceremony.js.map +1 -1
  56. package/dist/session-ephemerals.d.ts +271 -0
  57. package/dist/session-ephemerals.d.ts.map +1 -0
  58. package/dist/session-ephemerals.js +546 -0
  59. package/dist/session-ephemerals.js.map +1 -0
  60. package/dist/session-leaf-records.d.ts +132 -0
  61. package/dist/session-leaf-records.d.ts.map +1 -0
  62. package/dist/session-leaf-records.js +320 -0
  63. package/dist/session-leaf-records.js.map +1 -0
  64. package/dist/session-liveness.d.ts +135 -0
  65. package/dist/session-liveness.d.ts.map +1 -0
  66. package/dist/session-liveness.js +347 -0
  67. package/dist/session-liveness.js.map +1 -0
  68. package/dist/session-node-manager.d.ts +300 -1670
  69. package/dist/session-node-manager.d.ts.map +1 -1
  70. package/dist/session-node-manager.js +871 -9951
  71. package/dist/session-node-manager.js.map +1 -1
  72. package/dist/session-node-types.d.ts +933 -0
  73. package/dist/session-node-types.d.ts.map +1 -0
  74. package/dist/session-node-types.js +576 -0
  75. package/dist/session-node-types.js.map +1 -0
  76. package/dist/session-queries.d.ts +476 -0
  77. package/dist/session-queries.d.ts.map +1 -0
  78. package/dist/session-queries.js +1006 -0
  79. package/dist/session-queries.js.map +1 -0
  80. package/dist/session-records.d.ts +320 -0
  81. package/dist/session-records.d.ts.map +1 -0
  82. package/dist/session-records.js +792 -0
  83. package/dist/session-records.js.map +1 -0
  84. package/dist/session-salts.d.ts +390 -0
  85. package/dist/session-salts.d.ts.map +1 -0
  86. package/dist/session-salts.js +1457 -0
  87. package/dist/session-salts.js.map +1 -0
  88. package/dist/session-schema.d.ts +30 -0
  89. package/dist/session-schema.d.ts.map +1 -0
  90. package/dist/session-schema.js +786 -0
  91. package/dist/session-schema.js.map +1 -0
  92. package/dist/standing-receivers.d.ts +280 -0
  93. package/dist/standing-receivers.d.ts.map +1 -0
  94. package/dist/standing-receivers.js +1100 -0
  95. package/dist/standing-receivers.js.map +1 -0
  96. package/dist/transport-selector.d.ts +13 -0
  97. package/dist/transport-selector.d.ts.map +1 -1
  98. package/dist/transport-selector.js.map +1 -1
  99. package/dist/witness-alerts.d.ts +40 -0
  100. package/dist/witness-alerts.d.ts.map +1 -0
  101. package/dist/witness-alerts.js +102 -0
  102. package/dist/witness-alerts.js.map +1 -0
  103. package/package.json +5 -5
@@ -0,0 +1,1457 @@
1
+ import { extractErrorMessage } from "./error-message.js";
2
+ import { generateSaltContribution, SESSION_SALT_BYTES } from "@cello-protocol/crypto";
3
+ import { onPeerSaltFrame, SALT_ADOPTION_LABELS, SALT_FREEZE_GUIDANCE, } from "./session-salt-agreement.js";
4
+ import { UNSALTED_REASONS, UNSALTED_GUIDANCE, SALT_AGREEMENT_WAIT_MS, } from "./session-node-types.js";
5
+ import { contentHashFor, CONTENT_HASH_ALGS } from "./wire-content-hash.js";
6
+ import { CONTENT_ENCRYPTION_REASONS, } from "./content-encryption-status.js";
7
+ export class SessionSalts {
8
+ #ctx;
9
+ constructor(ctx) {
10
+ this.#ctx = ctx;
11
+ }
12
+ /** A getter so the moved queries still read `this.#db` and narrow exactly as they did. */
13
+ get #db() {
14
+ return this.#ctx.db();
15
+ }
16
+ /**
17
+ * DOD-M15-SEALWIRE-1 bullet 6 (part A) — the salt agreement's two pieces of per-session state.
18
+ *
19
+ * `#saltContributions` — OUR random half, MINTED ONCE PER SESSION. This being a map rather than a
20
+ * fresh call at each send is the whole correctness of the exchange: we re-announce on every
21
+ * counterparty connect, and a contribution regenerated per reconnect would have both sides
22
+ * deriving against a moving value with the fingerprints never settling — a session that
23
+ * reconnects and still disagrees, which reads as a network fault rather than a bug here.
24
+ *
25
+ * `#sessionSalts` — a CACHE over `sessions.content_salt`, which is the durable copy. Both are
26
+ * cleared by `#evictSessionCaches`: the contribution is worthless once a salt exists, and the
27
+ * salt is re-read from the row on revival, which is exactly what Decision #8 persists it for.
28
+ */
29
+ #saltContributions = new Map();
30
+ /**
31
+ * ─── B2b-2 state: what the SEND path needs that the row cannot answer ─────────────────────────
32
+ *
33
+ * `#saltPending` — an agreement that has actually gone out and not yet been answered. The first
34
+ * send waits on it (constraint 2). Absent means *nothing is in flight*, which is not the same as
35
+ * "no salt": a park-only session never starts one at all, and must not wait (constraint 5).
36
+ *
37
+ * `#hashedWithoutSalt` — this session has already computed an unsalted content hash. Decision #8
38
+ * closes adoption at the moment content is HASHED, and for a session's first message that is a
39
+ * full network round trip before any leaf, held row or in-flight entry exists. Without this flag
40
+ * the frontier count reads empty for exactly the window in which adopting would split the
41
+ * transcript.
42
+ *
43
+ * `#unsaltedAnnounced` — the fallback has been stated for this session. Decision #15 says once per
44
+ * session; a per-message warning is a filter waiting to be written.
45
+ *
46
+ * All three are per-session and in-memory by design, and are dropped with the rest of a session's
47
+ * caches on eviction — a revived session re-reads its salt from the row, re-derives its frontier
48
+ * from durable state, and starts a fresh agreement if it reconnects.
49
+ */
50
+ #saltPending = new Map();
51
+ /**
52
+ * HOW THE LAST AGREEMENT ENDED, kept after `#saltPending` is cleared.
53
+ *
54
+ * ⚠️ FOUND BY FALSIFYING MY OWN FIX. `#settleSaltPending` deletes the pending entry, so a send that
55
+ * arrives AFTER an agreement has already failed finds nothing pending and is told
56
+ * `no_agreement_started` — *"your counterparty was not connected"* — when in fact they were
57
+ * connected and our own dial to them failed. The outcome was observable only to a send that
58
+ * happened to already be waiting, which is the minority case.
59
+ *
60
+ * So the verdict outlives the wait. An ABSENT entry still means what it always meant — no
61
+ * agreement was ever started, the park-only case — and that distinction is the whole reason this
62
+ * is a separate map rather than a default.
63
+ */
64
+ #saltLastOutcome = new Map();
65
+ #unsaltedAnnounced = new Set();
66
+ #sessionSalts = new Map();
67
+ /**
68
+ * The peer half we last answered with a repair, hex — review F14, and it is what makes the repair
69
+ * TERMINATE. Without it, two daemons that already hold the same salt trade contributions forever
70
+ * once a reconnect leaves a stale copy queued on each side. See `onPeerSaltFrame`'s
71
+ * `alreadyRepairedAgainstPeerHalf`.
72
+ */
73
+ #saltRepairedAgainst = new Map();
74
+ /**
75
+ * THE MIRROR OF THE ABOVE — the peer FINGERPRINT we last answered with our half, hex.
76
+ *
77
+ * 006-CRYPTO finding 1. `#saltRepairedAgainst` terminates the salt-HOLDER's direction only. A side
78
+ * holding no salt answered every fingerprint with its contribution, and a latched holder answers
79
+ * every contribution with its fingerprint — so after one failed persist plus a reconnect, two
80
+ * healthy daemons repair at each other for the life of the session, one new stream and one INFO
81
+ * line each per round trip. Keyed on the peer's fingerprint BYTES for the same reason the other
82
+ * map is keyed on its half: a genuinely NEW fingerprint is new information and must still be
83
+ * answered; only an identical re-offer is the loop.
84
+ */
85
+ #saltRepairedAgainstFingerprint = new Map();
86
+ /**
87
+ * `#saltSuspended` — the peer has told us it can never hold a salt, so ours must not be USED. The
88
+ * bytes stay on disk (`DOD-M15-SALTSPLIT-1`, the other lane's authorization argument).
89
+ *
90
+ * ⚠️ THIS REPLACED AN IMMEDIATE, IRREVERSIBLE ERASE, AND THE REFRAMING IS THE WHOLE POINT.
91
+ *
92
+ * I defended the erase as a compatibility question — a legacy peer might send the misleading frame,
93
+ * we are pre-launch, do not carry weight for a state nobody is in. All true, and it does not reach
94
+ * the question. **It is an AUTHORIZATION question:** the receiver performed an irreversible
95
+ * destruction of durable key material on a peer's bare assertion with nothing to check it against.
96
+ * Re-derived against an empty database — *would I let one side erase the other's key material on an
97
+ * unauthenticated claim carrying no evidence?* No. My own empty-database rule argued FOR a guard,
98
+ * not against one.
99
+ *
100
+ * And my own trigger was the proof I walked past: `frontier_unreadable` is not a legacy peer, it is
101
+ * a **healthy current peer having one bad second**. Fixing the producer made our side stop emitting
102
+ * it wrongly and left the receiver built to obey it — *one side of that exchange correct by
103
+ * construction, the other still correct by luck.*
104
+ *
105
+ * A salt that cannot be used is inert. The destruction is what turned a transient disagreement into
106
+ * a permanent one, so **nothing irreversible hangs on the claim any more** and proving the claim
107
+ * stops being load-bearing.
108
+ *
109
+ * ⚠️ IN MEMORY ON PURPOSE, AND THE ERASE IS DEFERRED RATHER THAN CANCELLED. A durable mark needs a
110
+ * column, and this milestone has lost data twice in the rebuild DDL. In-memory alone would split
111
+ * the transcript at the next restart — unsalted now, salted after a reboot — so the salt IS erased,
112
+ * at the first unsalted hash, which is the moment erasing becomes both harmless (nothing was hashed
113
+ * under it) and REQUIRED (keeping it would re-salt after a restart). Before that moment a corrected
114
+ * announce carrying a matching fingerprint un-suspends and the session recovers fully salted, which
115
+ * erasure makes impossible even in principle: the far side cannot re-derive without both halves.
116
+ *
117
+ * A restart before either outcome loses the mark, we are salted again, the peer refuses one message,
118
+ * and the announce re-runs and re-suspends. **One refused message, then convergence** — against a
119
+ * dead session.
120
+ */
121
+ #saltSuspended = new Set();
122
+ #hashedWithoutSalt = new Map();
123
+ /**
124
+ * `#hashedWithSalt` — how many content hashes this session has computed UNDER its salt and not yet
125
+ * landed anywhere a count can see (`DOD-M15-SALTSPLIT-1`, review HIGH-2).
126
+ *
127
+ * The mirror of `#hashedWithoutSalt`, and it exists for the same window: a hash is computed, then a
128
+ * relay round trip happens, and only afterwards does the message appear as a leaf, a hold or an
129
+ * awaiting-ack entry. In between, every count reads zero.
130
+ *
131
+ * It is read by `#discardUnspentSalt` alone. "Unspent" must mean *nothing has been hashed under
132
+ * it*, and without this the answer is *nothing has FINISHED being hashed under it* — which is the
133
+ * question nobody asked, answered destructively.
134
+ *
135
+ * Never decremented on success: a salted hash that reaches the wire is spent forever, and unlike
136
+ * the unsalted counter there is no `abandonUnsaltedHash` equivalent to undo. It is cleared only
137
+ * with the rest of the session's caches. **For a discard decision, erring toward "spent" is the
138
+ * safe direction** — a salt kept is recoverable, a salt erased is not.
139
+ */
140
+ #hashedWithSalt = new Map();
141
+ /**
142
+ * THE LABEL THE PEER GAVE when it closed adoption — 006-CRYPTO finding 2.
143
+ *
144
+ * The wire carries WHY, `session-salt-agreement.ts` makes it a union so a caller cannot close
145
+ * without saying why, and the agreement's `detail` puts it in the log. It was going no further:
146
+ * `#settleSaltPending(..., "closed")` recorded only that it was closed, so every one of the four
147
+ * reasons arrived at the operator as "they had already hashed messages".
148
+ *
149
+ * Stored raw and rendered through `#peerClosedReason`, which maps anything outside the known set
150
+ * to a non-asserting reason — the peer chooses these bytes.
151
+ */
152
+ #saltPeerClosedLabel = new Map();
153
+ /**
154
+ * cello_list_sessions: every persisted session for one agent, regardless of
155
+ * status (active, interrupted, sealed, seal_interrupted_pending). Ordered most
156
+ * recently updated first so the live session surfaces at the top. This is the
157
+ * discovery surface that the by-id reads (cello_get_transcript /
158
+ * cello_get_sealed_receipt) depend on — without it an agent has no way to learn
159
+ * its own session ids after a restart or from a fresh MCP connection.
160
+ */
161
+ /**
162
+ * DOD-M15-REFUSED-INBOUND-SILENT-1, the DECLINED PROTECTION half — a FIELD, not an alert.
163
+ *
164
+ * An unsalted session is exactly as verifiable as every session shipped before salting existed,
165
+ * so there is nothing to interrupt the operator with and no event to fire. What was missing is
166
+ * STATE: nothing let anyone tell *"unsalted because this build predates the feature"* from
167
+ * *"unsalted because adoption was refused"* — and only the second says something about their
168
+ * setup. The session's own status now answers it, which costs nothing per message and cannot
169
+ * become a flood.
170
+ *
171
+ * The raw salt is dropped on the way out rather than passed through. `SELECT *` was handing the
172
+ * BLOB to a listing surface that has no use for it; the boolean is the whole question a reader of
173
+ * this list is asking, and shipping key material to answer a yes/no is not a trade worth making.
174
+ */
175
+ /**
176
+ * ⚠️ THE STORED COLUMN IS NOT THE ANSWER ON ITS OWN — 006-CRYPTO finding 3.
177
+ *
178
+ * A SUSPENDED salt keeps its bytes on disk deliberately (`DOD-M15-SALTSPLIT-1`: a salt kept is
179
+ * recoverable, a salt erased is not), while `#saltForHashing` returns null for it and every
180
+ * message goes out `sha256`. Reading the column alone therefore reported `true` at the exact
181
+ * moment the session had STOPPED salting — and because the field is emitted only when `false`,
182
+ * the agent saw nothing at all, which reads as "not unsalted".
183
+ *
184
+ * That is precisely the case this field was added for. Its own note above says it exists to tell
185
+ * *"unsalted because this build predates the feature"* from *"unsalted because adoption was
186
+ * refused"*, and the refused case was the one it could not report.
187
+ */
188
+ saltStatusOf(row, agentName) {
189
+ const { content_salt, ...rest } = row;
190
+ const stored = content_salt != null && content_salt.length > 0;
191
+ const suspended = agentName !== null && this.#saltSuspended.has(this.#ctx.sessionKey(agentName, String(row.session_id)));
192
+ /**
193
+ * 007-CRYPTO: the REAL state, not a constant. In 006 this was hardcoded `false` with a single
194
+ * reason, because nothing exchanged keys and saying so was the honest answer. It exchanges now,
195
+ * so a hardcode would be the stale claim that unit existed to remove.
196
+ *
197
+ * A row with no live agent name cannot be looked up in memory — an orphaned session, whose key
198
+ * (if it ever had one) died with the process. Reported as not-agreed rather than guessed at.
199
+ */
200
+ const enc = agentName === null
201
+ ? { key: null, reason: CONTENT_ENCRYPTION_REASONS.NOT_YET_AGREED }
202
+ : this.#ctx.contentEncryptionState(agentName, String(row.session_id));
203
+ return {
204
+ ...rest,
205
+ content_hashes_salted: stored && !suspended,
206
+ content_encrypted: enc.key !== null,
207
+ ...(enc.key === null ? { content_encryption_reason: enc.reason } : {}),
208
+ };
209
+ }
210
+ saltContributionFor(agentName, sessionId) {
211
+ const key = this.#ctx.sessionKey(agentName, sessionId);
212
+ let contribution = this.#saltContributions.get(key);
213
+ if (!contribution) {
214
+ contribution = generateSaltContribution();
215
+ this.#saltContributions.set(key, contribution);
216
+ }
217
+ return contribution;
218
+ }
219
+ /**
220
+ * Our half for a session, **without minting one** — review F1, and the distinction is the whole
221
+ * safety of the repair.
222
+ *
223
+ * A session that already holds a salt must never mint a fresh half. If it did, the repair would
224
+ * offer the peer a half the stored salt was NOT derived from, they would compute a different salt,
225
+ * and both sides would believe they had agreed — silently, which is the one outcome worse than
226
+ * refusing. So `null` from here means exactly "we hold a salt and the half behind it is gone",
227
+ * and that is the only state the agreement is allowed to call unrepairable.
228
+ */
229
+ ownSaltHalf(agentName, sessionId) {
230
+ return this.#saltContributions.get(this.#ctx.sessionKey(agentName, sessionId)) ?? null;
231
+ }
232
+ setSaltContributionForTest(agentName, sessionId, contribution) {
233
+ this.#saltContributions.set(this.#ctx.sessionKey(agentName, sessionId), contribution);
234
+ }
235
+ /**
236
+ * Test seam: drop this session's own half while leaving the stored salt in place — the state every
237
+ * teardown produces, because `#evictSessionCaches` clears the map and the row survives.
238
+ *
239
+ * It clears the SAME map the eviction clears rather than a stand-in, so a test cannot pass against
240
+ * a state the daemon never reaches. Reproducing it through a real teardown/revive would also drag
241
+ * in node rebuild and relay reconnection, none of which this is about.
242
+ */
243
+ forgetSaltContributionForTest(agentName, sessionId) {
244
+ this.#saltContributions.delete(this.#ctx.sessionKey(agentName, sessionId));
245
+ }
246
+ /**
247
+ * THE ONE PLACE THAT DECIDES HOW A SESSION'S OUTBOUND CONTENT IS HASHED —
248
+ * `DOD-M15-SEALWIRE-1` part B2b.
249
+ *
250
+ * Returns the hash AND the algorithm that produced it, together, because the two must not be
251
+ * decided separately. `wire-content-hash.ts` exists for exactly this reason and says so in its own
252
+ * header: the expression was written out at five call sites, the two added last got it wrong, and
253
+ * the failure was invisible — *"the send succeeds, `parked: false`, the sender's log says the frame
254
+ * left, and the receiver discards it at the authenticity check."* It took two real daemons.
255
+ *
256
+ * There are FOUR outbound sites (`session-content-handlers.ts`, two in `daemon.ts`,
257
+ * `document-delivery-transport.ts`). Once salting is switchable, each of them independently
258
+ * deciding whether to salt is that defect again with a worse failure mode — a message hashed one
259
+ * way and LABELLED another is refused by every peer, including a correct one.
260
+ *
261
+ * ⚠️ ASYNC, AND THAT IS THE POINT — B2b-2 constraint 2, not an implementation detail.
262
+ *
263
+ * The agreement is in flight while the operator composes their first message. Hash without waiting
264
+ * and it comes out unsalted, and that first unsalted hash closes adoption for the LIFE of the
265
+ * session (Decision #8, unit 1). Every session would fall back permanently while every log line
266
+ * about it stayed true — the feature present, wired, tested, and never once reached.
267
+ *
268
+ * The wait lives HERE rather than at the four call sites for the same reason `contentHashAlg` is a
269
+ * required parameter rather than a defaulted one: a site that forgets it must fail to compile. A
270
+ * caller that drops the `await` gets a `Promise` where bytes belong, which is a typecheck error;
271
+ * a caller that forgot to call a separate `awaitSaltSettled()` would silently send unsalted.
272
+ */
273
+ async contentHashForSession(agentName, sessionId, content) {
274
+ const { salt, reason } = await this.saltForHashing(agentName, sessionId);
275
+ if (salt !== null) {
276
+ /**
277
+ * ⚠️ THE SALTED HASH MARKS ITSELF SPENT — `DOD-M15-SALTSPLIT-1` review pass 1, HIGH-2.
278
+ *
279
+ * The unsalted branch below has counted itself since review pass 2 F1, for a reason stated
280
+ * there in full: between hashing and `#trackAwaitingAck` there is a relay round trip, and in
281
+ * that window leaves, held content and awaiting-ack ALL read zero. **The salted direction was
282
+ * left with no counterpart**, which was harmless while nothing acted on the answer — and
283
+ * `#discardUnspentSalt` is the first code that acts on it destructively.
284
+ *
285
+ * Without this, a peer's `adoption_closed` frame arriving inside that window finds adoption
286
+ * "open", discards the salt, and the message already on the wire carries
287
+ * `content_hash_alg: hmac-salt-v1` with a hash **nobody — including this daemon — can ever
288
+ * recompute**. The alg is copied verbatim into the parked envelope on TTF expiry, so it
289
+ * survives the round trip that would otherwise have hidden it.
290
+ *
291
+ * A COUNT, not a bit, for the same reason the unsalted side is a count: two connections can be
292
+ * mid-send at once, and one finishing must not clear the claim the other is still relying on.
293
+ */
294
+ const key = this.#ctx.sessionKey(agentName, sessionId);
295
+ this.#hashedWithSalt.set(key, (this.#hashedWithSalt.get(key) ?? 0) + 1);
296
+ const alg = CONTENT_HASH_ALGS.HMAC_SALT_V1;
297
+ return { hash: contentHashFor(content, { alg, salt }), alg };
298
+ }
299
+ /**
300
+ * ⚠️ MARKED BEFORE THE HASH IS RETURNED, and this closes a window the row cannot see.
301
+ *
302
+ * `#saltAdoptionClosed` counts leaves, held content and in-flight sends. For the FIRST message of
303
+ * a session none of the three exists at this moment — the leaf lands after `sendContent` returns,
304
+ * which is a network round trip later. A peer contribution arriving in that gap would be adopted,
305
+ * and the message already on the wire would become the single unsalted leaf in an otherwise
306
+ * salted transcript: the exact split Decision #8 forbids, reached by the one route every count
307
+ * reads as empty.
308
+ *
309
+ * In memory rather than in a column, and that is sufficient rather than convenient: if this
310
+ * process survives, the flag holds; if it does not, the message it protects either reached a
311
+ * durable form (leaf, held row, queued row — all of which the counts see) or never left, in which
312
+ * case there is nothing to split. The one remaining case — hashed, sent, and no local record —
313
+ * is covered from the other side, because the peer DID leaf it and closes its own adoption, and
314
+ * the wire state added in unit 1 tells us so.
315
+ */
316
+ /**
317
+ * ⚠️ NOT FOR A TORN-DOWN SESSION — review Finding 5. `#evictSessionCaches` settles the wait and
318
+ * clears both of these sets; a `.add()` afterwards re-populates a map whose eviction has already
319
+ * run, and the entries then outlive the session they describe. There is also nothing to protect:
320
+ * a session that no longer exists cannot adopt a salt or split a transcript.
321
+ */
322
+ /**
323
+ * ⚠️ THE DEFERRED ERASE — `DOD-M15-SALTSPLIT-1`. This is the moment a suspended salt becomes both
324
+ * harmless to erase and NECESSARY to erase, and it must run BEFORE the count below.
325
+ *
326
+ * Harmless: this session has hashed nothing under the salt, which is what let it be suspended.
327
+ * Necessary: we are about to hash unsalted, and a salt left on disk reads back fine after a
328
+ * restart — so the next process would hash salted and the transcript would be split down the
329
+ * middle by a reboot rather than by any frame.
330
+ *
331
+ * **Before the `#hashedWithoutSalt` increment on purpose.** `#discardUnspentSalt` refuses to erase
332
+ * once adoption is closed, and that counter is one of the things that closes it — increment first
333
+ * and the erase we just decided is correct gets refused by our own guard, leaving exactly the
334
+ * split this ordering exists to prevent.
335
+ */
336
+ if (reason !== UNSALTED_REASONS.SESSION_TORN_DOWN && this.#saltSuspended.has(this.#ctx.sessionKey(agentName, sessionId))) {
337
+ /**
338
+ * ⚠️ GOING UNSALTED AND ERASING THE SALT ARE ONE DECISION — pass 2, F2 (HIGH), and this is my
339
+ * regression, not a pre-existing one.
340
+ *
341
+ * The note above claimed the ordering was sufficient because `#hashedWithoutSalt` is what
342
+ * closes adoption. **It is one of FOUR contributors.** Leaves, held rows and awaiting-ack close
343
+ * it too — and the most ordinary event in the protocol closes it: *the peer sends us its next
344
+ * message.* Reproduced through the real inbound path: suspend, peer's message lands as leaf 0,
345
+ * we hash `sha256`, and the erase is REFUSED with `already_hashing` while the bytes stay on
346
+ * disk. One teardown-and-revive later — no process restart required — we hash `hmac` again.
347
+ * That is the split transcript, produced by the fix for the split transcript.
348
+ *
349
+ * Worth naming precisely: **the immediate-erase design this replaced could NOT produce it.**
350
+ * There, a refused discard simply kept the session salted — one rule throughout, and loud.
351
+ * Suspension is what made "unsalted now, salted later" reachable. Same shape as pass 1: the fix
352
+ * worse than the defect on one path.
353
+ *
354
+ * So the two are atomic. If the salt cannot be erased, we do **not** go unsalted — we keep
355
+ * hashing under the held salt, which is one rule for the whole session, and say so at ERROR.
356
+ * The counterparty may refuse those messages, and that is the honest failure: a dead session
357
+ * beats a transcript no single rule can verify. The durable column remains the real answer.
358
+ */
359
+ if (!this.discardUnspentSalt(agentName, sessionId)) {
360
+ const stillHeld = this.getSessionSalt(agentName, sessionId);
361
+ if (stillHeld !== null) {
362
+ const key = this.#ctx.sessionKey(agentName, sessionId);
363
+ this.#ctx.logger.error("session.salt.split", {
364
+ agentName, sessionId, reason: "suspended_but_unerasable",
365
+ 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.",
366
+ 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.",
367
+ });
368
+ this.#hashedWithSalt.set(key, (this.#hashedWithSalt.get(key) ?? 0) + 1);
369
+ const alg = CONTENT_HASH_ALGS.HMAC_SALT_V1;
370
+ return { hash: contentHashFor(content, { alg, salt: stillHeld }), alg };
371
+ }
372
+ }
373
+ }
374
+ if (reason !== UNSALTED_REASONS.SESSION_TORN_DOWN) {
375
+ /**
376
+ * ⚠️ A COUNT, NOT A BIT — review pass 2, F1 (HIGH). It was a `Set`, and that made it ONE FLAG
377
+ * PER SESSION for a fact that is per MESSAGE.
378
+ *
379
+ * The `sibling_send_in_flight` refusal path exists precisely when another connection is
380
+ * mid-send with an unsalted hash it computed itself — and `sendContent` awaits a full relay
381
+ * round trip before `#trackAwaitingAck` records anything. So: connection A hashes and sets the
382
+ * flag; A enters that round trip, visible in no count; connection B hashes, sees A's claim,
383
+ * refuses, and calls `abandonUnsaltedHash` — **deleting the flag A is still relying on.** The
384
+ * frontier then reads entirely empty, a salt frame arriving in that window is adopted, and A's
385
+ * message lands as leaf 0 hashed sha256 in a session that hashes everything after it under
386
+ * HMAC.
387
+ *
388
+ * That is the split transcript this unit exists to prevent, through a window a relay round
389
+ * trip wide. A count makes each in-flight hash hold its own claim.
390
+ */
391
+ const key = this.#ctx.sessionKey(agentName, sessionId);
392
+ this.#hashedWithoutSalt.set(key, (this.#hashedWithoutSalt.get(key) ?? 0) + 1);
393
+ }
394
+ /**
395
+ * NO `??` DEFAULT — review pass 2, F6. It read `reason ?? ADOPTION_CLOSED_LOCALLY`, which is the
396
+ * shape the closed set was built to eliminate: a seventh return path forgetting its reason would
397
+ * have been silently labelled *"you already hashed"* and inherited guidance about a frontier that
398
+ * never moved. `#saltForHashing` returns a discriminated union now, so a null salt without a
399
+ * reason does not compile.
400
+ */
401
+ this.announceUnsaltedOnce(agentName, sessionId, reason);
402
+ const alg = CONTENT_HASH_ALGS.SHA256;
403
+ return { hash: contentHashFor(content, { alg, salt: null }), alg };
404
+ }
405
+ /**
406
+ * The salt to hash this session's next message under, waiting for a pending agreement if one is
407
+ * genuinely in flight — B2b-2 constraints 2 and 5.
408
+ *
409
+ * Three exits, and the order matters:
410
+ *
411
+ * 1. We already hold one. No wait, ever.
412
+ * 2. Adoption is closed — this session has hashed or leafed something already, so a salt could
413
+ * never be adopted now even if one arrived. Waiting would be waiting for a value we would
414
+ * then have to refuse.
415
+ * 3. Nothing is pending. **This is the park-only case (constraint 5)**: the announcement hangs
416
+ * off `onPeerConnect`, an offline counterparty never connects, so no agreement was ever
417
+ * started. Waiting the full bound there pauses every message to an offline peer and falls
418
+ * back anyway — a stall bought for nothing.
419
+ *
420
+ * Only a session with an agreement actually in flight waits, and only until it settles or the
421
+ * bound expires.
422
+ */
423
+ async saltForHashing(agentName, sessionId) {
424
+ const key = this.#ctx.sessionKey(agentName, sessionId);
425
+ const held = this.getSessionSalt(agentName, sessionId);
426
+ if (held !== null) {
427
+ /**
428
+ * SUSPENDED BEATS HELD — `DOD-M15-SALTSPLIT-1`. The peer has said it can never hold a salt, so
429
+ * hashing under ours produces a message it must refuse. We hold one and deliberately do not
430
+ * use it.
431
+ *
432
+ * ⚠️ An earlier note here said `PEER_CLOSED_ADOPTION` "already carries exactly the right
433
+ * guidance, so no new reason is needed and none is invented." That was right about not
434
+ * inventing a reason and wrong about which one applies: the peer can suspend us for any of
435
+ * four reasons, and the one hardcoded here asserted the most flattering of them. It now asks
436
+ * the same mapping every other closed path asks (006-CRYPTO finding 2).
437
+ */
438
+ if (this.#saltSuspended.has(key)) {
439
+ return { salt: null, reason: this.peerClosedReason(key) };
440
+ }
441
+ return { salt: held };
442
+ }
443
+ if (this.saltAdoptionClosed(agentName, sessionId).closed) {
444
+ return { salt: null, reason: UNSALTED_REASONS.ADOPTION_CLOSED_LOCALLY };
445
+ }
446
+ const pending = this.#saltPending.get(key);
447
+ if (pending === undefined) {
448
+ // An agreement that already ENDED is not an agreement that never started. Only the second is
449
+ // "your counterparty was not connected", and only an absent entry means it.
450
+ const last = this.#saltLastOutcome.get(key);
451
+ if (last !== undefined)
452
+ return { salt: null, reason: this.reasonForOutcome(key, last) };
453
+ return { salt: null, reason: UNSALTED_REASONS.NO_AGREEMENT_STARTED };
454
+ }
455
+ const settled = await pending.settled;
456
+ if (settled === "agreed") {
457
+ const agreed = this.getSessionSalt(agentName, sessionId);
458
+ /**
459
+ * A settled-`agreed` that reads back NULL is a READ failure, not a persist failure — pass 2,
460
+ * F4. `persist_failed` has its own outcome now, so the only way to arrive here empty is
461
+ * `#getSessionSalt` returning null after the salt was stored: a throwing read, or a
462
+ * wrong-width row, with the cache evicted in the microtask between settle and resume. Rare —
463
+ * and labelling it `our_persist_failed` sent the operator to look for a
464
+ * `session.salt.persist.failed` line that will not be there.
465
+ */
466
+ return agreed !== null
467
+ ? { salt: agreed }
468
+ : { salt: null, reason: UNSALTED_REASONS.OUR_READ_FAILED };
469
+ }
470
+ if (settled === "announce_failed") {
471
+ return { salt: null, reason: UNSALTED_REASONS.ANNOUNCE_FAILED };
472
+ }
473
+ if (settled === "persist_failed") {
474
+ // Named separately from the timeout on purpose: the peer answered in time and OUR write
475
+ // failed, so nothing about their build is involved and sending the operator there wastes them.
476
+ return { salt: null, reason: UNSALTED_REASONS.OUR_PERSIST_FAILED };
477
+ }
478
+ if (settled === "closed") {
479
+ /**
480
+ * Two very different things reach `closed`, and only one of them is about the counterparty.
481
+ *
482
+ * `#handleSaltFrame`'s terminal branch — the peer told us it cannot adopt — is a settled
483
+ * bilateral outcome and the session is fine. `#evictSessionCaches` — this session is being
484
+ * torn down underneath us — is not: there is no session left to be unsalted, and a caller that
485
+ * marks `#hashedWithoutSalt` for it re-populates a map whose eviction has already run
486
+ * (review Finding 5). `#saltPending` is gone by the time we look, so the live node is what
487
+ * distinguishes them.
488
+ */
489
+ return {
490
+ salt: null,
491
+ reason: this.#ctx.activeEntry(key) !== undefined
492
+ ? UNSALTED_REASONS.PEER_CLOSED_ADOPTION
493
+ : UNSALTED_REASONS.SESSION_TORN_DOWN,
494
+ };
495
+ }
496
+ if (settled === "timeout") {
497
+ /**
498
+ * A DECISION, NOT A RETRY. Logged once, here, because this is the moment the session became
499
+ * permanently unsalted — and an operator reading a later `session.content.unsalted` needs to
500
+ * be able to find out WHY this session has no salt when their others do.
501
+ */
502
+ this.#ctx.logger.warn("session.salt.agreement.timeout", {
503
+ agentName, sessionId, waitedMs: pending.boundMs,
504
+ impact: "the counterparty did not answer the salt agreement in time, so this session is unsalted FOR ITS LIFE — the message is being sent now rather than held any longer. Nothing is lost and nothing is degraded relative to any shipped release.",
505
+ // Review F4: `session.salt.persist.failed` reaches this same timeout by a completely
506
+ // different route — the peer answered promptly and OUR OWN write failed, so we returned
507
+ // before announcing and nothing came back. Omitting it sent that operator to ask their
508
+ // counterparty about a version mismatch that was never involved.
509
+ guidance: "Most often the counterparty is on a build that predates the salt agreement, in which case this is expected and permanent for this session — a newer one will agree normally. If you know they are on the same version, look for session.salt.persist.failed on THIS side first (our own write failing produces this same timeout), then session.salt.announce.failed on either side.",
510
+ });
511
+ return { salt: null, reason: UNSALTED_REASONS.AGREEMENT_TIMED_OUT };
512
+ }
513
+ return { salt: null, reason: UNSALTED_REASONS.AGREEMENT_TIMED_OUT };
514
+ }
515
+ /**
516
+ * Decision #15's fallback announcement — ONCE per session, never per message.
517
+ *
518
+ * A warning that fires on every message of every unsalted session is not a signal, it is a reason
519
+ * to build a filter; and the operator who filters it also filters the one session where it meant
520
+ * something. Stated once, with what the session actually loses.
521
+ */
522
+ announceUnsaltedOnce(agentName, sessionId, reason) {
523
+ const key = this.#ctx.sessionKey(agentName, sessionId);
524
+ if (this.#unsaltedAnnounced.has(key))
525
+ return;
526
+ this.#unsaltedAnnounced.add(key);
527
+ this.#ctx.logger.info("session.content.unsalted", {
528
+ agentName, sessionId,
529
+ // The REASON is the field that makes this line diagnosable, and it was the missing one. The
530
+ // impact is the same for all six; what to do about it is not.
531
+ reason,
532
+ impact: "this session hashes its messages the way every build before this feature did. Nothing is degraded relative to any shipped release and no message is affected — it only means a relay holding the hashes could confirm a guess at a short message in THIS conversation, which a salt would have prevented.",
533
+ guidance: UNSALTED_GUIDANCE[reason],
534
+ });
535
+ }
536
+ /**
537
+ * Register that a salt agreement is IN FLIGHT for this session, so the first send waits for it.
538
+ *
539
+ * Called where we announce our own state — not at session creation. That distinction is
540
+ * constraint 5: an agreement exists to be waited for only once a frame has actually gone out.
541
+ */
542
+ markSaltPending(agentName, sessionId, boundMs = SALT_AGREEMENT_WAIT_MS) {
543
+ const key = this.#ctx.sessionKey(agentName, sessionId);
544
+ if (this.#saltPending.has(key))
545
+ return;
546
+ let resolve = () => { };
547
+ const settled = new Promise((r) => { resolve = r; });
548
+ const timer = setTimeout(() => this.settleSaltPending(agentName, sessionId, "timeout"), boundMs);
549
+ // The daemon must be able to exit with this outstanding — a pending agreement is not a reason to
550
+ // hold the process open.
551
+ if (typeof timer.unref === "function")
552
+ timer.unref();
553
+ this.#saltPending.set(key, { settled, resolve, timer, boundMs });
554
+ }
555
+ /**
556
+ * WHICH of the four terminal answers the peer actually gave — 006-CRYPTO finding 2.
557
+ *
558
+ * The default is the NON-ASSERTING reason, not the most common one. An unknown label means a build
559
+ * we do not understand, and rendering that as "they had already hashed messages" states something
560
+ * about a counterparty that may be untrue — which is what sends an operator to raise a
561
+ * non-problem with them. The label is peer-supplied, so nothing outside the known set is repeated
562
+ * back as our own diagnosis.
563
+ *
564
+ * A missing entry maps to the already-hashing case: `PEER_CLOSED_FIRST` and an absent label both
565
+ * mean the peer is answering a closure of OURS, and `#saltForHashing` answers that with
566
+ * `ADOPTION_CLOSED_LOCALLY` one branch earlier — this is only the fallback if it did not.
567
+ */
568
+ peerClosedReason(key) {
569
+ const label = this.#saltPeerClosedLabel.get(key);
570
+ if (label === undefined || label === SALT_ADOPTION_LABELS.PEER_CLOSED_FIRST) {
571
+ return UNSALTED_REASONS.PEER_CLOSED_ADOPTION;
572
+ }
573
+ if (label === SALT_ADOPTION_LABELS.ALREADY_HASHING)
574
+ return UNSALTED_REASONS.PEER_CLOSED_ADOPTION;
575
+ if (label === SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE)
576
+ return UNSALTED_REASONS.PEER_FRONTIER_UNREADABLE;
577
+ if (label === SALT_ADOPTION_LABELS.EXCHANGE_STALLED)
578
+ return UNSALTED_REASONS.PEER_EXCHANGE_STALLED;
579
+ return UNSALTED_REASONS.PEER_CLOSED_UNSPECIFIED;
580
+ }
581
+ /** Resolve a pending agreement. Idempotent: the first outcome wins and the timer is cleared. */
582
+ settleSaltPending(agentName, sessionId, outcome) {
583
+ const key = this.#ctx.sessionKey(agentName, sessionId);
584
+ const pending = this.#saltPending.get(key);
585
+ if (pending === undefined)
586
+ return;
587
+ this.#saltPending.delete(key);
588
+ // `agreed` is not recorded: the salt itself is the record, and `#getSessionSalt` answers first.
589
+ if (outcome !== "agreed")
590
+ this.#saltLastOutcome.set(key, outcome);
591
+ clearTimeout(pending.timer);
592
+ pending.resolve(outcome);
593
+ }
594
+ /**
595
+ * THIS SESSION'S UNSALTED HASH NEVER BECAME A MESSAGE — release the permanent closure it caused.
596
+ *
597
+ * ⚠️ REVIEW FINDING 3, and it is the opposite of the direction the flag was written to defend.
598
+ * `#hashedWithoutSalt` closes adoption at hash time, because for a session's first message the
599
+ * leaf is a network round trip away and every frontier count reads zero in between. Correct — but
600
+ * `cello_send` has three paths that compute the hash and then produce NOTHING: a sibling send
601
+ * holding the in-flight claim, the frontier moving under the send, and a non-durable send failure
602
+ * whose bytes go to a queue with no production consumer.
603
+ *
604
+ * In all three the session was permanently unsalted for a message that exists nowhere: no leaf, no
605
+ * wire, no copy at the peer. And B2b-2 made two of them MORE likely on a first message, because
606
+ * the five-second wait widens the very window the frontier re-check is watching.
607
+ *
608
+ * Only safe because it is called on paths that provably sent nothing. It deliberately does NOT
609
+ * clear `#unsaltedAnnounced`: the announcement was true when it fired and re-announcing on the
610
+ * retry would be the per-message flood Decision #15 forbids.
611
+ *
612
+ * ─── THREE OTHER SITES HASH AND MAY SEND NOTHING, AND ARE EXEMPT ON PURPOSE (pass 2, F8) ──────
613
+ *
614
+ * `daemon.ts`'s one-shot rejection and away reply, and `document-delivery-transport.ts`'s frame
615
+ * send, can all fail after hashing. None of them needs to abandon, and the reason is the same in
616
+ * each: every one is a REPLY. The inbound message that triggered it has already been leafed on
617
+ * this side, so `#saltAdoptionClosed` is already closed by the leaf count and would stay closed
618
+ * whatever this flag said. Calling abandon there would be a no-op that looks like a guarantee.
619
+ *
620
+ * Written down rather than left to be re-derived: the next reader's first question is why the
621
+ * list is three and not six.
622
+ */
623
+ abandonUnsaltedHash(agentName, sessionId) {
624
+ const key = this.#ctx.sessionKey(agentName, sessionId);
625
+ const held = this.#hashedWithoutSalt.get(key) ?? 0;
626
+ if (held === 0)
627
+ return;
628
+ // DECREMENT, never delete — F1. Deleting released a sibling's claim along with this one.
629
+ if (held > 1) {
630
+ this.#hashedWithoutSalt.set(key, held - 1);
631
+ return;
632
+ }
633
+ this.#hashedWithoutSalt.delete(key);
634
+ /**
635
+ * INFO, not DEBUG — review pass 2, F3. `session.content.unsalted` has already told this operator
636
+ * at INFO that the session is unsalted *"permanently… start a new session if you want the
637
+ * protection."* That statement is now false, and a retraction logged below the level of the
638
+ * claim it retracts is not a retraction. The announcement itself is deliberately NOT re-armed —
639
+ * re-announcing on the retry is the per-message flood Decision #15 forbids.
640
+ */
641
+ this.#ctx.logger.info("session.content.unsalted.retracted", {
642
+ agentName, sessionId,
643
+ impact: "a hash computed unsalted never became a message — no leaf, nothing on the wire, no copy at the counterparty — so this session CAN still adopt a salt. An earlier session.content.unsalted line said the session was permanently unsalted; that no longer applies.",
644
+ });
645
+ }
646
+ /**
647
+ * PUBLIC read of a session's agreed salt — `DOD-M15-SEALWIRE-1` part B2a.
648
+ *
649
+ * `content-park.ts` runs a SECOND, independent content-hash verifier (the park signature does not
650
+ * cover the envelope content, so it checks before `ingestReceivedContent` is ever reached), and it
651
+ * hardcoded `sha256`. It needs the salt to verify a v3 envelope, and it is outside this class.
652
+ *
653
+ * Read-only and cache-backed, so exposing it adds no way to CHANGE the salt from outside — the
654
+ * only writer remains `#persistSessionSalt`, behind the one-salt-per-session predicate.
655
+ */
656
+ /**
657
+ * IS THIS SESSION ACTUALLY PROTECTED BY ITS SALT RIGHT NOW — pass 2, F3.
658
+ *
659
+ * Distinct from `getSessionContentSalt`, which is POSSESSION and is what the verifier needs: a
660
+ * message parked before suspension was hashed under this salt and must still be checkable against
661
+ * it, so that accessor must keep answering with the bytes.
662
+ *
663
+ * This one answers the OPERATOR's question, and it is a different question. A suspended session
664
+ * holds a salt it will not use, so every hash it produces is `sha256` — reporting `contentSalted:
665
+ * true` there is not a gap, it is an affirmatively false security claim on the surface whose own
666
+ * comment reads *"a security property must not be inferable from a gap."* Same predicate
667
+ * `#saltForHashing` uses, so the flag cannot drift from the behaviour it describes.
668
+ */
669
+ isContentSaltActive(agentName, sessionId) {
670
+ if (this.#saltSuspended.has(this.#ctx.sessionKey(agentName, sessionId)))
671
+ return false;
672
+ return this.getSessionSalt(agentName, sessionId) !== null;
673
+ }
674
+ /**
675
+ * This session's agreed salt, or null. Reads the durable row through a cache, because Decision #8
676
+ * persists it for exactly one reason: *"a restart silently splits the transcript"* if the lookup
677
+ * misses and a fresh salt is minted.
678
+ *
679
+ * A read failure returns null WITH a log rather than throwing — except the bare `!this.#db` guard,
680
+ * which is this file's convention at 60+ sites and only reachable during shutdown. Null means "we
681
+ * hold no salt", which drives the agreement to offer a contribution — and against a peer that does
682
+ * hold one that is a named, loud `salt_state_divergent` refusal. So the degraded path ends in a
683
+ * diagnosis, not in a session that quietly hashes under the wrong value.
684
+ *
685
+ * ⚠️ THIS PARAGRAPH SPENT A UNIT STRANDED 180 LINES AWAY, directly above `contentHashForSession`
686
+ * and followed by that method's own block — so a reader hovering the hash decision got prose about
687
+ * salt read failures. Harmless and exactly the kind of drift that makes a comment stop being read.
688
+ */
689
+ getSessionSalt(agentName, sessionId) {
690
+ const key = this.#ctx.sessionKey(agentName, sessionId);
691
+ const cached = this.#sessionSalts.get(key);
692
+ if (cached)
693
+ return cached;
694
+ if (!this.#db)
695
+ return null;
696
+ try {
697
+ const row = this.#db
698
+ .prepare("SELECT content_salt FROM sessions WHERE agent_id = ? AND session_id = ?")
699
+ .get(this.#ctx.requireAgentId(agentName), sessionId);
700
+ const stored = row?.content_salt;
701
+ if (!stored || stored.length === 0)
702
+ return null;
703
+ /**
704
+ * A WRONG-WIDTH ROW IS NOT A SALT — review F8.
705
+ *
706
+ * Any non-empty blob used to be accepted, so a truncated row became "our salt", the digests
707
+ * then differed, and the operator was told *"one of you is running an older build — compare
708
+ * versions with them"*: sent to their counterparty over corruption on their own disk. Refusing
709
+ * it here makes this side hold NO salt, which re-offers a contribution and repairs.
710
+ */
711
+ if (stored.length !== SESSION_SALT_BYTES) {
712
+ this.#ctx.logger.error("session.salt.read.failed", {
713
+ agentName, sessionId, storedBytes: stored.length, expected: SESSION_SALT_BYTES,
714
+ reason: "wrong_width",
715
+ impact: "the stored salt is the wrong size, so it is not used; this session is treated as holding no salt and will re-agree one with the counterparty rather than comparing a corrupt value and blaming their build",
716
+ });
717
+ return null;
718
+ }
719
+ const salt = new Uint8Array(stored);
720
+ this.#sessionSalts.set(key, salt);
721
+ return salt;
722
+ }
723
+ catch (err) {
724
+ this.#ctx.logger.error("session.salt.read.failed", {
725
+ agentName, sessionId, error: extractErrorMessage(err),
726
+ impact: "this session is treated as holding no salt, so it will offer a fresh contribution; against a counterparty that still holds theirs the agreement refuses by name rather than hashing under a value only one side has",
727
+ });
728
+ return null;
729
+ }
730
+ }
731
+ /**
732
+ * Is this session past the point where a salt can be adopted? — Decision #8, part B2b-2.
733
+ *
734
+ * ⚠️ THE PREDICATE IS "HAS ANYTHING BEEN HASHED", NOT "IS THERE A LEAF" — review F5. A leaf is
735
+ * APPENDED after `await sendContent(...)` returns, so a message can be hashed, put on the wire, and
736
+ * still be invisible to `tree.size()`. Adopting inside that window makes leaf 0 unsalted and the
737
+ * rest salted — the exact split this exists to prevent, with the guard green.
738
+ *
739
+ * ⚠️ HELD CONTENT COUNTS, AND MUST BE HYDRATED FIRST — review F6. `#ensureHeldRestored` is lazy and
740
+ * is not called at session-node creation, so a revived session whose first inbound frame is the
741
+ * salt frame reads a frontier that excludes durable `held_content` rows — rows already hashed
742
+ * unsalted, which `#releaseHeld` will append moments later. Every other frontier reader in this
743
+ * file hydrates first, for this reason. `release: false`, because a salt frame must never deliver
744
+ * messages as a side effect.
745
+ *
746
+ * ⚠️ "CANNOT TELL" IS CLOSED, NOT OPEN. `#requireAgentId` throws for a retired agent, and inferring
747
+ * "zero leaves" from a failure to count them is how a guard becomes a formality. The cost of
748
+ * refusing is an unsalted session; the cost of permitting is a transcript neither rule can verify.
749
+ */
750
+ saltAdoptionClosed(agentName, sessionId) {
751
+ try {
752
+ this.#ctx.ensureHeldRestored(agentName, sessionId, { release: false });
753
+ }
754
+ catch { /* hydration is best-effort; the counts below still refuse on their own failure */ }
755
+ try {
756
+ const key = this.#ctx.sessionKey(agentName, sessionId);
757
+ const leaves = this.#ctx.getSessionTree(agentName, sessionId).size();
758
+ const held = this.#ctx.heldContentFor(key)?.size ?? 0;
759
+ const inFlight = this.#ctx.awaitingAck.get(key)?.size ?? 0;
760
+ /**
761
+ * ⚠️ THE HASH ITSELF COUNTS — B2b-2, and none of the three counts above can see it.
762
+ *
763
+ * Decision #8 closes adoption when content is HASHED. For a session's first message the leaf
764
+ * lands after `sendContent` returns, a network round trip later; there is no held row and no
765
+ * in-flight entry yet either. So between the hash and the leaf every count reads zero, and a
766
+ * peer contribution arriving in that window would be adopted — leaving the message already on
767
+ * the wire as the one unsalted leaf in a salted transcript.
768
+ */
769
+ const hashed = this.#hashedWithoutSalt.get(key) ?? 0;
770
+ const total = leaves + held + inFlight + hashed;
771
+ return {
772
+ closed: total > 0,
773
+ // The label crosses the WIRE, so it carries no counts and no error text — only which of the
774
+ // two refusals this is. The counts stay in `why`, which stays local.
775
+ label: SALT_ADOPTION_LABELS.ALREADY_HASHING,
776
+ leafCount: total,
777
+ why: `leaves=${leaves} held=${held} awaiting_ack=${inFlight} hashed=${hashed}`,
778
+ };
779
+ }
780
+ catch (err) {
781
+ return {
782
+ closed: true,
783
+ label: SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE,
784
+ leafCount: -1,
785
+ why: `frontier_unreadable: ${extractErrorMessage(err)}`,
786
+ };
787
+ }
788
+ }
789
+ /**
790
+ * Persist the agreed salt, and DO NOT ANNOUNCE ONE WE FAILED TO STORE.
791
+ *
792
+ * The caller sends its fingerprint only if this returns true. A salt held in memory and not on
793
+ * disk would confirm agreement to the counterparty and then be gone at the next restart — turning
794
+ * a loud `salt_state_divergent` refusal, which is the whole point of Decision #10, into the silent
795
+ * split it exists to prevent, one restart later.
796
+ *
797
+ * ⚠️ SECOND ORPHAN OF THE SAME KIND. This paragraph was stranded above `#saltAdoptionClosed` and
798
+ * followed by that method's own block, exactly like the `#getSessionSalt` one re-homed in the
799
+ * previous pass — which walked straight past this one sixty lines below it. Two in one file is not
800
+ * coincidence: inserting a method between a doc block and its subject leaves no error, no lint,
801
+ * and no test, so the drift is invisible until someone reads for it.
802
+ */
803
+ persistSessionSalt(agentName, sessionId, salt) {
804
+ if (!this.#db) {
805
+ // NOT a silent return — review F7. The other two persist failures each emit an event, so a
806
+ // derive that could not store because the handle is closed was the ONE salt path producing no
807
+ // record at all. Only reachable during shutdown, which is exactly when a lone unexplained
808
+ // gap in the log is hardest to account for later.
809
+ this.#ctx.logger.error("session.salt.persist.failed", {
810
+ agentName, sessionId, reason: "db_closed",
811
+ impact: "the salt was NOT stored and is not announced; the agreement stays open and re-runs on the next connect",
812
+ });
813
+ return false;
814
+ }
815
+ try {
816
+ /**
817
+ * ORDER MATTERS HERE, and getting it wrong cost three findings — review F3, F4, F7.
818
+ *
819
+ * The adoption guard used to run FIRST, above `!this.#db` and outside this `try`. That:
820
+ * - short-circuited the `salt_already_stored` discrimination below, so a session that DOES
821
+ * hold a valid salt was told it "stays unsalted FOR THE LIFE of the session" after a
822
+ * transient read failure — a refusal asserting something false about the row (F4);
823
+ * - put `getSessionTree`'s `#requireAgentId` throw outside the `try`, where it surfaced as
824
+ * *"the stream read failed"* instead of a named salt-persist failure (F7).
825
+ *
826
+ * So the row's own state is established first, and only a session with no salt at all reaches
827
+ * the adoption question.
828
+ */
829
+ const existingRow = this.#db
830
+ .prepare("SELECT length(content_salt) AS n FROM sessions WHERE agent_id = ? AND session_id = ?")
831
+ .get(this.#ctx.requireAgentId(agentName), sessionId);
832
+ if (existingRow?.n === SESSION_SALT_BYTES) {
833
+ this.#ctx.logger.error("session.salt.persist.failed", {
834
+ agentName, sessionId, reason: "salt_already_stored",
835
+ impact: "this session already has a salt and it was NOT replaced — Decision #8 is one salt per session. Reaching here means a read failure made this side believe it had none; the stored salt is intact, nothing was announced, and the agreement re-runs against it on the next connect.",
836
+ });
837
+ return false;
838
+ }
839
+ const adoption = this.saltAdoptionClosed(agentName, sessionId);
840
+ if (adoption.closed) {
841
+ this.#ctx.logger.warn("session.salt.adoption.refused", {
842
+ agentName, sessionId, reason: "already_hashing", leafCount: adoption.leafCount, frontier: adoption.why,
843
+ impact: "this session has already hashed content under the unsalted rule, so the salt was NOT adopted — it stays unsalted FOR THE LIFE of the session. Adopting now would hash the rest of the conversation differently and leave a transcript that neither rule can verify end to end.",
844
+ guidance: "Nothing is broken and no message was lost: an unsalted session is exactly as verifiable as every session before this feature existed. It only means a relay holding the hashes could confirm a guess at a short message in THIS conversation. If you want the protection, start a new session — the agreement runs at open, before anything is hashed.",
845
+ });
846
+ return false;
847
+ }
848
+ /**
849
+ * THE ROW COUNT IS THE CHECK, and without it this method reported success for a write that
850
+ * stored nothing.
851
+ *
852
+ * An `UPDATE` that matches no row does not throw — it returns `changes: 0`. So a session whose
853
+ * row is missing (retired agent, a row that failed to write at creation, an id that does not
854
+ * line up) took the success branch, cached the salt in memory, and announced our fingerprint
855
+ * to the counterparty. Agreement confirmed, nothing on disk, and the failure surfaces at the
856
+ * next restart as the divergence this whole design exists to make loud — except one restart
857
+ * late and with both sides believing they had agreed.
858
+ *
859
+ * Found by a mutant that removed the caller's `if (!persisted) return`: the suite stayed green,
860
+ * because nothing could produce a false from here.
861
+ */
862
+ /**
863
+ * ONE SALT PER SESSION, ENFORCED AT THE WRITE — review F18.
864
+ *
865
+ * This `UPDATE` was unconditional, so it could replace an already-stored VALID salt. The path
866
+ * is real: `#getSessionSalt` returns null on a transient read failure, which sends this side
867
+ * down the derive path, which then overwrote the perfectly good salt on disk. The read error
868
+ * was logged; the destruction of the durable value was not — and the read log actively said
869
+ * the wrong thing, promising only that we would "offer a fresh contribution".
870
+ *
871
+ * The predicate has to allow ONE overwrite: a wrong-width blob is refused by `#getSessionSalt`
872
+ * (F8) precisely so a corrupt row can be replaced rather than stranding the session forever.
873
+ * So: write when there is nothing there, or when what is there is not a salt.
874
+ */
875
+ const written = this.#db
876
+ .prepare("UPDATE sessions SET content_salt = ? WHERE agent_id = ? AND session_id = ? " +
877
+ "AND (content_salt IS NULL OR length(content_salt) <> ?)")
878
+ .run(Buffer.from(salt), this.#ctx.requireAgentId(agentName), sessionId, SESSION_SALT_BYTES);
879
+ if (Number(written.changes) !== 1) {
880
+ // WHICH of the two it was. "No row" is a broken session record; "a salt is already there" is
881
+ // this guard doing its job, and telling an operator the row is missing when it is not would
882
+ // send them to look at the wrong thing.
883
+ // The `salt_already_stored` case is decided above now, before the adoption question, so
884
+ // reaching here with a valid salt in the row is not possible. Re-read anyway rather than
885
+ // assume: a wrong-width blob also fails the predicate and must not be reported as a missing
886
+ // row, which would send the operator to look at session state for a corrupt value.
887
+ const existing = this.#db
888
+ .prepare("SELECT length(content_salt) AS n FROM sessions WHERE agent_id = ? AND session_id = ?")
889
+ .get(this.#ctx.requireAgentId(agentName), sessionId);
890
+ const alreadyStored = existing?.n === SESSION_SALT_BYTES;
891
+ this.#ctx.logger.error("session.salt.persist.failed", {
892
+ agentName, sessionId, changes: Number(written.changes),
893
+ reason: alreadyStored ? "salt_already_stored" : "no_session_row",
894
+ impact: alreadyStored
895
+ ? "this session already has a salt and it was NOT replaced — Decision #8 is one salt per session. Reaching here means a read failure made this side believe it had none; the stored salt is intact, nothing was announced, and the agreement re-runs against it on the next connect."
896
+ : "the salt was NOT stored — no session row matched — so it is not announced either; the agreement stays open rather than being confirmed against a value that exists only in memory",
897
+ });
898
+ return false;
899
+ }
900
+ this.#sessionSalts.set(this.#ctx.sessionKey(agentName, sessionId), salt);
901
+ return true;
902
+ }
903
+ catch (err) {
904
+ this.#ctx.logger.error("session.salt.persist.failed", {
905
+ agentName, sessionId, error: extractErrorMessage(err),
906
+ impact: "the salt was NOT stored, so it is not announced to the counterparty either; the agreement stays open rather than being confirmed against a value that would vanish at the next restart",
907
+ });
908
+ return false;
909
+ }
910
+ }
911
+ /**
912
+ * DROP AN UNSPENT SALT — `DOD-M15-SALTSPLIT-1`. The second writer of `content_salt`, and the only
913
+ * one that clears it.
914
+ *
915
+ * Reached when the counterparty tells us it can never adopt a salt for this session. Keeping ours
916
+ * would mean every message we send from here is refused by them with
917
+ * `content_hash_salt_unavailable` — a conversation that dies while looking merely quiet, which is
918
+ * the failure this exists to prevent.
919
+ *
920
+ * ⚠️ THE ADOPTION CHECK IS REPEATED HERE ON PURPOSE, not because the caller is untrusted.
921
+ *
922
+ * The caller has already computed `adoption`, so this looks redundant — and it is, for today's one
923
+ * call site. It stays because the cost of a future caller getting it wrong is a transcript that no
924
+ * single rule can verify: leaves hashed under a salt that has just been erased, with nothing
925
+ * recording that they were. A guard whose failure mode is silent and permanent belongs next to the
926
+ * destructive act, not only at the place that currently decides to perform it. Same reasoning that
927
+ * made `placeOwnLeaf`'s authorship parameter required rather than optional.
928
+ *
929
+ * Returns true only if a salt was actually cleared.
930
+ */
931
+ /**
932
+ * SUSPEND, don't destroy — `DOD-M15-SALTSPLIT-1`, the authorization argument. Returns true if a
933
+ * salt is now suspended (or already was).
934
+ *
935
+ * This is the frame handler's entry point. It runs the same two refusals as the erase below —
936
+ * a spent salt and one mid-flight are not ours to set aside either, because the messages already
937
+ * hashed under them would become unverifiable the moment we stop using it — and where they do not
938
+ * fire it records the suspension instead of doing anything irreversible.
939
+ */
940
+ suspendSalt(agentName, sessionId, correlationId) {
941
+ const key = this.#ctx.sessionKey(agentName, sessionId);
942
+ if (this.getSessionSalt(agentName, sessionId) === null)
943
+ return false;
944
+ if (this.#saltSuspended.has(key))
945
+ return true;
946
+ const inFlight = this.#hashedWithSalt.get(key) ?? 0;
947
+ const adoption = this.saltAdoptionClosed(agentName, sessionId);
948
+ if (inFlight > 0 || adoption.closed) {
949
+ /**
950
+ * SPENT, or mid-send. Suspending is not destructive, but it IS a split: content already hashed
951
+ * under this salt stays hashed under it while everything after would be hashed the other way,
952
+ * in one session, with nothing recording where the change happened. That is the one thing
953
+ * Decision #8 forbids outright, so the salt keeps being used and the session stays honestly
954
+ * broken rather than becoming dishonestly half-verifiable.
955
+ */
956
+ this.#ctx.logger.info("session.salt.suspend.refused", {
957
+ agentName, sessionId, correlationId,
958
+ reason: inFlight > 0 ? "salted_hash_in_flight" : adoption.label,
959
+ ...(inFlight > 0 ? { inFlight } : { frontier: adoption.why }),
960
+ 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.",
961
+ });
962
+ return false;
963
+ }
964
+ this.#saltSuspended.add(key);
965
+ this.#ctx.logger.info("session.salt.suspended", {
966
+ agentName, sessionId, correlationId,
967
+ 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.",
968
+ 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.",
969
+ });
970
+ return true;
971
+ }
972
+ /** Un-suspend: the peer answered with a fingerprint matching the salt we kept. */
973
+ resumeSalt(agentName, sessionId, correlationId) {
974
+ const key = this.#ctx.sessionKey(agentName, sessionId);
975
+ if (!this.#saltSuspended.has(key))
976
+ return;
977
+ /**
978
+ * ⚠️ REFUSE THE RESUME IF THIS SESSION HAS ALREADY HASHED UNSALTED — pass 2, F1 (HIGH).
979
+ *
980
+ * `#resumeSalt` deleted the mark unconditionally, and the reviewer produced the counter-example
981
+ * in ONE process with no restart: suspend, the peer keeps talking so a leaf lands, we send `m1`
982
+ * under `sha256`, the peer's frontier recovers and announces `fingerprint(S)`, we resume, and
983
+ * `m2` goes out under `hmac`. Two rules, one session — and `session.salt.resumed` asserted
984
+ * *"No message was hashed while suspended, so the transcript is uniform"* while it was happening.
985
+ * **The code never checked the thing its own log line claimed**, which is this milestone's
986
+ * signature defect committed inside the fix for it.
987
+ *
988
+ * `#unsaltedAnnounced` is exactly that fact and is already maintained, so the check costs a
989
+ * lookup. Once it is set the salt can never be used again, so it is erased here rather than left
990
+ * to be found by a later restart.
991
+ */
992
+ if (this.#unsaltedAnnounced.has(key)) {
993
+ this.#ctx.logger.warn("session.salt.resume.refused", {
994
+ agentName, sessionId, correlationId,
995
+ 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.",
996
+ 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.",
997
+ });
998
+ this.#saltSuspended.delete(key);
999
+ this.discardUnspentSalt(agentName, sessionId, correlationId);
1000
+ return;
1001
+ }
1002
+ this.#saltSuspended.delete(key);
1003
+ /**
1004
+ * THE RECOVERY THE ERASE MADE IMPOSSIBLE. Keeping the bytes is what allows this line to exist:
1005
+ * the peer's earlier terminal frame was wrong (a frontier it could not read for a moment), it can
1006
+ * read again, and the fingerprints match — so the session resumes salted with nothing lost. An
1007
+ * erased salt cannot be re-derived from one side.
1008
+ */
1009
+ this.#ctx.logger.info("session.salt.resumed", {
1010
+ agentName, sessionId, correlationId,
1011
+ 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.",
1012
+ });
1013
+ }
1014
+ discardUnspentSalt(agentName, sessionId, correlationId) {
1015
+ const held = this.getSessionSalt(agentName, sessionId);
1016
+ if (held === null)
1017
+ return false;
1018
+ /**
1019
+ * ⚠️ IN-FLIGHT FIRST — `DOD-M15-SALTSPLIT-1` review HIGH-2. `#saltAdoptionClosed` cannot see a
1020
+ * hash that has been computed under the salt but has not yet become a leaf, a hold or an
1021
+ * awaiting-ack entry, and that gap is a full relay round trip wide.
1022
+ */
1023
+ /**
1024
+ * ⚠️ MEASURED UNREACHABLE FROM TODAY'S CALLERS, AND KEPT ANYWAY — pass 2 test-teeth, survivor 2.
1025
+ *
1026
+ * Deleting this block leaves the whole salt suite GREEN. That is the definition this unit has
1027
+ * used all along for *"not a guard, a comment that happens to execute"*, so it is labelled rather
1028
+ * than quietly left to look load-bearing. `#suspendSalt` refuses on `inFlight > 0` before a
1029
+ * session can ever be marked, and both callers of this method require the mark — so the deferred
1030
+ * erase cannot observe a non-zero count.
1031
+ *
1032
+ * It stays for one reason: **it sits at an irreversible write.** The earlier instance of this
1033
+ * question in this same unit was resolved by making the guard the actual decision-maker, and that
1034
+ * option does not exist here — `#suspendSalt` genuinely must refuse early, so the duplication is
1035
+ * structural rather than a mistake about where responsibility lives. For a destructive act, the
1036
+ * safe direction is to keep a check that cannot fire over removing one that turns out it could.
1037
+ *
1038
+ * What must NOT happen is claiming it as coverage. It is not tested and it is not testable from
1039
+ * outside; if a third caller ever reaches this method without the suspension mark, this becomes
1040
+ * reachable and needs a test in the same commit.
1041
+ */
1042
+ const inFlight = this.#hashedWithSalt.get(this.#ctx.sessionKey(agentName, sessionId)) ?? 0;
1043
+ if (inFlight > 0) {
1044
+ this.#ctx.logger.info("session.salt.discard.refused", {
1045
+ agentName, sessionId, correlationId, reason: "salted_hash_in_flight", inFlight,
1046
+ 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.",
1047
+ });
1048
+ return false;
1049
+ }
1050
+ const adoption = this.saltAdoptionClosed(agentName, sessionId);
1051
+ if (adoption.closed) {
1052
+ /**
1053
+ * SPENT. Something is already hashed under this salt, so it is not ours to drop.
1054
+ *
1055
+ * INFO, not ERROR, and the level is a judgement rather than a downgrade: this is the guard
1056
+ * doing its job correctly, and the FAILURE it accompanies — the session is split and unusable
1057
+ * — is reported by `session.salt.split` at ERROR from the caller that has the operator-facing
1058
+ * detail. Two ERRORs for one condition trains people to read neither. This line stays so the
1059
+ * refusal itself is correlatable when someone asks why the salt is still on disk.
1060
+ */
1061
+ this.#ctx.logger.info("session.salt.discard.refused", {
1062
+ agentName, sessionId, correlationId, reason: adoption.label, frontier: adoption.why,
1063
+ 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.",
1064
+ });
1065
+ return false;
1066
+ }
1067
+ if (!this.#db) {
1068
+ this.#ctx.logger.error("session.salt.discard.failed", {
1069
+ agentName, sessionId, correlationId, reason: "db_closed",
1070
+ 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.",
1071
+ });
1072
+ return false;
1073
+ }
1074
+ try {
1075
+ const cleared = this.#db
1076
+ .prepare("UPDATE sessions SET content_salt = NULL WHERE agent_id = ? AND session_id = ?")
1077
+ .run(this.#ctx.requireAgentId(agentName), sessionId);
1078
+ if (Number(cleared.changes) !== 1) {
1079
+ // The row-count check that `#persistSessionSalt` learned the hard way: an UPDATE matching no
1080
+ // row does not throw, and reporting success here would leave the durable salt in place while
1081
+ // the cache said otherwise — salted after a restart, unsalted before one.
1082
+ this.#ctx.logger.error("session.salt.discard.failed", {
1083
+ agentName, sessionId, correlationId, changes: Number(cleared.changes), reason: "no_session_row",
1084
+ 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",
1085
+ });
1086
+ return false;
1087
+ }
1088
+ }
1089
+ catch (err) {
1090
+ this.#ctx.logger.error("session.salt.discard.failed", {
1091
+ agentName, sessionId, correlationId, error: extractErrorMessage(err),
1092
+ 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",
1093
+ });
1094
+ return false;
1095
+ }
1096
+ /**
1097
+ * CACHE AFTER ROW, and both or the session is worse off than before.
1098
+ *
1099
+ * `#saltForHashing` reads the cache on its first line and never consults the row, so clearing
1100
+ * one without the other produces a session that hashes one way in this process and the other way
1101
+ * in the next — the split transcript, arriving at a daemon restart instead of at a frame.
1102
+ */
1103
+ this.#sessionSalts.delete(this.#ctx.sessionKey(agentName, sessionId));
1104
+ /**
1105
+ * ⚠️ THE MARK GOES WITH THE BYTES — pass 2, F5. Leaving the key in `#saltSuspended` after a
1106
+ * successful erase means a LATER agreed salt is silently never used: `#persistSessionSalt`'s
1107
+ * predicate explicitly allows a write when the column is NULL, and `abandonUnsaltedHash` can
1108
+ * re-open adoption — so the session would log `session.salt.agreed`, surface as protected, and
1109
+ * hash `sha256` for the rest of its life. A stale suppression is indistinguishable from a
1110
+ * feature that does not work.
1111
+ */
1112
+ this.#saltSuspended.delete(this.#ctx.sessionKey(agentName, sessionId));
1113
+ this.#ctx.logger.info("session.salt.discarded", {
1114
+ agentName, sessionId, correlationId,
1115
+ 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.",
1116
+ });
1117
+ return true;
1118
+ }
1119
+ /** Apply one inbound salt-agreement frame. The verdict is the pure function's; this executes it. */
1120
+ async handleSaltFrame(agentName, sessionId, frame, correlationId) {
1121
+ const key = this.#ctx.sessionKey(agentName, sessionId);
1122
+ const peerHalfHex = frame.contribution ? Buffer.from(frame.contribution).toString("hex") : null;
1123
+ const peerFingerprintHex = frame.fingerprint ? Buffer.from(frame.fingerprint).toString("hex") : null;
1124
+ // WHY the peer closed, kept for the operator-facing reason — 006-CRYPTO finding 2. Recorded here
1125
+ // rather than in the `adoption_closed` handler because that action fires for OUR closure too,
1126
+ // and only the frame says what the PEER said.
1127
+ if (typeof frame.adoptionClosed === "string") {
1128
+ this.#saltPeerClosedLabel.set(key, frame.adoptionClosed);
1129
+ }
1130
+ const adoption = this.saltAdoptionClosed(agentName, sessionId);
1131
+ const action = onPeerSaltFrame({
1132
+ ...this.saltState(agentName, sessionId),
1133
+ // Review F2: the frontier is what decides whether THIS side can still adopt, and only the
1134
+ // caller can count it. Without this the state machine derives, the persist refuses, and the
1135
+ // peer never learns — which is how the two sides end up on opposite verdicts.
1136
+ ownAdoption: adoption.closed
1137
+ ? { closed: true, label: adoption.label, why: adoption.why }
1138
+ : { closed: false },
1139
+ // Keyed on the peer's BYTES, not on a repair counter: a genuinely NEW half from the peer must
1140
+ // still get our contribution back, and only an identical re-offer is the loop (review F14).
1141
+ alreadyRepairedAgainstPeerHalf: peerHalfHex !== null && this.#saltRepairedAgainst.get(key) === peerHalfHex,
1142
+ // The mirror, 006-CRYPTO finding 1: without it a saltless side answers a latched holder's
1143
+ // fingerprint forever. Same keying rule — an identical re-offer is the loop, a new one is not.
1144
+ alreadyRepairedAgainstPeerFingerprint: peerFingerprintHex !== null && this.#saltRepairedAgainstFingerprint.get(key) === peerFingerprintHex,
1145
+ frame,
1146
+ });
1147
+ if (action.action === "confirmed") {
1148
+ // DOD-M15-SALTSPLIT-1: the peer confirms the salt we KEPT while suspended — resume before logging
1149
+ // agreement, so a resumed session is never reported as agreed while still suspended.
1150
+ this.resumeSalt(agentName, sessionId, correlationId);
1151
+ this.#ctx.logger.info("session.salt.agreed", {
1152
+ agentName, sessionId, correlationId, via: "fingerprint_match",
1153
+ });
1154
+ // B2b-2: release a first send that is waiting on this agreement. Both `confirmed` and
1155
+ // `derive_and_announce` end with a salt this side can hash under, so both settle the wait.
1156
+ this.settleSaltPending(agentName, sessionId, "agreed");
1157
+ return;
1158
+ }
1159
+ if (action.action === "derive_and_announce") {
1160
+ /**
1161
+ * ⚠️ I DEFENDED THE OPPOSITE OF THIS TWICE, AND BOTH DEFENCES WERE WRONG. The code now does
1162
+ * what the "surviving mutant" did; recording that rather than quietly switching, because the
1163
+ * reasoning is the useful part.
1164
+ *
1165
+ * A failed persist used to fall through with no settle, so a waiting first send sat out the
1166
+ * FULL FIVE SECONDS and was then told, by the timeout path, to go and check its counterparty's
1167
+ * build version — for a fault that was this machine's own disk.
1168
+ *
1169
+ * Defence #1 said releasing the waiter "would hand it a null it would hash unsalted under."
1170
+ * True, and not a consequence: that is exactly what the timeout does. Defence #2 said the
1171
+ * remaining bound gave a repair a chance to land — and the review showed that essentially
1172
+ * cannot fire. This branch returns BEFORE the announce, so nothing goes out and nothing comes
1173
+ * back; all five of `#sendSaltFrame`'s callers are triggered by a peer connect or an inbound
1174
+ * frame. Only a counterparty reconnect inside those seconds could do it.
1175
+ *
1176
+ * So the real trade was a rare reconnect-within-five-seconds repair against five seconds of
1177
+ * visible latency on the operator's first message AND a diagnosis pointing at the wrong
1178
+ * machine. The repair loses. Settle immediately under its own name, so `#saltForHashing` can
1179
+ * say *our own write failed* instead of *they did not answer*.
1180
+ */
1181
+ if (!this.persistSessionSalt(agentName, sessionId, action.salt)) {
1182
+ this.settleSaltPending(agentName, sessionId, "persist_failed");
1183
+ return;
1184
+ }
1185
+ this.#ctx.logger.info("session.salt.agreed", {
1186
+ agentName, sessionId, correlationId, via: "derived",
1187
+ });
1188
+ this.settleSaltPending(agentName, sessionId, "agreed");
1189
+ // `void`, not `await` — review F10. This runs inside the INBOUND content-stream handler, so
1190
+ // awaiting an outbound `newStream` here lets a stalled dial hold up the stream we are reading.
1191
+ // The connect-side call is `void`-ed for the same reason and this is now consistent with it.
1192
+ void this.#ctx.sendSaltFrame(agentName, sessionId, correlationId);
1193
+ return;
1194
+ }
1195
+ if (action.action === "adoption_closed") {
1196
+ // B2b-2: terminal means there is nothing left to wait for. A send still holding on the bound
1197
+ // would otherwise sit out the full five seconds for an answer that has already arrived and
1198
+ // said no — the slowest possible way to reach a decision both sides already agree on.
1199
+ this.settleSaltPending(agentName, sessionId, "closed");
1200
+ /**
1201
+ * Terminal, and NOT a freeze — review F1/F2. Both sides stay unsalted, which is exactly as
1202
+ * verifiable as every session shipped before the salt existed; the thing that was broken was
1203
+ * them disagreeing about it silently.
1204
+ *
1205
+ * WHICH SIDE DECLINED decides the level, and it is not decoration.
1206
+ *
1207
+ * If WE closed, an operator has lost a protection they could otherwise have had, and there is
1208
+ * something they can do about it — that is a WARN under `session.salt.adoption.refused`, which
1209
+ * keeps meaning what it has always meant.
1210
+ *
1211
+ * If we are merely LEARNING the peer closed, nothing about this machine is at fault and there
1212
+ * is nothing for its operator to do. Logging that at WARN would fire on the innocent side of
1213
+ * every such session and train them to ignore the name.
1214
+ */
1215
+ /**
1216
+ * DOD-M15-SALTSPLIT-1 — ONE PLACE DECIDES WHETHER THE SALT GOES, and it is not here.
1217
+ *
1218
+ * ⚠️ THIS CALL WAS INSIDE THE `else` BELOW, AND THE REVERT TEST CAUGHT IT.
1219
+ *
1220
+ * Guarding it by `adoption.closed` here meant `#discardUnspentSalt`'s own adoption check could
1221
+ * never be reached, so deleting that check left all three tests GREEN — the survivor. A guard
1222
+ * nothing can redden is not a guard; it is a comment that happens to execute, which is the
1223
+ * shape this milestone keeps finding.
1224
+ *
1225
+ * Called unconditionally now. The method owns the spent/unspent decision, both outcomes run
1226
+ * through it, and deleting its check reddens the spent test immediately. That also removes the
1227
+ * duplicated condition: two places deciding the same thing is one place being wrong later.
1228
+ */
1229
+ /**
1230
+ * The return is CONSUMED, not decorative — review LOW-5. `true` means a salt was actually
1231
+ * cleared, which settles the question below without a second read; `false` is ambiguous (we
1232
+ * held none, or we refused to drop one), so that case still asks.
1233
+ */
1234
+ const suspended = this.suspendSalt(agentName, sessionId, correlationId);
1235
+ /**
1236
+ * "Still holds a salt it is USING" — suspension is what settles it, not possession. A suspended
1237
+ * session keeps the bytes on disk deliberately, and reporting that as an unrecoverable split
1238
+ * would fire the ERROR below on the one case that recovers by itself.
1239
+ */
1240
+ const stillHoldsSalt = !suspended && this.getSessionSalt(agentName, sessionId) !== null;
1241
+ const shared = {
1242
+ agentName, sessionId, correlationId, detail: action.detail,
1243
+ /**
1244
+ * ⚠️ *"no message is affected"* IS FALSE WHEN WE ARE STILL HOLDING A SALT — review MEDIUM-4,
1245
+ * second instance. The sentence was written for a session where neither side ever adopted
1246
+ * one, and it stayed attached to a branch that now also covers the case where this side
1247
+ * kept a spent salt and every message it sends is about to be refused. Two log lines from
1248
+ * one event contradicting each other is worse than either alone.
1249
+ */
1250
+ impact: stillHoldsSalt
1251
+ ? "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."
1252
+ : "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.",
1253
+ };
1254
+ if (adoption.closed) {
1255
+ /**
1256
+ * ⚠️ TWO REFUSALS, TWO DIFFERENT THINGS TO DO — and this used to report both as
1257
+ * `already_hashing`.
1258
+ *
1259
+ * A session that has already sent messages is the feature working: the fix is a new session,
1260
+ * and it will work. A frontier this side could not READ is local storage trouble: a new
1261
+ * session will refuse in exactly the same way, so sending the operator to open one is
1262
+ * sending them somewhere that cannot help. `frontier` carries the counts (or the error) so
1263
+ * the two are separable from the log alone.
1264
+ */
1265
+ const unreadable = adoption.label === SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE;
1266
+ this.#ctx.logger.warn("session.salt.adoption.refused", {
1267
+ ...shared,
1268
+ reason: adoption.label,
1269
+ leafCount: adoption.leafCount,
1270
+ frontier: adoption.why,
1271
+ guidance: unreadable
1272
+ ? "This side could not read its own message frontier, so it refused the salt rather than risk hashing half the session one way and half the other. Starting a new session will NOT help — it will refuse the same way. Look for session.content.held.restore.failed or other storage errors around this line; the conversation still works and every message is intact, it is just unsalted."
1273
+ : "Nothing is broken and no message was lost: an unsalted session is exactly as verifiable as every session before this feature existed. It only means a relay holding the hashes could confirm a guess at a short message in THIS conversation. If you want the protection, start a new session — the agreement runs at open, before anything is hashed.",
1274
+ });
1275
+ }
1276
+ else {
1277
+ /**
1278
+ * DOD-M15-SALTSPLIT-1 — CARRY OUT THE CLAIM ABOVE INSTEAD OF ONLY STATING IT.
1279
+ *
1280
+ * `shared.impact` says *"neither side will use a content salt for this session, and both now
1281
+ * know it."* Nothing made that true: a salt already agreed on this side stayed on disk and in
1282
+ * the cache, and `#saltForHashing` returns it before it ever looks at adoption. Our adoption
1283
+ * is still open here, so nothing has been hashed under it and dropping it is free.
1284
+ *
1285
+ * Ordering matters — discard BEFORE the log, so the line cannot claim an outcome that the
1286
+ * write then failed to produce.
1287
+ */
1288
+ this.#ctx.logger.info("session.salt.adoption.closed", shared);
1289
+ }
1290
+ /**
1291
+ * ⚠️ OUTSIDE THE ADOPTION BRANCH — pass 2, F4. This used to live inside `if (adoption.closed)`,
1292
+ * so the one case that needed it most never got it: suspension refused for
1293
+ * `salted_hash_in_flight` while adoption is still OPEN leaves us holding a salt the peer can
1294
+ * never accept, and it took the `else` path. Measured on that exact scenario:
1295
+ * `suspend.refused = 1`, `adoption.closed = 1`, **`split = 0`** — while two other log lines
1296
+ * told the operator to *"see session.salt.split on the next line"*, a line that was never
1297
+ * written. Guidance pointing at an event that does not fire is worse than no guidance: it
1298
+ * spends the reader's trust and their time.
1299
+ *
1300
+ * The condition was always `stillHoldsSalt`; only its placement disagreed.
1301
+ *
1302
+ * ─── What this event means, moved here with the code it describes ─────────────────────────
1303
+ *
1304
+ * We hold a salt AND the peer has told us it can never hold one. Either our frontier closed
1305
+ * with the salt already spent, or a salted hash is mid-flight — both mean the salt cannot be
1306
+ * released, so the peer will refuse every message we send with `content_hash_salt_unavailable`.
1307
+ *
1308
+ * `session.salt.adoption.refused` may fire alongside, saying *"nothing is degraded relative to
1309
+ * any shipped release, and no message is affected"* — true for the ordinary refusal and FALSE
1310
+ * here, at the exact moment every message stops being accepted. Hence its own event at ERROR
1311
+ * rather than a tightened sentence on that one: an operator filtering for the refusal is
1312
+ * looking at a benign condition, and this is not it.
1313
+ */
1314
+ if (stillHoldsSalt) {
1315
+ /**
1316
+ * ⚠️ TWO REASONS REACH `adoption.closed`, AND ONLY ONE IS ABOUT CONTENT — review MEDIUM-4.
1317
+ *
1318
+ * This fired for both with a single impact asserting *"content here is already hashed
1319
+ * under a salt"*. For `frontier_unreadable` that is a claim about content made from a
1320
+ * database read that FAILED — we do not know what was hashed; that is the whole condition.
1321
+ *
1322
+ * The WARN twenty lines above was explicitly corrected for this exact collapse — its
1323
+ * comment reads *"TWO REFUSALS, TWO DIFFERENT THINGS TO DO — and this used to report both
1324
+ * as `already_hashing`"* — and I reintroduced it one severity level up, with the guidance
1325
+ * that WARN was fixed to stop giving. Branching on the label the way it already does.
1326
+ */
1327
+ const unreadable = adoption.label === SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE;
1328
+ this.#ctx.logger.error("session.salt.split", {
1329
+ agentName, sessionId, correlationId, reason: adoption.label, frontier: adoption.why,
1330
+ impact: unreadable
1331
+ ? "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."
1332
+ : "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.",
1333
+ guidance: unreadable
1334
+ ? "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."
1335
+ : "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.",
1336
+ });
1337
+ }
1338
+ if (action.announce) {
1339
+ void this.#ctx.sendSaltFrame(agentName, sessionId, correlationId, action.announce);
1340
+ }
1341
+ return;
1342
+ }
1343
+ if (action.action === "repair") {
1344
+ /**
1345
+ * THE REPAIR — review F1. The two sides are out of step and CAN converge, so re-send our half
1346
+ * rather than destroying the session.
1347
+ *
1348
+ * At INFO because it is a real event an operator may need to correlate with a
1349
+ * `session.salt.announce.failed` or `session.salt.persist.failed` on either machine, and
1350
+ * because a session that repairs REPEATEDLY is a signal even though each repair is benign.
1351
+ */
1352
+ this.#ctx.logger.info("session.salt.repair", {
1353
+ agentName, sessionId, correlationId, detail: action.detail,
1354
+ answeredWith: action.frame.contribution ? "contribution" : "fingerprint",
1355
+ });
1356
+ // Recorded ONLY for a repair that sent our half, because that is the one a second identical
1357
+ // offer must not repeat (review F14).
1358
+ if (peerHalfHex && action.frame.contribution)
1359
+ this.#saltRepairedAgainst.set(key, peerHalfHex);
1360
+ // AND THE MIRROR (006-CRYPTO finding 1): we answered the peer's FINGERPRINT with our half. An
1361
+ // earlier note here said recording this "says nothing, that branch is already terminal for the
1362
+ // peer" — it is terminal only for a peer that HOLDS a salt, and the loop is the case where we
1363
+ // do not. A second identical fingerprint now closes adoption instead of repairing again.
1364
+ if (peerFingerprintHex && action.frame.contribution) {
1365
+ this.#saltRepairedAgainstFingerprint.set(key, peerFingerprintHex);
1366
+ }
1367
+ void this.#ctx.sendSaltFrame(agentName, sessionId, correlationId, action.frame);
1368
+ return;
1369
+ }
1370
+ // `detail` is the primitive's own sentence wherever the primitive produced it — never a code of
1371
+ // ours substituted for it (Invariant 2). `guidance` is what the operator can DO, and it comes
1372
+ // from the total map so a reason can never reach a log without one.
1373
+ this.#ctx.logger.error("session.salt.disagreement", {
1374
+ agentName, sessionId, correlationId,
1375
+ reason: action.reason,
1376
+ detail: action.detail,
1377
+ guidance: SALT_FREEZE_GUIDANCE[action.reason],
1378
+ });
1379
+ /**
1380
+ * TELL THE PEER BEFORE TEARING DOWN — review F1's mirror.
1381
+ *
1382
+ * Only the fingerprint mismatch carries a notice, and only it can: the peer holds everything
1383
+ * needed to run the identical comparison and has simply not been given our side of it. Without
1384
+ * this the session stops answering and the far operator gets no reason at all, while ours gets a
1385
+ * full explanation — Decision #10 asks for BOTH sides to refuse by name.
1386
+ *
1387
+ * Awaited, unlike the other sends, because `destroySessionNode` on the next line takes the node
1388
+ * away and an un-awaited write would race its own transport. A failure is already handled
1389
+ * inside — the refusal here has happened either way.
1390
+ */
1391
+ if (action.notifyPeer) {
1392
+ await this.#ctx.sendSaltFrame(agentName, sessionId, correlationId, action.notifyPeer);
1393
+ }
1394
+ await this.#ctx.freezeSession(agentName, sessionId, action.reason, {
1395
+ event: "session.salt.frozen",
1396
+ observation: `the salt agreement could not be completed with this counterparty: ${action.detail}`,
1397
+ impact: "the session was stopped rather than left to hash under a value the two sides do not share; no message was lost and the transcript is unaffected — only a NEW session moves this forward",
1398
+ reviveReason: `session_frozen_${action.reason}`,
1399
+ // The operator-facing sentence comes from the TOTAL guidance map, so a reason can never reach
1400
+ // this refusal without one — and it is what stops a salt disagreement being reported to them
1401
+ // as their counterparty failing a key check.
1402
+ reviveGuidance: SALT_FREEZE_GUIDANCE[action.reason],
1403
+ }, correlationId);
1404
+ }
1405
+ /**
1406
+ * Forget every salt fact this module holds for one session.
1407
+ *
1408
+ * ⚠️ ELEVEN MAPS BEHIND ONE CALL, and that is the point. These were eleven of the twenty-four
1409
+ * per-session containers the manager's cache eviction cleared by hand — so forgetting a session
1410
+ * meant knowing every map that might hold a piece of it, and adding a twelfth meant remembering to
1411
+ * add a twelfth delete. Missing one leaves a per-session entry alive for the life of the process.
1412
+ * The list of what to forget now lives beside the code that fills them.
1413
+ */
1414
+ evictSession(agentName, sessionId) {
1415
+ const key = this.#ctx.sessionKey(agentName, sessionId);
1416
+ this.#saltContributions.delete(key);
1417
+ this.#sessionSalts.delete(key);
1418
+ this.#saltRepairedAgainst.delete(key);
1419
+ this.#saltRepairedAgainstFingerprint.delete(key);
1420
+ this.#saltPeerClosedLabel.delete(key);
1421
+ this.#hashedWithoutSalt.delete(key);
1422
+ this.#hashedWithSalt.delete(key);
1423
+ this.#saltSuspended.delete(key);
1424
+ this.#unsaltedAnnounced.delete(key);
1425
+ this.#saltLastOutcome.delete(key);
1426
+ }
1427
+ /**
1428
+ * The pair the agreement reasons over: our salt, and the half that goes with it.
1429
+ *
1430
+ * Minting is deliberate and conditional. With NO salt we are certain to need a half — to offer, or
1431
+ * to derive with — so minting here is what makes the exchange work at all. WITH a salt we must
1432
+ * never mint; see `#ownSaltHalf`.
1433
+ */
1434
+ saltState(agentName, sessionId) {
1435
+ const ownSalt = this.getSessionSalt(agentName, sessionId);
1436
+ return {
1437
+ ownSalt,
1438
+ ownContribution: ownSalt
1439
+ ? this.ownSaltHalf(agentName, sessionId)
1440
+ : this.saltContributionFor(agentName, sessionId),
1441
+ };
1442
+ }
1443
+ /**
1444
+ * ONE mapping from a settled outcome to the operator-facing reason, so the send that WAITED and the
1445
+ * send that arrived afterwards cannot disagree about what happened.
1446
+ */
1447
+ reasonForOutcome(key, outcome) {
1448
+ if (outcome === "announce_failed")
1449
+ return UNSALTED_REASONS.ANNOUNCE_FAILED;
1450
+ if (outcome === "persist_failed")
1451
+ return UNSALTED_REASONS.OUR_PERSIST_FAILED;
1452
+ if (outcome === "closed")
1453
+ return this.peerClosedReason(key);
1454
+ return UNSALTED_REASONS.AGREEMENT_TIMED_OUT;
1455
+ }
1456
+ }
1457
+ //# sourceMappingURL=session-salts.js.map