@cello-protocol/daemon 0.0.181 → 0.0.183

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/dist/agent-id-migration.d.ts +9 -0
  2. package/dist/agent-id-migration.d.ts.map +1 -1
  3. package/dist/agent-id-migration.js +73 -1
  4. package/dist/agent-id-migration.js.map +1 -1
  5. package/dist/agent-selection.d.ts +73 -0
  6. package/dist/agent-selection.d.ts.map +1 -0
  7. package/dist/agent-selection.js +80 -0
  8. package/dist/agent-selection.js.map +1 -0
  9. package/dist/agent-settings-keys.d.ts.map +1 -1
  10. package/dist/agent-settings-keys.js +18 -0
  11. package/dist/agent-settings-keys.js.map +1 -1
  12. package/dist/assignment-verify.d.ts +62 -0
  13. package/dist/assignment-verify.d.ts.map +1 -0
  14. package/dist/assignment-verify.js +185 -0
  15. package/dist/assignment-verify.js.map +1 -0
  16. package/dist/backup-restore.d.ts +102 -0
  17. package/dist/backup-restore.d.ts.map +1 -0
  18. package/dist/backup-restore.js +340 -0
  19. package/dist/backup-restore.js.map +1 -0
  20. package/dist/bin/cello-daemon.js +91 -49
  21. package/dist/bin/cello-daemon.js.map +1 -1
  22. package/dist/close-commitment.d.ts +67 -0
  23. package/dist/close-commitment.d.ts.map +1 -0
  24. package/dist/close-commitment.js +78 -0
  25. package/dist/close-commitment.js.map +1 -0
  26. package/dist/close-session-handler.d.ts +20 -0
  27. package/dist/close-session-handler.d.ts.map +1 -1
  28. package/dist/close-session-handler.js +351 -58
  29. package/dist/close-session-handler.js.map +1 -1
  30. package/dist/consortium-bootstrap.d.ts +2 -0
  31. package/dist/consortium-bootstrap.d.ts.map +1 -1
  32. package/dist/consortium-bootstrap.js +182 -9
  33. package/dist/consortium-bootstrap.js.map +1 -1
  34. package/dist/content-encryption-status.d.ts +97 -0
  35. package/dist/content-encryption-status.d.ts.map +1 -0
  36. package/dist/content-encryption-status.js +122 -0
  37. package/dist/content-encryption-status.js.map +1 -0
  38. package/dist/content-park-client.d.ts +10 -0
  39. package/dist/content-park-client.d.ts.map +1 -1
  40. package/dist/content-park-client.js +84 -3
  41. package/dist/content-park-client.js.map +1 -1
  42. package/dist/content-park.d.ts.map +1 -1
  43. package/dist/content-park.js +323 -21
  44. package/dist/content-park.js.map +1 -1
  45. package/dist/daemon.d.ts +13 -1
  46. package/dist/daemon.d.ts.map +1 -1
  47. package/dist/daemon.js +1168 -141
  48. package/dist/daemon.js.map +1 -1
  49. package/dist/delivery-session-suspects.d.ts.map +1 -1
  50. package/dist/delivery-session-suspects.js +5 -0
  51. package/dist/delivery-session-suspects.js.map +1 -1
  52. package/dist/directory-auth-posture.d.ts +87 -0
  53. package/dist/directory-auth-posture.d.ts.map +1 -0
  54. package/dist/directory-auth-posture.js +134 -0
  55. package/dist/directory-auth-posture.js.map +1 -0
  56. package/dist/directory-bootstrap.d.ts +66 -3
  57. package/dist/directory-bootstrap.d.ts.map +1 -1
  58. package/dist/directory-bootstrap.js +133 -7
  59. package/dist/directory-bootstrap.js.map +1 -1
  60. package/dist/document-delivery-transport.d.ts +22 -2
  61. package/dist/document-delivery-transport.d.ts.map +1 -1
  62. package/dist/document-delivery-transport.js +11 -3
  63. package/dist/document-delivery-transport.js.map +1 -1
  64. package/dist/document-handlers.d.ts.map +1 -1
  65. package/dist/document-handlers.js +119 -0
  66. package/dist/document-handlers.js.map +1 -1
  67. package/dist/document-layer.d.ts +0 -2
  68. package/dist/document-layer.d.ts.map +1 -1
  69. package/dist/document-layer.js.map +1 -1
  70. package/dist/inbound-sessions.d.ts +31 -1
  71. package/dist/inbound-sessions.d.ts.map +1 -1
  72. package/dist/inbound-sessions.js +276 -16
  73. package/dist/inbound-sessions.js.map +1 -1
  74. package/dist/index.d.ts +2 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +3 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/initiate-session-handler.d.ts.map +1 -1
  79. package/dist/initiate-session-handler.js +34 -3
  80. package/dist/initiate-session-handler.js.map +1 -1
  81. package/dist/ipc-server.d.ts +5 -1
  82. package/dist/ipc-server.d.ts.map +1 -1
  83. package/dist/ipc-server.js +19 -3
  84. package/dist/ipc-server.js.map +1 -1
  85. package/dist/manifest-deps.d.ts +0 -11
  86. package/dist/manifest-deps.d.ts.map +1 -1
  87. package/dist/manifest-deps.js +28 -2
  88. package/dist/manifest-deps.js.map +1 -1
  89. package/dist/manifest-validity.d.ts +153 -0
  90. package/dist/manifest-validity.d.ts.map +1 -0
  91. package/dist/manifest-validity.js +268 -0
  92. package/dist/manifest-validity.js.map +1 -0
  93. package/dist/notification-handlers.d.ts.map +1 -1
  94. package/dist/notification-handlers.js +15 -0
  95. package/dist/notification-handlers.js.map +1 -1
  96. package/dist/outbound-sessions.d.ts +31 -0
  97. package/dist/outbound-sessions.d.ts.map +1 -1
  98. package/dist/outbound-sessions.js +217 -17
  99. package/dist/outbound-sessions.js.map +1 -1
  100. package/dist/park-envelope.d.ts +95 -1
  101. package/dist/park-envelope.d.ts.map +1 -1
  102. package/dist/park-envelope.js +195 -4
  103. package/dist/park-envelope.js.map +1 -1
  104. package/dist/refusal-reasons.d.ts +72 -0
  105. package/dist/refusal-reasons.d.ts.map +1 -0
  106. package/dist/refusal-reasons.js +69 -0
  107. package/dist/refusal-reasons.js.map +1 -0
  108. package/dist/register-handler.d.ts.map +1 -1
  109. package/dist/register-handler.js +76 -0
  110. package/dist/register-handler.js.map +1 -1
  111. package/dist/registration-manager.d.ts.map +1 -1
  112. package/dist/registration-manager.js +20 -1
  113. package/dist/registration-manager.js.map +1 -1
  114. package/dist/relay-only.d.ts +133 -0
  115. package/dist/relay-only.d.ts.map +1 -0
  116. package/dist/relay-only.js +183 -0
  117. package/dist/relay-only.js.map +1 -0
  118. package/dist/restart-seal-resolver.d.ts.map +1 -1
  119. package/dist/restart-seal-resolver.js +6 -0
  120. package/dist/restart-seal-resolver.js.map +1 -1
  121. package/dist/retry-queue.d.ts +49 -2
  122. package/dist/retry-queue.d.ts.map +1 -1
  123. package/dist/retry-queue.js +55 -10
  124. package/dist/retry-queue.js.map +1 -1
  125. package/dist/roster-freshness.d.ts +169 -0
  126. package/dist/roster-freshness.d.ts.map +1 -0
  127. package/dist/roster-freshness.js +249 -0
  128. package/dist/roster-freshness.js.map +1 -0
  129. package/dist/seal-certificate-pull.d.ts +17 -1
  130. package/dist/seal-certificate-pull.d.ts.map +1 -1
  131. package/dist/seal-certificate-pull.js +37 -3
  132. package/dist/seal-certificate-pull.js.map +1 -1
  133. package/dist/seal-coordinator.d.ts +17 -1
  134. package/dist/seal-coordinator.d.ts.map +1 -1
  135. package/dist/seal-coordinator.js +71 -0
  136. package/dist/seal-coordinator.js.map +1 -1
  137. package/dist/seal-failure-store.d.ts +104 -0
  138. package/dist/seal-failure-store.d.ts.map +1 -0
  139. package/dist/seal-failure-store.js +126 -0
  140. package/dist/seal-failure-store.js.map +1 -0
  141. package/dist/seal-flows.d.ts.map +1 -1
  142. package/dist/seal-flows.js +35 -6
  143. package/dist/seal-flows.js.map +1 -1
  144. package/dist/seal-upgrade.d.ts +20 -5
  145. package/dist/seal-upgrade.d.ts.map +1 -1
  146. package/dist/seal-upgrade.js +28 -5
  147. package/dist/seal-upgrade.js.map +1 -1
  148. package/dist/session-assignment-parser.d.ts +41 -5
  149. package/dist/session-assignment-parser.d.ts.map +1 -1
  150. package/dist/session-assignment-parser.js +77 -14
  151. package/dist/session-assignment-parser.js.map +1 -1
  152. package/dist/session-ceremony.d.ts +35 -0
  153. package/dist/session-ceremony.d.ts.map +1 -1
  154. package/dist/session-ceremony.js +97 -0
  155. package/dist/session-ceremony.js.map +1 -1
  156. package/dist/session-connection-gater.d.ts +46 -7
  157. package/dist/session-connection-gater.d.ts.map +1 -1
  158. package/dist/session-connection-gater.js +163 -12
  159. package/dist/session-connection-gater.js.map +1 -1
  160. package/dist/session-content-handlers.d.ts +12 -0
  161. package/dist/session-content-handlers.d.ts.map +1 -1
  162. package/dist/session-content-handlers.js +149 -8
  163. package/dist/session-content-handlers.js.map +1 -1
  164. package/dist/session-node-manager.d.ts +653 -40
  165. package/dist/session-node-manager.d.ts.map +1 -1
  166. package/dist/session-node-manager.js +5938 -374
  167. package/dist/session-node-manager.js.map +1 -1
  168. package/dist/session-read-handlers.d.ts +27 -0
  169. package/dist/session-read-handlers.d.ts.map +1 -1
  170. package/dist/session-read-handlers.js +127 -6
  171. package/dist/session-read-handlers.js.map +1 -1
  172. package/dist/session-relay-client.d.ts +177 -2
  173. package/dist/session-relay-client.d.ts.map +1 -1
  174. package/dist/session-relay-client.js +564 -16
  175. package/dist/session-relay-client.js.map +1 -1
  176. package/dist/session-salt-agreement.d.ts +338 -0
  177. package/dist/session-salt-agreement.d.ts.map +1 -0
  178. package/dist/session-salt-agreement.js +478 -0
  179. package/dist/session-salt-agreement.js.map +1 -0
  180. package/dist/signal-submission.d.ts.map +1 -1
  181. package/dist/signal-submission.js +10 -1
  182. package/dist/signal-submission.js.map +1 -1
  183. package/dist/signaling-connect.d.ts +18 -1
  184. package/dist/signaling-connect.d.ts.map +1 -1
  185. package/dist/signaling-connect.js +206 -6
  186. package/dist/signaling-connect.js.map +1 -1
  187. package/dist/types.d.ts +75 -0
  188. package/dist/types.d.ts.map +1 -1
  189. package/dist/types.js.map +1 -1
  190. package/dist/vocabulary.d.ts.map +1 -1
  191. package/dist/vocabulary.js +10 -0
  192. package/dist/vocabulary.js.map +1 -1
  193. package/dist/wire-content-hash.d.ts +68 -0
  194. package/dist/wire-content-hash.d.ts.map +1 -1
  195. package/dist/wire-content-hash.js +83 -0
  196. package/dist/wire-content-hash.js.map +1 -1
  197. package/package.json +5 -5
@@ -31,7 +31,7 @@ var _a;
31
31
  import { createHash } from "node:crypto";
32
32
  import * as lp from "it-length-prefixed";
33
33
  import { decode } from "cbor-x";
34
- import { encodeCbor } from "@cello-protocol/protocol-types";
34
+ import { encodeCbor, decodeSealPayload } from "@cello-protocol/protocol-types";
35
35
  import { evaluateRelayAck } from "./relay-receipt-store.js";
36
36
  export const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
37
37
  export const RELAY_AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
@@ -51,11 +51,118 @@ export const RELAY_AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
51
51
  * ENUMERATED, never pattern-matched. A substring rule like `reason.includes("sealed")` would absorb
52
52
  * a future reason nobody has considered — which is the same collapse in a new coat.
53
53
  */
54
- export const TERMINAL_RELAY_REFUSALS = new Set(["session_sealed", "session_not_found"]);
54
+ export const TERMINAL_RELAY_REFUSALS = new Set([
55
+ "session_sealed",
56
+ "session_not_found",
57
+ /**
58
+ * `DOD-M15-TERMINAL-REASON-1` split `session_sealed` into named causes, and this set is one of
59
+ * THREE places keyed on the old literal — a rename on the relay silently made a terminal refusal
60
+ * non-terminal here, which is the 68-minute defect above reopened by a string change.
61
+ *
62
+ * `seal_refused`: a directory READ the seal and rejected it. Terminal in the strongest sense —
63
+ * there is no later, and no retry can change a merits verdict.
64
+ *
65
+ * `seal_in_progress` is deliberately ABSENT. A seal in flight may still succeed, and after
66
+ * `DOD-M15-TRANSPORT-TERMINAL-1` the session can return to `active` — treating it as terminal
67
+ * would retire a conversation that is about to seal normally.
68
+ */
69
+ "seal_refused",
70
+ ]);
55
71
  /** True when the relay has ended this session and no later submit can succeed. */
56
72
  export function isTerminalRelayRefusal(reason) {
57
73
  return reason !== undefined && TERMINAL_RELAY_REFUSALS.has(reason);
58
74
  }
75
+ /**
76
+ * DOD-M15-RELAYSLOTS-1 — **WHICH REFUSALS JUSTIFY TRYING A DIFFERENT RELAY.**
77
+ *
78
+ * We run several relays, so "move on to the next one" is always available — which is exactly why it
79
+ * needs a rule. Moving on from a problem that every relay will have turns one client-side fault into
80
+ * what looks like a fleet-wide outage, and the operator then goes looking for a broken relay.
81
+ *
82
+ * ENUMERATED, never pattern-matched, for the same reason `TERMINAL_RELAY_REFUSALS` is: a substring
83
+ * rule would silently absorb a future reason nobody has considered.
84
+ */
85
+ const RELAY_SIDE_REFUSALS = new Set([
86
+ /**
87
+ * The relay holds no directory public key, so it can verify nothing and is refusing everyone.
88
+ * That is this relay being misconfigured, not us being wrong — another relay is the right move,
89
+ * and it is the whole reason we run more than one.
90
+ */
91
+ "online_token_no_directory_key",
92
+ ]);
93
+ /**
94
+ * Classify a relay's auth refusal: what to tell the operator, and whether another relay would help.
95
+ *
96
+ * Everything not in `RELAY_SIDE_REFUSALS` defaults to "do not try another", and that default is the
97
+ * safe direction. A token problem reproduces identically on every relay in the fleet, so retrying
98
+ * around the fleet spends real time turning a client fault into an apparent outage — and a slot cap
99
+ * IS satisfiable elsewhere, but spreading to another relay papers over sessions that leaked and
100
+ * brings the same wall back on the next one.
101
+ */
102
+ export function classifyRelayAuthRefusal(reason, extra = {}) {
103
+ const tryAnotherRelay = RELAY_SIDE_REFUSALS.has(reason);
104
+ let advice;
105
+ switch (reason) {
106
+ case "online_token_required":
107
+ advice = "This agent has no online token from a directory yet. It is issued when a directory " +
108
+ "marks the agent online, so this usually clears itself on the next directory connection. If " +
109
+ "it persists, the agent is not reaching any directory — check that first, not the relay.";
110
+ break;
111
+ case "online_token_expired":
112
+ advice = "The online token has expired and is refreshed on the next directory connection. If " +
113
+ "it keeps expiring, this machine's clock or its directory connection is the thing to look at.";
114
+ break;
115
+ case "online_token_signature_invalid":
116
+ case "online_token_malformed":
117
+ case "online_token_lifetime_too_long":
118
+ advice = "This relay would not accept the token this agent was issued. Most often the relay " +
119
+ "and the directory are not in the same consortium — check which directories this relay is " +
120
+ "configured to trust.";
121
+ break;
122
+ case "online_token_pubkey_mismatch":
123
+ advice = "The token names a different key from the one this agent signed with. That is an " +
124
+ "identity mix-up on this machine, not a relay problem.";
125
+ break;
126
+ case "online_token_no_directory_key":
127
+ advice = "This relay holds no directory public key, so it cannot verify anyone and is " +
128
+ "refusing every agent. Its operator needs to configure one; another relay will work now.";
129
+ break;
130
+ case "slot_cap_exceeded":
131
+ advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
132
+ ? `This agent already holds ${String(extra.slotsHeld)} of a maximum ${String(extra.slotCap)} ` +
133
+ "reservations on this relay, and none is idle enough to reclaim. That is almost always " +
134
+ "sessions that were never closed — close some and this clears. Moving to another relay " +
135
+ "would work now and hit the same wall there."
136
+ : "This agent already holds the most reservations one agent may hold on this relay. That is " +
137
+ "almost always sessions that were never closed — close some and this clears.";
138
+ break;
139
+ case "session_tuple_cap_exceeded":
140
+ advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
141
+ ? `You already have ${String(extra.slotsHeld)} conversations open with this counterparty, ` +
142
+ `which is the maximum of ${String(extra.slotCap)} this relay allows between one pair of ` +
143
+ "agents. Close some and try again — this is almost always conversations that were never " +
144
+ "closed rather than ones anybody is still using."
145
+ : "You already have the maximum number of concurrent conversations open with this " +
146
+ "counterparty. Close some and try again.";
147
+ break;
148
+ case "rate_limited":
149
+ advice = extra.retryAfterMs !== undefined
150
+ ? `This relay is throttling this agent; it clears on its own in about ${String(Math.ceil(extra.retryAfterMs / 1000))}s.`
151
+ : "This relay is throttling this agent; it clears on its own after the throttle window.";
152
+ break;
153
+ default:
154
+ advice = "This agent could not authenticate to this relay, so it cannot hold a reservation " +
155
+ "here and is reachable only over a direct connection.";
156
+ }
157
+ return {
158
+ reason,
159
+ advice,
160
+ tryAnotherRelay,
161
+ ...(extra.slotsHeld !== undefined ? { slotsHeld: extra.slotsHeld } : {}),
162
+ ...(extra.slotCap !== undefined ? { slotCap: extra.slotCap } : {}),
163
+ ...(extra.retryAfterMs !== undefined ? { retryAfterMs: extra.retryAfterMs } : {}),
164
+ };
165
+ }
59
166
  export const LEAF_KIND_MSG = 0x00;
60
167
  /** Control leaf (SEAL etc.) — two distinct-sender ctrl leaves trigger directory notarization. */
61
168
  export const LEAF_KIND_CTRL = 0x02;
@@ -151,6 +258,18 @@ export class AgentRelayClient {
151
258
  #logger;
152
259
  #receiptStore;
153
260
  #sealLeafStore;
261
+ /** DOD-M15-RELAYSLOTS-1 — read fresh at every auth. See `AgentRelayClientOpts.onlineToken`. */
262
+ #onlineToken;
263
+ /**
264
+ * DOD-M15-RELAYSLOTS-1: the last refusal this relay gave us, classified. Kept because a log line
265
+ * reaches neither the operator asking why their agent is unreachable nor the code deciding
266
+ * whether a different relay would do any better.
267
+ */
268
+ #lastAuthRefusal = null;
269
+ /** The last classified auth refusal from this relay, or null if the last attempt succeeded. */
270
+ getLastAuthRefusal() {
271
+ return this.#lastAuthRefusal;
272
+ }
154
273
  #stream = null;
155
274
  #connecting = null;
156
275
  #closed = false;
@@ -166,6 +285,13 @@ export class AgentRelayClient {
166
285
  // The sender-signed structure1_cbor of the in-flight submit, paired with its ack so the
167
286
  // SubmitResult can carry it (the ack itself only returns the relay's structure2_cbor).
168
287
  #pendingStructure1 = null;
288
+ /**
289
+ * DOD-M15-SEALWIRE-1 bullet 5 (sent half) — OUR signature over the in-flight `#pendingStructure1`,
290
+ * paired with its ack for the same reason that one is: the ack returns the relay's record, never
291
+ * ours. Cleared wherever `#pendingStructure1` is cleared; the two must never drift apart, because
292
+ * a signature paired with the WRONG signed bytes is worse than no signature at all.
293
+ */
294
+ #pendingSignature = null;
169
295
  // The in-flight submit's leaf kind (0x00 msg / 0x02 ctrl), paired with its ack so
170
296
  // #captureReceipt can persist it alongside the Structure2/Structure1 carry bytes for the unilateral seal.
171
297
  #pendingLeafKind = null;
@@ -189,6 +315,7 @@ export class AgentRelayClient {
189
315
  this.#logger = opts.logger;
190
316
  this.#receiptStore = opts.receiptStore;
191
317
  this.#sealLeafStore = opts.sealLeafStore;
318
+ this.#onlineToken = opts.onlineToken;
192
319
  }
193
320
  /** The agent's K_local public key as hex — the responder identity for auto-acknowledge. */
194
321
  get senderPubkeyHex() {
@@ -220,6 +347,29 @@ export class AgentRelayClient {
220
347
  .then(() => undefined, () => undefined);
221
348
  }
222
349
  }
350
+ /**
351
+ * DOD-M15-RELAYAUTH-1 review H1 — **present the assignment and WAIT for the relay to say it
352
+ * recorded it.**
353
+ *
354
+ * `registerSession` above presents eagerly and forgets: the record is queued onto the submit chain
355
+ * and nobody can observe when it lands. That is correct for the witness relay, where the only
356
+ * requirement is "before the first submit". It is NOT sufficient for the relay that GATES A DIAL,
357
+ * because there the record is a precondition of an action we are about to take on another thread
358
+ * of the protocol — and losing that race denies a legitimate dial (review H1).
359
+ *
360
+ * Chained on `#submitChain` exactly like `#doSubmit`, so it cannot interleave with a submit on the
361
+ * same stream. Idempotent by construction: `#doRecord` returns `true` immediately once the session
362
+ * is recorded, so calling this straight after `registerSession` waits for the record that call
363
+ * already queued rather than sending a second one.
364
+ *
365
+ * Returns whether the relay recorded it. NEVER throws — a caller must be free to proceed on false
366
+ * (a dial that might be denied still beats no dial at all).
367
+ */
368
+ async recordAssignmentAndWait(node, sessionIdHex) {
369
+ const run = this.#submitChain.then(() => this.#doRecord(node, sessionIdHex));
370
+ this.#submitChain = run.then(() => undefined, () => undefined);
371
+ return run.catch(() => false);
372
+ }
223
373
  /**
224
374
  * Present the directory-signed assignment to the relay. Idempotent
225
375
  * (no-op once `recorded`, or when the session has no assignment — direct/persisted/legacy sessions).
@@ -311,12 +461,25 @@ export class AgentRelayClient {
311
461
  hasSessions() {
312
462
  return this.#sessions.size > 0;
313
463
  }
464
+ /**
465
+ * Is THIS session already registered on this client?
466
+ *
467
+ * DOD-M15-RELAYLEAK-1 (review MEDIUM-5). The detached seal transport releases its registration
468
+ * when the submit finishes, and "did I register it, or did I find it already there?" is the
469
+ * difference between releasing my own and **pulling a live one out from under a concurrent
470
+ * caller** — which closes the client that caller is mid-`submitLeaf` on. `hasSessions()` cannot
471
+ * answer it: it is a count, and by then the id is in the set either way.
472
+ */
473
+ hasSession(sessionIdHex) {
474
+ return this.#sessions.has(sessionIdHex);
475
+ }
314
476
  /** Settle the one outstanding submit (if any) exactly once. */
315
477
  #settlePending(r) {
316
478
  const resolve = this.#pendingAck;
317
479
  this.#pendingAck = null;
318
480
  this.#pendingAckSessionHex = null;
319
481
  this.#pendingStructure1 = null;
482
+ this.#pendingSignature = null;
320
483
  this.#pendingLeafKind = null;
321
484
  if (resolve)
322
485
  resolve(r);
@@ -456,6 +619,8 @@ export class AgentRelayClient {
456
619
  const s2 = frame["structure2_cbor"];
457
620
  const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
458
621
  const structure1Cbor = this.#pendingStructure1 ?? undefined;
622
+ // Captured with structure1Cbor and BEFORE #settlePending clears both — see #pendingSignature.
623
+ const senderSignature = this.#pendingSignature ?? undefined;
459
624
  // Verify the relay's signed ordering record and durably store the receipt BEFORE
460
625
  // settling (which clears #pendingStructure1, the source of the content hash + session id). A
461
626
  // signed-but-INVALID ACK rejects the submit so the send does not settle ok on an unverified sequence.
@@ -463,12 +628,24 @@ export class AgentRelayClient {
463
628
  this.#settlePending(rejectSubmit
464
629
  ? { ok: false, reason: "relay_ack_signature_invalid" }
465
630
  : seq >= 0
466
- ? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor }
631
+ ? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor, sender_signature: senderSignature }
467
632
  : { ok: false, reason: "relay_ack_malformed" });
468
633
  }
469
634
  else if (type === "hash_submit_error") {
470
635
  const reason = typeof frame["reason"] === "string" ? frame["reason"] : "relay_rejected";
471
- this.#settlePending({ ok: false, reason });
636
+ // Carry the relay's `detail` through — see `SubmitResult`. Reading the class and discarding
637
+ // what happened is how a refusal arrives as a bare code with no cause attached to it.
638
+ const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
639
+ // DOD-M15-RELAYABUSE-1: the relay knows when its window clears and says so. Carried, not
640
+ // dropped — `#doSubmit` waits it out and resubmits, so a throttle never reaches the operator.
641
+ const rawRetry = frame["retry_after_ms"];
642
+ const retry_after_ms = typeof rawRetry === "number" && Number.isFinite(rawRetry) && rawRetry > 0 ? rawRetry : undefined;
643
+ this.#settlePending({
644
+ ok: false,
645
+ reason,
646
+ ...(detail ? { detail } : {}),
647
+ ...(retry_after_ms !== undefined ? { retry_after_ms } : {}),
648
+ });
472
649
  }
473
650
  else if (type === "assignment_ok") {
474
651
  // The relay verified + recorded our client-presented assignment.
@@ -480,7 +657,23 @@ export class AgentRelayClient {
480
657
  else if (type === "assignment_invalid") {
481
658
  // The relay rejected the assignment (e.g. directory_signature_invalid — not signed by any
482
659
  // consortium directory). Fail LOUD: the session has no relay witness until this is resolved.
483
- this.#logger.warn("session.relay.assignment.invalid", { relayPeerId: this.#relayPeerId, reason: typeof frame["reason"] === "string" ? frame["reason"] : "unknown" });
660
+ const reason = typeof frame["reason"] === "string" ? frame["reason"] : "unknown";
661
+ this.#logger.warn("session.relay.assignment.invalid", { relayPeerId: this.#relayPeerId, reason });
662
+ /**
663
+ * DOD-M15-RELAYSLOTS-1 review M2 — **the tuple cap has to reach the operator too.**
664
+ *
665
+ * Clause 7 says EVERY refusal reaches them with a cause and an affordance, and this one was
666
+ * arriving as `assignment_invalid` in a log. It is also the refusal most likely to hit a real
667
+ * person, for the reason the order itself gives: nobody knows what sessions they have open, so
668
+ * whoever hits it believes they have none. Routed through the same classifier and onto the
669
+ * same surface as every other relay refusal.
670
+ */
671
+ const concurrent = typeof frame["concurrent_sessions"] === "number" ? frame["concurrent_sessions"] : undefined;
672
+ const cap = typeof frame["session_cap"] === "number" ? frame["session_cap"] : undefined;
673
+ this.#lastAuthRefusal = classifyRelayAuthRefusal(reason, {
674
+ ...(concurrent !== undefined ? { slotsHeld: concurrent } : {}),
675
+ ...(cap !== undefined ? { slotCap: cap } : {}),
676
+ });
484
677
  const r = this.#pendingRecord;
485
678
  this.#pendingRecord = null;
486
679
  if (r)
@@ -540,6 +733,36 @@ export class AgentRelayClient {
540
733
  });
541
734
  }
542
735
  }
736
+ else if (type === "relay_slot_reclaimed") {
737
+ /**
738
+ * DOD-M15-RELAYSLOTS-1 clause 8 — **the reaped party is told, and this is where it lands.**
739
+ *
740
+ * The relay reclaimed this agent's circuit reservation to free capacity. Without a branch
741
+ * here the frame fell off the end of this chain and was discarded in silence, which is the
742
+ * trap the order records in its own words: a refusal that only reaches the relay's log does
743
+ * not exist. From the agent's side the reservation simply stops working.
744
+ *
745
+ * Recorded as a refusal so it reaches `cello_status` through the same surface as every other
746
+ * relay refusal, with the same shape: a cause, and what to do about it.
747
+ */
748
+ const idleMs = typeof frame["idle_ms"] === "number" ? frame["idle_ms"] : undefined;
749
+ const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
750
+ this.#lastAuthRefusal = {
751
+ reason: "slot_reclaimed",
752
+ advice: detail ?? "This relay reclaimed your circuit reservation to free capacity because it " +
753
+ "had carried no traffic for a long time. Your agent stays online and rebuilds its receiver " +
754
+ "automatically; a new session will take a fresh reservation.",
755
+ // Not the relay's fault and not ours — it was under pressure and we were the quietest. The
756
+ // client rebuilds against the same pool, so there is nothing to fail over from.
757
+ tryAnotherRelay: false,
758
+ };
759
+ this.#logger.warn("session.relay.slot_reclaimed", {
760
+ relayPeerId: this.#relayPeerId,
761
+ ...(idleMs !== undefined ? { idleHours: Math.round(idleMs / 3_600_000) } : {}),
762
+ impact: "this relay reclaimed our circuit reservation to free capacity. Until a receiver is " +
763
+ "rebuilt, this agent is reachable only over a direct connection.",
764
+ });
765
+ }
543
766
  // session_interrupted / content_park_notify are out of scope here — session interruption
544
767
  // is handled by the session node manager's dedicated relay-stream watcher.
545
768
  }
@@ -551,6 +774,61 @@ export class AgentRelayClient {
551
774
  async connect(node) {
552
775
  return this.#ensureConnected(node);
553
776
  }
777
+ /**
778
+ * DOD-M15-RELAYAUTH-1 review HIGH-1 — prove key possession FROM THIS NODE, on its own stream.
779
+ *
780
+ * ⚠️ **`connect()` CANNOT be used for this, and using it was the defect.** `#ensureConnected`
781
+ * returns `true` the moment `#stream` is non-null, and `#stream` belongs to whichever node
782
+ * connected FIRST. An agent legitimately runs several nodes against one relay — the node promoted
783
+ * into a live session, plus the replacement standing receiver built behind it — and they share
784
+ * one `AgentRelayClient` because the cache is keyed `${agent}::${relay}`. So calling `connect()`
785
+ * from the replacement receiver short-circuited on the session node's stream, sent nothing, and
786
+ * the relay never saw that receiver's transport identity: it revoked the reservation ~15s later,
787
+ * the watchdog rebuilt, and the agent churned on a ~45s loop holding no usable circuit address
788
+ * for as long as the conversation lasted. Any future "reuse the existing connection" optimisation
789
+ * here reintroduces exactly that.
790
+ *
791
+ * So this always opens its own short-lived stream from `node`, and marks it
792
+ * `purpose: "reservation"` so the relay proves possession WITHOUT rebinding the agent's delivery
793
+ * stream (which would steal the live session's inbound leaves — see the relay-side dispatch).
794
+ */
795
+ async proveReservation(node) {
796
+ if (this.#closed)
797
+ return false;
798
+ for (const addr of this.#relayAddrs) {
799
+ try {
800
+ await node.dial(addr);
801
+ break;
802
+ }
803
+ catch { /* try the next address */ }
804
+ }
805
+ let stream;
806
+ try {
807
+ stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
808
+ }
809
+ catch (err) {
810
+ this.#logger.warn("session.relay.reservation_proof.failed", {
811
+ relayPeerId: this.#relayPeerId, reason: "stream", error: extractErrorMessage(err),
812
+ });
813
+ return false;
814
+ }
815
+ try {
816
+ const iter = lp.decode(stream)[Symbol.asyncIterator]();
817
+ const ok = await this.#authenticate(stream, iter, "reservation");
818
+ this.#logger.info("session.relay.reservation_proof.result", {
819
+ relayPeerId: this.#relayPeerId,
820
+ nodePeerId: node.getPeerId(),
821
+ ok,
822
+ // DOD-M15-RELAYSLOTS-1: name the cause here too. `ok: false` alone sent people looking at
823
+ // the transport for what is usually a token or a cap.
824
+ ...(ok ? {} : { refusalReason: this.#lastAuthRefusal?.reason ?? "no_relay_verdict" }),
825
+ });
826
+ return ok;
827
+ }
828
+ finally {
829
+ await stream.close().catch(() => { });
830
+ }
831
+ }
554
832
  /** Ensure an authenticated stream exists, (re)dialing from `node` if needed. */
555
833
  async #ensureConnected(node) {
556
834
  if (this.#closed)
@@ -603,7 +881,7 @@ export class AgentRelayClient {
603
881
  this.#startReader(stream, iter);
604
882
  return true;
605
883
  }
606
- async #authenticate(stream, iter) {
884
+ async #authenticate(stream, iter, purpose) {
607
885
  const challengeRes = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
608
886
  if (challengeRes.done || challengeRes.value === undefined) {
609
887
  this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "no_challenge" });
@@ -627,8 +905,37 @@ export class AgentRelayClient {
627
905
  return false;
628
906
  }
629
907
  const authSig = await this.#keyProvider.sign(buildRelayAuthPayload(nonce, this.#senderPubkey));
908
+ /**
909
+ * DOD-M15-RELAYSLOTS-1: read the token NOW, not at construction — it is reissued on every
910
+ * signaling reconnect and the one this client was built with is usually already gone.
911
+ *
912
+ * When there is none we send the auth anyway. Declining to try would replace a named refusal
913
+ * from the relay (`online_token_required`, which says what is wrong and what to do) with
914
+ * silence on both sides — and silence is what an operator reads as "the product is broken".
915
+ */
916
+ const onlineToken = this.#onlineToken?.();
917
+ if (!onlineToken) {
918
+ this.#logger.warn("session.relay.auth.no_online_token", {
919
+ relayPeerId: this.#relayPeerId,
920
+ impact: "authenticating without the directory's online token. The relay will refuse this and " +
921
+ "will not let this node keep a circuit reservation, so the agent is reachable by nobody " +
922
+ "over this relay. The usual cause is that no directory connection has been established " +
923
+ "yet; the next signaling connect issues a token and the receiver re-authenticates.",
924
+ });
925
+ }
630
926
  try {
631
- stream.send(lp.encode.single(encodeCbor({ type: "relay_auth_response", pubkey: this.#senderPubkey, signature: authSig })));
927
+ stream.send(lp.encode.single(encodeCbor({
928
+ type: "relay_auth_response",
929
+ pubkey: this.#senderPubkey,
930
+ signature: authSig,
931
+ // DOD-M15-RELAYAUTH-1: absent for the ordinary session auth (which also registers this
932
+ // stream as the agent's delivery target). `"reservation"` proves possession from THIS
933
+ // node's transport identity and nothing more — see proveReservation().
934
+ ...(purpose ? { purpose } : {}),
935
+ // DOD-M15-RELAYSLOTS-1: opaque bytes from the directory, forwarded verbatim. The client
936
+ // never parses them — a format it does not read is a format it cannot get wrong.
937
+ ...(onlineToken ? { online_token: onlineToken } : {}),
938
+ })));
632
939
  }
633
940
  catch (err) {
634
941
  this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "response_send", error: extractErrorMessage(err) });
@@ -648,12 +955,50 @@ export class AgentRelayClient {
648
955
  return false;
649
956
  }
650
957
  if (ackFrame["type"] !== "relay_auth_ok") {
958
+ /**
959
+ * DOD-M15-RELAYABUSE-1 review F2 — **THE RELAY SAYS WHY, AND WE USED TO THROW IT AWAY.**
960
+ *
961
+ * `relay_auth_failed` carries a `reason` — `rate_limited`, `signature_invalid`, `nonce_expired`,
962
+ * `nonce_reused`, `nonce_unknown` — and, when throttled, a `retry_after_ms`. This branch
963
+ * collapsed all of them into the single word `auth_rejected`, so a throttled agent looked
964
+ * exactly like a bad signature, which looked exactly like a dead relay. That is precisely the
965
+ * distinction the order that added those refusals set out to create, undone at the last hop.
966
+ *
967
+ * The difference matters to whoever is looking: `rate_limited` clears by itself and says when;
968
+ * `signature_invalid` never clears and means a key or clock problem; a nonce failure means the
969
+ * handshake raced and an immediate retry is the right move. One label for all three sends
970
+ * someone to look for a broken relay in all three cases.
971
+ */
972
+ const relayReason = typeof ackFrame["reason"] === "string" ? ackFrame["reason"] : undefined;
973
+ const retryAfterMs = typeof ackFrame["retry_after_ms"] === "number" ? ackFrame["retry_after_ms"] : undefined;
974
+ /**
975
+ * DOD-M15-RELAYSLOTS-1: keep the refusal, do not merely log it.
976
+ *
977
+ * Everything below writes an excellent warn line into a file nobody opens. The operator who
978
+ * runs `cello_use_agent` and finds their agent unreachable never sees it, and the daemon
979
+ * deciding whether another relay would help cannot read it either. So the classified refusal
980
+ * — reason, what to do about it, and whether to fail over — is stored where both can reach it.
981
+ */
982
+ const slotsHeld = typeof ackFrame["slots_held"] === "number" ? ackFrame["slots_held"] : undefined;
983
+ const slotCap = typeof ackFrame["slot_cap"] === "number" ? ackFrame["slot_cap"] : undefined;
984
+ this.#lastAuthRefusal = classifyRelayAuthRefusal(ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame", {
985
+ ...(slotsHeld !== undefined ? { slotsHeld } : {}),
986
+ ...(slotCap !== undefined ? { slotCap } : {}),
987
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
988
+ });
651
989
  this.#logger.warn("session.relay.auth.failed", {
652
990
  relayPeerId: this.#relayPeerId,
653
- reason: ackFrame["type"] === "relay_auth_failed" ? "auth_rejected" : "unexpected_frame",
991
+ reason: ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame",
992
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
993
+ impact: relayReason === "rate_limited"
994
+ ? "this relay is throttling us; it clears on its own after the stated window, and until it does this agent cannot reserve or witness here"
995
+ : "this agent could not authenticate to this relay, so it cannot witness leaves or hold a reservation here",
654
996
  });
655
997
  return false;
656
998
  }
999
+ // DOD-M15-RELAYSLOTS-1: a success clears the stored refusal, so a stale one is never reported
1000
+ // as the current state of a relay that has since started admitting us.
1001
+ this.#lastAuthRefusal = null;
657
1002
  return true;
658
1003
  }
659
1004
  #startReader(stream, iter) {
@@ -729,18 +1074,132 @@ export class AgentRelayClient {
729
1074
  * `final_message` and from `answered` — guards that could never fire while this method hardcoded
730
1075
  * MSG for every caller. See `document-leaf-kind-on-the-wire.test.ts` for what that cost.
731
1076
  */
732
- async submitMessageHash(node, sessionId, contentHash, leafKind = LEAF_KIND_MSG) {
733
- return this.submitLeaf(node, sessionId, contentHash, leafKind);
1077
+ async submitMessageHash(node, sessionId, contentHash,
1078
+ /**
1079
+ * REQUIRED — `DOD-M15-SEALWIRE-1` B2b-1 pass-2 F3. This default was the last one on the path,
1080
+ * and it is the reason a test passing `undefined` for `leafKind` looked correct: the value was
1081
+ * silently rebuilt as MESSAGE one hop below the parameter the fix had just made required. Its
1082
+ * one production caller always passes explicitly, so the default was dead and misleading.
1083
+ */
1084
+ leafKind) {
1085
+ /**
1086
+ * `null`, and it is now IMPOSSIBLE to omit — see `submitLeaf`'s note on why the parameter is
1087
+ * required. A message leaf's content belongs to the operator and never reaches the relay.
1088
+ */
1089
+ return this.submitLeaf(node, sessionId, contentHash, leafKind, null);
734
1090
  }
735
1091
  /**
736
1092
  * Submit a leaf hash of a given kind (0x00 message / 0x02 control) to the relay. The SEAL
737
1093
  * ctrl leaf rides this path: two distinct-sender ctrl leaves in the relay's
738
1094
  * log trigger the directory's FROST notarization (relay `#maybeProcessSeal`).
1095
+ *
1096
+ * ─── `contentBytes` — `DOD-M15-SEALWIRE-1` bullets 3+4, THE SENDER LEG ───────────────────────
1097
+ *
1098
+ * The SEAL leaf's own payload, carried alongside its hash. Without it the directory holds a
1099
+ * SHA-256 pre-image and nothing else, so the client's SIGNED `final_root` — the one value in the
1100
+ * whole seal that the relay cannot produce — is unrecoverable, and every root check the directory
1101
+ * can make compares the relay against itself.
1102
+ *
1103
+ * ⚠️ THIS PARAMETER PUTS LEAF CONTENT ON THE RELAY, AND THE RELAY IS THE PARTY THIS PROTOCOL
1104
+ * EXISTS TO KEEP CONTENT AWAY FROM (INV-3: a forwarding relay sees ciphertext).
1105
+ *
1106
+ * It is safe for a SEAL ctrl leaf and for nothing else. The payload is `[session_id, final_root,
1107
+ * close_timestamp, "PENDING"]` and the relay already knows all four — it assigned the session,
1108
+ * built the tree the root comes from, and stamped the leaf. Nothing is disclosed. That reasoning
1109
+ * stops dead at the next leaf kind: a `msg` leaf's content is the operator's plaintext and a `doc`
1110
+ * leaf's is their document.
1111
+ *
1112
+ * So both directions are REFUSED rather than tidied, and refused HERE rather than at the relay:
1113
+ *
1114
+ * - content on a non-ctrl leaf → the relay would refuse the whole frame, but only after the
1115
+ * operator's words had already crossed the wire to the party that must not have them, and the
1116
+ * refusal would destroy their send rather than protect it.
1117
+ * - a ctrl leaf with NO payload → this was the actual defect. `submitSealLeaf` computed the
1118
+ * payload, hashed it, and had nowhere to put it, so it was dropped. The seal still succeeded,
1119
+ * the relay still acked, and three hops later the directory reported `not_carried` and blamed
1120
+ * the relay's build version — for a value the client never sent. Four reviewed legs shipped
1121
+ * over that silence. A dropped argument now fails on the machine that dropped it.
1122
+ *
1123
+ * ⚠️ REQUIRED, AND `| null` RATHER THAN `?` — THE TYPE IS THE GUARD.
1124
+ *
1125
+ * I first wrote this optional and covered it with tests. Then I ran the revert test that mattered:
1126
+ * drop the argument at the one call site that must pass it, exactly reproducing the original
1127
+ * defect. **All five new tests stayed green.** An optional parameter makes the defect a silent,
1128
+ * type-legal omission — which is precisely how it shipped through four reviews the first time.
1129
+ *
1130
+ * Required means the omission is a COMPILE ERROR, caught by the gate on the machine that made it,
1131
+ * before any test runs. Every caller must now say what this leaf carries, and `submitMessageHash`
1132
+ * says `null` in one visible place instead of by saying nothing at all.
739
1133
  */
740
- async submitLeaf(node, sessionId, contentHash, leafKind) {
1134
+ async submitLeaf(node, sessionId, contentHash, leafKind, contentBytes) {
1135
+ if (contentBytes !== null && leafKind !== LEAF_KIND_CTRL) {
1136
+ // Logged at ERROR and returned: a caller reaching this line is trying to hand the relay
1137
+ // operator content, and the log must carry it even if the caller swallows the result.
1138
+ this.#logger.error("session.relay.submit.content_not_permitted", {
1139
+ relayPeerId: this.#relayPeerId,
1140
+ leafKind,
1141
+ impact: "the submit was NOT sent. Only a SEAL ctrl leaf may carry its content to the relay; every other leaf kind's content belongs to the operator.",
1142
+ guidance: "Pass contentBytes only with LEAF_KIND_CTRL. If a new leaf kind genuinely needs to disclose its content to the relay, that is a protocol decision, not a call-site one.",
1143
+ });
1144
+ return { ok: false, reason: "content_not_permitted_for_leaf_kind" };
1145
+ }
1146
+ if (contentBytes === null && leafKind === LEAF_KIND_CTRL) {
1147
+ this.#logger.error("session.relay.submit.seal_payload_missing", {
1148
+ relayPeerId: this.#relayPeerId,
1149
+ impact: "the seal leaf was NOT sent. Sending it without its payload produces a certificate the directory cannot check against any participant's signed transcript — silently, and reported downstream as the RELAY being on an old build.",
1150
+ guidance: "A ctrl leaf on this path is a SEAL leaf; pass the encodeSealPayload bytes whose SHA-256(0x02 ‖ payload) is the contentHash argument.",
1151
+ });
1152
+ return { ok: false, reason: "seal_payload_not_carried" };
1153
+ }
1154
+ if (contentBytes !== null) {
1155
+ /**
1156
+ * ⚠️ THE BYTES MUST BE A SEAL PAYLOAD FOR THIS SESSION, AND THEY MUST HASH TO THE HASH BEING
1157
+ * SIGNED — review pass 2, MEDIUM-1 and HIGH-1. The kind check alone was not the property this
1158
+ * parameter's whole justification rests on.
1159
+ *
1160
+ * The justification is: *"the payload is [session_id, final_root, close_timestamp, "PENDING"]
1161
+ * and the relay already knows all four, so nothing is disclosed."* The code enforced
1162
+ * `leafKind === CTRL` and nothing else — so a caller passing a ctrl leaf with four kilobytes
1163
+ * of the operator's text would have transmitted it, and been refused only at the relay, AFTER
1164
+ * it crossed the wire to the party that must not have it. That is the precise harm the local
1165
+ * guard exists to prevent, and the relay learned this same lesson at its own review (H1 in
1166
+ * `relay-frames.ts`) one file over. I wrote the weaker version anyway.
1167
+ *
1168
+ * THE HASH BINDING IS THE MORE IMPORTANT HALF, and it closes a mutant that survived pass 1's
1169
+ * type hardening. Making the parameter required catches an OMITTED argument; it cannot catch a
1170
+ * SUBSTITUTED one. A caller that re-derives the payload instead of passing the one it hashed
1171
+ * — a second `encodeSealPayload` call, a fresh `Date.now()` — compiles, and the mismatch
1172
+ * surfaces at the directory as `seal_payload_unbound`, whose guidance reads *"someone between
1173
+ * them and here altered or fabricated the payload — the relay is the only party on that path.
1174
+ * Treat this as relay tampering, not a version mismatch."*
1175
+ *
1176
+ * **A client-side derivation slip would be published as a named accusation against a healthy
1177
+ * relay operator.** Checking it here makes it a local refusal on the machine that caused it.
1178
+ */
1179
+ const rederived = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(contentBytes).digest());
1180
+ if (!Buffer.from(rederived).equals(Buffer.from(contentHash))) {
1181
+ this.#logger.error("session.relay.submit.seal_payload_unbound", {
1182
+ relayPeerId: this.#relayPeerId,
1183
+ impact: "the seal leaf was NOT sent. The payload does not hash to the content_hash this leaf signs, so the directory would have reported it as RELAY TAMPERING — a named accusation against a node that did nothing wrong.",
1184
+ guidance: "Pass the SAME bytes that produced contentHash. Re-deriving the payload at the call site produces a different close_timestamp and breaks the binding.",
1185
+ });
1186
+ return { ok: false, reason: "seal_payload_unbound" };
1187
+ }
1188
+ const decoded = decodeSealPayload(contentBytes);
1189
+ if (!decoded || !Buffer.from(decoded.session_id).equals(Buffer.from(sessionId))) {
1190
+ this.#logger.error("session.relay.submit.seal_payload_invalid", {
1191
+ relayPeerId: this.#relayPeerId,
1192
+ impact: "the submit was NOT sent. Only a SEAL payload for THIS session may be disclosed to the relay — arbitrary bytes on a ctrl leaf are still the operator's content, and a payload for another session is a replay.",
1193
+ guidance: decoded
1194
+ ? "The payload names a different session than the one being submitted."
1195
+ : "The bytes are not a decodable SEAL payload. Build them with encodeSealPayload.",
1196
+ });
1197
+ return { ok: false, reason: "seal_payload_invalid" };
1198
+ }
1199
+ }
741
1200
  // Chain on the prior submit so only one is outstanding at a time (FIFO). The ack
742
1201
  // carries no session_id, so concurrent submits on one stream would be ambiguous.
743
- const run = this.#submitChain.then(() => this.#doSubmit(node, sessionId, contentHash, leafKind));
1202
+ const run = this.#submitChain.then(() => this.#doSubmit(node, sessionId, contentHash, leafKind, contentBytes));
744
1203
  // Keep the chain alive regardless of this submit's outcome.
745
1204
  this.#submitChain = run.then(() => undefined, () => undefined);
746
1205
  return run;
@@ -773,7 +1232,22 @@ export class AgentRelayClient {
773
1232
  * and returned as-is — retrying those would be pointless traffic masking a real state.
774
1233
  */
775
1234
  static #SESSION_NOT_FOUND_ATTEMPTS = 3;
776
- async #doSubmit(node, sessionId, contentHash, leafKind) {
1235
+ /**
1236
+ * DOD-M15-RELAYABUSE-1 review F1: how many times a throttled submit waits out the relay's stated
1237
+ * window before the refusal is surfaced. Three, because the window is a fixed sliding minute —
1238
+ * two waits clear any ordinary burst, and a third failure means something other than this
1239
+ * sender's own volume is going on, which the operator should hear about.
1240
+ */
1241
+ static #RATE_LIMITED_ATTEMPTS = 3;
1242
+ /** Used only when the relay names no window (an older relay, or a malformed value). */
1243
+ static #RATE_LIMITED_FALLBACK_MS = 5_000;
1244
+ /**
1245
+ * Ceiling on a single wait, so a relay reporting an implausible window cannot park a send
1246
+ * indefinitely — a hostile or misconfigured relay must not be able to stall a sender by
1247
+ * answering `retry_after_ms: 3600000`. Past this the send fails and says so.
1248
+ */
1249
+ static #RATE_LIMITED_MAX_WAIT_MS = 65_000;
1250
+ async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes) {
777
1251
  const sessionIdHex = Buffer.from(sessionId).toString("hex");
778
1252
  // Snapshotted BEFORE the first attempt, and it is the whole safety of this loop.
779
1253
  //
@@ -791,7 +1265,7 @@ export class AgentRelayClient {
791
1265
  // reported `session_not_found`, indistinguishable at the wire from the race — which is why
792
1266
  // this must be discriminated on OUR state, not on the relay's reason string.
793
1267
  const recordedBefore = this.#sessions.get(sessionIdHex)?.recorded === true;
794
- let result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind);
1268
+ let result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
795
1269
  for (let attempt = 1; attempt < _a.#SESSION_NOT_FOUND_ATTEMPTS
796
1270
  && !recordedBefore
797
1271
  && !result.ok
@@ -810,7 +1284,52 @@ export class AgentRelayClient {
810
1284
  attempt,
811
1285
  reason: result.reason,
812
1286
  });
813
- result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind);
1287
+ result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
1288
+ }
1289
+ /**
1290
+ * DOD-M15-RELAYABUSE-1 review F1 — **A THROTTLE IS BACK-PRESSURE, NOT AN ERROR.** (Andre,
1291
+ * 2026-08-31: retry on the relay's own timing; surface only if the retry also fails.)
1292
+ *
1293
+ * Without this the relay's `rate_limited` fell into the caller's catch-all: one log line, and
1294
+ * `cello_send` returned `{ok:true, delivered:true}` for a message the relay had refused to
1295
+ * witness — on the parked path telling the operator it was *"sealed, witnessed and on its way"*.
1296
+ * The leaf went out unwitnessed with no sequence number, and the seal later covered a transcript
1297
+ * missing it.
1298
+ *
1299
+ * This is the ONE refusal that is safely retryable and self-clearing, and the relay tells us
1300
+ * exactly when. So we wait it out here, where the wait is invisible, rather than handing the
1301
+ * agent an error for a condition that resolves in under a minute. Bounded: if the window is
1302
+ * absent or implausible we fall back to a fixed wait, and after
1303
+ * `#RATE_LIMITED_ATTEMPTS` the refusal is returned and the caller surfaces it — Option 2 as
1304
+ * the fallback, not the first move.
1305
+ */
1306
+ for (let attempt = 1; attempt < _a.#RATE_LIMITED_ATTEMPTS
1307
+ && !result.ok
1308
+ && result.reason === "rate_limited"
1309
+ && !this.#closed; attempt++) {
1310
+ const waitMs = Math.min(result.retry_after_ms !== undefined ? result.retry_after_ms : _a.#RATE_LIMITED_FALLBACK_MS, _a.#RATE_LIMITED_MAX_WAIT_MS);
1311
+ this.#logger.info("session.relay.submit.throttled", {
1312
+ relayPeerId: this.#relayPeerId,
1313
+ sessionShort: sessionIdHex.slice(0, 16),
1314
+ attempt,
1315
+ waitMs,
1316
+ retryAfterMsFromRelay: result.retry_after_ms,
1317
+ impact: "the relay is throttling this sender; waiting out its stated window and resubmitting — the message is NOT lost and the operator is not told, because this clears on its own",
1318
+ });
1319
+ await new Promise((r) => setTimeout(r, waitMs));
1320
+ if (this.#closed)
1321
+ break;
1322
+ result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
1323
+ }
1324
+ if (!result.ok && result.reason === "rate_limited") {
1325
+ // Option 2, the fallback: it did not clear within our budget, so the caller must hear it
1326
+ // rather than be told the message was witnessed.
1327
+ this.#logger.warn("session.relay.submit.throttle_persisted", {
1328
+ relayPeerId: this.#relayPeerId,
1329
+ sessionShort: sessionIdHex.slice(0, 16),
1330
+ attempts: _a.#RATE_LIMITED_ATTEMPTS,
1331
+ impact: "this message was NOT witnessed by the relay — it has no sequence number and will not appear in the notarized record",
1332
+ });
814
1333
  }
815
1334
  // The relay lost a session we had successfully recorded — sealed, idle-swept, or restarted.
816
1335
  // Report THAT, rather than letting the caller read a bare `session_not_found` that reads like
@@ -825,7 +1344,7 @@ export class AgentRelayClient {
825
1344
  }
826
1345
  return result;
827
1346
  }
828
- async #doSubmitOnce(node, sessionId, contentHash, leafKind) {
1347
+ async #doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes) {
829
1348
  if (this.#closed)
830
1349
  return { ok: false, reason: "relay_client_closed" };
831
1350
  if (!(await this.#ensureConnected(node)))
@@ -872,6 +1391,32 @@ export class AgentRelayClient {
872
1391
  leaf_kind: leafKind,
873
1392
  structure1_cbor: structure1,
874
1393
  sender_signature: signature,
1394
+ /**
1395
+ * `DOD-M15-SEALWIRE-1` bullets 3+4 — the SEAL payload, and ONLY on a ctrl leaf.
1396
+ *
1397
+ * ⚠️ MY REASON FOR THE SPREAD WAS MEASURABLY WRONG, AND THE TRUE RISK IS THE OPPOSITE ONE —
1398
+ * review pass 2, MEDIUM-3, corrected rather than deleted.
1399
+ *
1400
+ * It said an explicit `content_bytes: undefined` *"encodes as a present CBOR key, and the
1401
+ * relay's guard refuses a present-but-unusable value by voiding the whole frame — that would
1402
+ * turn every ordinary message into a refused submit."* Measured through the production encoder:
1403
+ * the key IS emitted (0xf7), but it decodes back to `undefined`, so the relay's guard never
1404
+ * fires and the frame is **accepted with no payload**.
1405
+ *
1406
+ * So the mutation does not produce a loud federation-wide refusal. It produces a silent
1407
+ * `not_carried` at the directory — exactly the silent downgrade this whole unit exists to kill,
1408
+ * and a far worse outcome than the one I warned about. Writing the scarier consequence would
1409
+ * have sent the next reader hunting an availability bug instead of a mute one.
1410
+ *
1411
+ * The spread is still correct, and the ANCHOR test is what pins it: `"content_bytes" in frame`
1412
+ * is TRUE for the `undefined` mutant precisely because the key is present, so that assertion —
1413
+ * not the relay — is what catches this.
1414
+ *
1415
+ * `submitLeaf` has already established that this is set if and only if `leafKind` is ctrl, and
1416
+ * that the bytes are a SEAL payload for this session hashing to the signed `content_hash` —
1417
+ * every direction refused there, at ERROR, before anything reaches the wire.
1418
+ */
1419
+ ...(contentBytes !== null ? { content_bytes: contentBytes } : {}),
875
1420
  });
876
1421
  // Set the resolver synchronously (no await between the in-flight check and the set):
877
1422
  // the submit chain guarantees no other submit runs concurrently, so #pendingAck is null.
@@ -882,6 +1427,7 @@ export class AgentRelayClient {
882
1427
  // Remember this submit's sender-signed structure1_cbor so its ack can return the full
883
1428
  // ordering record (the ack itself carries only the relay's structure2_cbor).
884
1429
  this.#pendingStructure1 = structure1;
1430
+ this.#pendingSignature = signature;
885
1431
  this.#pendingLeafKind = leafKind;
886
1432
  try {
887
1433
  stream.send(lp.encode.single(frame));
@@ -891,6 +1437,7 @@ export class AgentRelayClient {
891
1437
  this.#pendingAck = null;
892
1438
  this.#pendingAckSessionHex = null;
893
1439
  this.#pendingStructure1 = null;
1440
+ this.#pendingSignature = null;
894
1441
  this.#pendingLeafKind = null;
895
1442
  }
896
1443
  this.#logger.warn("session.relay.submit.send.failed", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
@@ -913,6 +1460,7 @@ export class AgentRelayClient {
913
1460
  this.#pendingAck = null;
914
1461
  this.#pendingAckSessionHex = null;
915
1462
  this.#pendingStructure1 = null;
1463
+ this.#pendingSignature = null;
916
1464
  this.#pendingLeafKind = null;
917
1465
  }
918
1466
  }