@cello-protocol/daemon 0.0.182 → 0.0.184

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/agent-settings-keys.d.ts.map +1 -1
  2. package/dist/agent-settings-keys.js +18 -0
  3. package/dist/agent-settings-keys.js.map +1 -1
  4. package/dist/close-session-handler.d.ts.map +1 -1
  5. package/dist/close-session-handler.js +90 -16
  6. package/dist/close-session-handler.js.map +1 -1
  7. package/dist/consortium-bootstrap.d.ts.map +1 -1
  8. package/dist/consortium-bootstrap.js +117 -2
  9. package/dist/consortium-bootstrap.js.map +1 -1
  10. package/dist/content-encryption-status.d.ts +97 -0
  11. package/dist/content-encryption-status.d.ts.map +1 -0
  12. package/dist/content-encryption-status.js +122 -0
  13. package/dist/content-encryption-status.js.map +1 -0
  14. package/dist/content-park-client.d.ts +10 -0
  15. package/dist/content-park-client.d.ts.map +1 -1
  16. package/dist/content-park-client.js +84 -3
  17. package/dist/content-park-client.js.map +1 -1
  18. package/dist/content-park.d.ts.map +1 -1
  19. package/dist/content-park.js +184 -13
  20. package/dist/content-park.js.map +1 -1
  21. package/dist/daemon.d.ts.map +1 -1
  22. package/dist/daemon.js +671 -67
  23. package/dist/daemon.js.map +1 -1
  24. package/dist/document-handlers.d.ts.map +1 -1
  25. package/dist/document-handlers.js +119 -0
  26. package/dist/document-handlers.js.map +1 -1
  27. package/dist/document-layer.d.ts +0 -2
  28. package/dist/document-layer.d.ts.map +1 -1
  29. package/dist/document-layer.js.map +1 -1
  30. package/dist/frame-values.d.ts.map +1 -1
  31. package/dist/frame-values.js +14 -0
  32. package/dist/frame-values.js.map +1 -1
  33. package/dist/inclusion-proof-handlers.d.ts +43 -0
  34. package/dist/inclusion-proof-handlers.d.ts.map +1 -0
  35. package/dist/inclusion-proof-handlers.js +565 -0
  36. package/dist/inclusion-proof-handlers.js.map +1 -0
  37. package/dist/inclusion-proof.d.ts +151 -0
  38. package/dist/inclusion-proof.d.ts.map +1 -0
  39. package/dist/inclusion-proof.js +228 -0
  40. package/dist/inclusion-proof.js.map +1 -0
  41. package/dist/initiate-session-handler.d.ts.map +1 -1
  42. package/dist/initiate-session-handler.js +34 -3
  43. package/dist/initiate-session-handler.js.map +1 -1
  44. package/dist/manifest-deps.d.ts +0 -11
  45. package/dist/manifest-deps.d.ts.map +1 -1
  46. package/dist/manifest-deps.js +28 -2
  47. package/dist/manifest-deps.js.map +1 -1
  48. package/dist/manifest-validity.d.ts +2 -0
  49. package/dist/manifest-validity.d.ts.map +1 -1
  50. package/dist/manifest-validity.js +1 -1
  51. package/dist/manifest-validity.js.map +1 -1
  52. package/dist/network-directory-node.d.ts +16 -0
  53. package/dist/network-directory-node.d.ts.map +1 -1
  54. package/dist/network-directory-node.js +40 -0
  55. package/dist/network-directory-node.js.map +1 -1
  56. package/dist/notification-handlers.d.ts.map +1 -1
  57. package/dist/notification-handlers.js +71 -2
  58. package/dist/notification-handlers.js.map +1 -1
  59. package/dist/outbound-sessions.d.ts +6 -0
  60. package/dist/outbound-sessions.d.ts.map +1 -1
  61. package/dist/outbound-sessions.js +50 -7
  62. package/dist/outbound-sessions.js.map +1 -1
  63. package/dist/park-envelope.d.ts +23 -0
  64. package/dist/park-envelope.d.ts.map +1 -1
  65. package/dist/park-envelope.js +76 -0
  66. package/dist/park-envelope.js.map +1 -1
  67. package/dist/register-handler.d.ts.map +1 -1
  68. package/dist/register-handler.js +76 -0
  69. package/dist/register-handler.js.map +1 -1
  70. package/dist/relay-only.d.ts +133 -0
  71. package/dist/relay-only.d.ts.map +1 -0
  72. package/dist/relay-only.js +183 -0
  73. package/dist/relay-only.js.map +1 -0
  74. package/dist/retry-queue.d.ts +26 -1
  75. package/dist/retry-queue.d.ts.map +1 -1
  76. package/dist/retry-queue.js +18 -1
  77. package/dist/retry-queue.js.map +1 -1
  78. package/dist/seal-coordinator.d.ts +51 -1
  79. package/dist/seal-coordinator.d.ts.map +1 -1
  80. package/dist/seal-coordinator.js +248 -1
  81. package/dist/seal-coordinator.js.map +1 -1
  82. package/dist/seal-escalation.d.ts.map +1 -1
  83. package/dist/seal-escalation.js +28 -0
  84. package/dist/seal-escalation.js.map +1 -1
  85. package/dist/seal-failure-store.d.ts.map +1 -1
  86. package/dist/seal-failure-store.js +5 -1
  87. package/dist/seal-failure-store.js.map +1 -1
  88. package/dist/sealed-leaf-set.d.ts +92 -0
  89. package/dist/sealed-leaf-set.d.ts.map +1 -0
  90. package/dist/sealed-leaf-set.js +129 -0
  91. package/dist/sealed-leaf-set.js.map +1 -0
  92. package/dist/session-ceremony.d.ts +27 -1
  93. package/dist/session-ceremony.d.ts.map +1 -1
  94. package/dist/session-ceremony.js +109 -7
  95. package/dist/session-ceremony.js.map +1 -1
  96. package/dist/session-content-handlers.d.ts +12 -0
  97. package/dist/session-content-handlers.d.ts.map +1 -1
  98. package/dist/session-content-handlers.js +39 -4
  99. package/dist/session-content-handlers.js.map +1 -1
  100. package/dist/session-node-manager.d.ts +387 -4
  101. package/dist/session-node-manager.d.ts.map +1 -1
  102. package/dist/session-node-manager.js +3529 -279
  103. package/dist/session-node-manager.js.map +1 -1
  104. package/dist/session-read-handlers.d.ts.map +1 -1
  105. package/dist/session-read-handlers.js +16 -1
  106. package/dist/session-read-handlers.js.map +1 -1
  107. package/dist/session-relay-client.d.ts +163 -1
  108. package/dist/session-relay-client.d.ts.map +1 -1
  109. package/dist/session-relay-client.js +540 -6
  110. package/dist/session-relay-client.js.map +1 -1
  111. package/dist/session-salt-agreement.d.ts +58 -4
  112. package/dist/session-salt-agreement.d.ts.map +1 -1
  113. package/dist/session-salt-agreement.js +110 -3
  114. package/dist/session-salt-agreement.js.map +1 -1
  115. package/dist/signal-submission.d.ts +11 -1
  116. package/dist/signal-submission.d.ts.map +1 -1
  117. package/dist/signal-submission.js.map +1 -1
  118. package/dist/signaling-connect.d.ts +18 -1
  119. package/dist/signaling-connect.d.ts.map +1 -1
  120. package/dist/signaling-connect.js +164 -1
  121. package/dist/signaling-connect.js.map +1 -1
  122. package/dist/submission-retry.d.ts +208 -0
  123. package/dist/submission-retry.d.ts.map +1 -0
  124. package/dist/submission-retry.js +505 -0
  125. package/dist/submission-retry.js.map +1 -0
  126. package/dist/types.d.ts +33 -0
  127. package/dist/types.d.ts.map +1 -1
  128. package/dist/types.js.map +1 -1
  129. package/dist/vocabulary.d.ts +13 -3
  130. package/dist/vocabulary.d.ts.map +1 -1
  131. package/dist/vocabulary.js +14 -3
  132. package/dist/vocabulary.js.map +1 -1
  133. package/dist/wire-content-hash.d.ts +15 -3
  134. package/dist/wire-content-hash.d.ts.map +1 -1
  135. package/dist/wire-content-hash.js +15 -3
  136. package/dist/wire-content-hash.js.map +1 -1
  137. package/package.json +5 -5
@@ -32,6 +32,7 @@ import { createHash } from "node:crypto";
32
32
  import * as lp from "it-length-prefixed";
33
33
  import { decode } from "cbor-x";
34
34
  import { encodeCbor, decodeSealPayload } from "@cello-protocol/protocol-types";
35
+ import { verify } from "@cello-protocol/crypto";
35
36
  import { evaluateRelayAck } from "./relay-receipt-store.js";
36
37
  export const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
37
38
  export const RELAY_AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
@@ -72,6 +73,97 @@ export const TERMINAL_RELAY_REFUSALS = new Set([
72
73
  export function isTerminalRelayRefusal(reason) {
73
74
  return reason !== undefined && TERMINAL_RELAY_REFUSALS.has(reason);
74
75
  }
76
+ /**
77
+ * DOD-M15-RELAYSLOTS-1 — **WHICH REFUSALS JUSTIFY TRYING A DIFFERENT RELAY.**
78
+ *
79
+ * We run several relays, so "move on to the next one" is always available — which is exactly why it
80
+ * needs a rule. Moving on from a problem that every relay will have turns one client-side fault into
81
+ * what looks like a fleet-wide outage, and the operator then goes looking for a broken relay.
82
+ *
83
+ * ENUMERATED, never pattern-matched, for the same reason `TERMINAL_RELAY_REFUSALS` is: a substring
84
+ * rule would silently absorb a future reason nobody has considered.
85
+ */
86
+ const RELAY_SIDE_REFUSALS = new Set([
87
+ /**
88
+ * The relay holds no directory public key, so it can verify nothing and is refusing everyone.
89
+ * That is this relay being misconfigured, not us being wrong — another relay is the right move,
90
+ * and it is the whole reason we run more than one.
91
+ */
92
+ "online_token_no_directory_key",
93
+ ]);
94
+ /**
95
+ * Classify a relay's auth refusal: what to tell the operator, and whether another relay would help.
96
+ *
97
+ * Everything not in `RELAY_SIDE_REFUSALS` defaults to "do not try another", and that default is the
98
+ * safe direction. A token problem reproduces identically on every relay in the fleet, so retrying
99
+ * around the fleet spends real time turning a client fault into an apparent outage — and a slot cap
100
+ * IS satisfiable elsewhere, but spreading to another relay papers over sessions that leaked and
101
+ * brings the same wall back on the next one.
102
+ */
103
+ export function classifyRelayAuthRefusal(reason, extra = {}) {
104
+ const tryAnotherRelay = RELAY_SIDE_REFUSALS.has(reason);
105
+ let advice;
106
+ switch (reason) {
107
+ case "online_token_required":
108
+ advice = "This agent has no online token from a directory yet. It is issued when a directory " +
109
+ "marks the agent online, so this usually clears itself on the next directory connection. If " +
110
+ "it persists, the agent is not reaching any directory — check that first, not the relay.";
111
+ break;
112
+ case "online_token_expired":
113
+ advice = "The online token has expired and is refreshed on the next directory connection. If " +
114
+ "it keeps expiring, this machine's clock or its directory connection is the thing to look at.";
115
+ break;
116
+ case "online_token_signature_invalid":
117
+ case "online_token_malformed":
118
+ case "online_token_lifetime_too_long":
119
+ advice = "This relay would not accept the token this agent was issued. Most often the relay " +
120
+ "and the directory are not in the same consortium — check which directories this relay is " +
121
+ "configured to trust.";
122
+ break;
123
+ case "online_token_pubkey_mismatch":
124
+ advice = "The token names a different key from the one this agent signed with. That is an " +
125
+ "identity mix-up on this machine, not a relay problem.";
126
+ break;
127
+ case "online_token_no_directory_key":
128
+ advice = "This relay holds no directory public key, so it cannot verify anyone and is " +
129
+ "refusing every agent. Its operator needs to configure one; another relay will work now.";
130
+ break;
131
+ case "slot_cap_exceeded":
132
+ advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
133
+ ? `This agent already holds ${String(extra.slotsHeld)} of a maximum ${String(extra.slotCap)} ` +
134
+ "reservations on this relay, and none is idle enough to reclaim. That is almost always " +
135
+ "sessions that were never closed — close some and this clears. Moving to another relay " +
136
+ "would work now and hit the same wall there."
137
+ : "This agent already holds the most reservations one agent may hold on this relay. That is " +
138
+ "almost always sessions that were never closed — close some and this clears.";
139
+ break;
140
+ case "session_tuple_cap_exceeded":
141
+ advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
142
+ ? `You already have ${String(extra.slotsHeld)} conversations open with this counterparty, ` +
143
+ `which is the maximum of ${String(extra.slotCap)} this relay allows between one pair of ` +
144
+ "agents. Close some and try again — this is almost always conversations that were never " +
145
+ "closed rather than ones anybody is still using."
146
+ : "You already have the maximum number of concurrent conversations open with this " +
147
+ "counterparty. Close some and try again.";
148
+ break;
149
+ case "rate_limited":
150
+ advice = extra.retryAfterMs !== undefined
151
+ ? `This relay is throttling this agent; it clears on its own in about ${String(Math.ceil(extra.retryAfterMs / 1000))}s.`
152
+ : "This relay is throttling this agent; it clears on its own after the throttle window.";
153
+ break;
154
+ default:
155
+ advice = "This agent could not authenticate to this relay, so it cannot hold a reservation " +
156
+ "here and is reachable only over a direct connection.";
157
+ }
158
+ return {
159
+ reason,
160
+ advice,
161
+ tryAnotherRelay,
162
+ ...(extra.slotsHeld !== undefined ? { slotsHeld: extra.slotsHeld } : {}),
163
+ ...(extra.slotCap !== undefined ? { slotCap: extra.slotCap } : {}),
164
+ ...(extra.retryAfterMs !== undefined ? { retryAfterMs: extra.retryAfterMs } : {}),
165
+ };
166
+ }
75
167
  export const LEAF_KIND_MSG = 0x00;
76
168
  /** Control leaf (SEAL etc.) — two distinct-sender ctrl leaves trigger directory notarization. */
77
169
  export const LEAF_KIND_CTRL = 0x02;
@@ -100,6 +192,19 @@ export function buildRelayAuthPayload(nonce, pubkey) {
100
192
  export function encodeStructure1(contentHash, senderPubkey, sessionId, lastSeenSeq, timestamp) {
101
193
  return encodeCbor([1, contentHash, senderPubkey, sessionId, lastSeenSeq, timestamp]);
102
194
  }
195
+ /**
196
+ * Rebuild the bytes a relay signs when it reports a witnessed forgery.
197
+ *
198
+ * ⚠️ **MIRRORED CODEC** — `packages/relay/src/leaf-witness.ts` `buildWitnessAlertTbs` in
199
+ * trustless-cello is the other half and the two MUST stay in sync. Both call `encodeCbor` from
200
+ * `@cello-protocol/protocol-types` rather than configuring an encoder, so the only thing that can
201
+ * drift is the field list, and both list it in one place.
202
+ */
203
+ const RELAY_WITNESS_DOMAIN = "CELLO-RELAY-WITNESS-v1";
204
+ function buildWitnessAlertTbs(sessionId, reason, observedAt, submitterIsCounterparty) {
205
+ const body = encodeCbor([RELAY_WITNESS_DOMAIN, sessionId, reason, observedAt, submitterIsCounterparty]);
206
+ return new Uint8Array(createHash("sha256").update(body).digest());
207
+ }
103
208
  /**
104
209
  * Extract a real message from a thrown value. libp2p / cross-package errors are not
105
210
  * always `instanceof Error` in this realm (multi-version split), so fall back to a
@@ -167,6 +272,22 @@ export class AgentRelayClient {
167
272
  #logger;
168
273
  #receiptStore;
169
274
  #sealLeafStore;
275
+ /** DOD-M15-RELAYSLOTS-1 — read fresh at every auth. See `AgentRelayClientOpts.onlineToken`. */
276
+ #onlineToken;
277
+ /** DOD-M15-CORROBORATE-1 — where a relay's witness alert goes. See the opt of the same name. */
278
+ #onWitnessAlert;
279
+ /** DOD-M15-CORROBORATE-1 review F7 — where an UNREADABLE witness alert goes. */
280
+ #onWitnessUnreadable;
281
+ /**
282
+ * DOD-M15-RELAYSLOTS-1: the last refusal this relay gave us, classified. Kept because a log line
283
+ * reaches neither the operator asking why their agent is unreachable nor the code deciding
284
+ * whether a different relay would do any better.
285
+ */
286
+ #lastAuthRefusal = null;
287
+ /** The last classified auth refusal from this relay, or null if the last attempt succeeded. */
288
+ getLastAuthRefusal() {
289
+ return this.#lastAuthRefusal;
290
+ }
170
291
  #stream = null;
171
292
  #connecting = null;
172
293
  #closed = false;
@@ -182,6 +303,13 @@ export class AgentRelayClient {
182
303
  // The sender-signed structure1_cbor of the in-flight submit, paired with its ack so the
183
304
  // SubmitResult can carry it (the ack itself only returns the relay's structure2_cbor).
184
305
  #pendingStructure1 = null;
306
+ /**
307
+ * DOD-M15-SEALWIRE-1 bullet 5 (sent half) — OUR signature over the in-flight `#pendingStructure1`,
308
+ * paired with its ack for the same reason that one is: the ack returns the relay's record, never
309
+ * ours. Cleared wherever `#pendingStructure1` is cleared; the two must never drift apart, because
310
+ * a signature paired with the WRONG signed bytes is worse than no signature at all.
311
+ */
312
+ #pendingSignature = null;
185
313
  // The in-flight submit's leaf kind (0x00 msg / 0x02 ctrl), paired with its ack so
186
314
  // #captureReceipt can persist it alongside the Structure2/Structure1 carry bytes for the unilateral seal.
187
315
  #pendingLeafKind = null;
@@ -205,6 +333,9 @@ export class AgentRelayClient {
205
333
  this.#logger = opts.logger;
206
334
  this.#receiptStore = opts.receiptStore;
207
335
  this.#sealLeafStore = opts.sealLeafStore;
336
+ this.#onlineToken = opts.onlineToken;
337
+ this.#onWitnessAlert = opts.onWitnessAlert;
338
+ this.#onWitnessUnreadable = opts.onWitnessUnreadable;
208
339
  }
209
340
  /** The agent's K_local public key as hex — the responder identity for auto-acknowledge. */
210
341
  get senderPubkeyHex() {
@@ -236,6 +367,29 @@ export class AgentRelayClient {
236
367
  .then(() => undefined, () => undefined);
237
368
  }
238
369
  }
370
+ /**
371
+ * DOD-M15-RELAYAUTH-1 review H1 — **present the assignment and WAIT for the relay to say it
372
+ * recorded it.**
373
+ *
374
+ * `registerSession` above presents eagerly and forgets: the record is queued onto the submit chain
375
+ * and nobody can observe when it lands. That is correct for the witness relay, where the only
376
+ * requirement is "before the first submit". It is NOT sufficient for the relay that GATES A DIAL,
377
+ * because there the record is a precondition of an action we are about to take on another thread
378
+ * of the protocol — and losing that race denies a legitimate dial (review H1).
379
+ *
380
+ * Chained on `#submitChain` exactly like `#doSubmit`, so it cannot interleave with a submit on the
381
+ * same stream. Idempotent by construction: `#doRecord` returns `true` immediately once the session
382
+ * is recorded, so calling this straight after `registerSession` waits for the record that call
383
+ * already queued rather than sending a second one.
384
+ *
385
+ * Returns whether the relay recorded it. NEVER throws — a caller must be free to proceed on false
386
+ * (a dial that might be denied still beats no dial at all).
387
+ */
388
+ async recordAssignmentAndWait(node, sessionIdHex) {
389
+ const run = this.#submitChain.then(() => this.#doRecord(node, sessionIdHex));
390
+ this.#submitChain = run.then(() => undefined, () => undefined);
391
+ return run.catch(() => false);
392
+ }
239
393
  /**
240
394
  * Present the directory-signed assignment to the relay. Idempotent
241
395
  * (no-op once `recorded`, or when the session has no assignment — direct/persisted/legacy sessions).
@@ -327,12 +481,25 @@ export class AgentRelayClient {
327
481
  hasSessions() {
328
482
  return this.#sessions.size > 0;
329
483
  }
484
+ /**
485
+ * Is THIS session already registered on this client?
486
+ *
487
+ * DOD-M15-RELAYLEAK-1 (review MEDIUM-5). The detached seal transport releases its registration
488
+ * when the submit finishes, and "did I register it, or did I find it already there?" is the
489
+ * difference between releasing my own and **pulling a live one out from under a concurrent
490
+ * caller** — which closes the client that caller is mid-`submitLeaf` on. `hasSessions()` cannot
491
+ * answer it: it is a count, and by then the id is in the set either way.
492
+ */
493
+ hasSession(sessionIdHex) {
494
+ return this.#sessions.has(sessionIdHex);
495
+ }
330
496
  /** Settle the one outstanding submit (if any) exactly once. */
331
497
  #settlePending(r) {
332
498
  const resolve = this.#pendingAck;
333
499
  this.#pendingAck = null;
334
500
  this.#pendingAckSessionHex = null;
335
501
  this.#pendingStructure1 = null;
502
+ this.#pendingSignature = null;
336
503
  this.#pendingLeafKind = null;
337
504
  if (resolve)
338
505
  resolve(r);
@@ -472,6 +639,8 @@ export class AgentRelayClient {
472
639
  const s2 = frame["structure2_cbor"];
473
640
  const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
474
641
  const structure1Cbor = this.#pendingStructure1 ?? undefined;
642
+ // Captured with structure1Cbor and BEFORE #settlePending clears both — see #pendingSignature.
643
+ const senderSignature = this.#pendingSignature ?? undefined;
475
644
  // Verify the relay's signed ordering record and durably store the receipt BEFORE
476
645
  // settling (which clears #pendingStructure1, the source of the content hash + session id). A
477
646
  // signed-but-INVALID ACK rejects the submit so the send does not settle ok on an unverified sequence.
@@ -479,7 +648,7 @@ export class AgentRelayClient {
479
648
  this.#settlePending(rejectSubmit
480
649
  ? { ok: false, reason: "relay_ack_signature_invalid" }
481
650
  : seq >= 0
482
- ? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor }
651
+ ? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor, sender_signature: senderSignature }
483
652
  : { ok: false, reason: "relay_ack_malformed" });
484
653
  }
485
654
  else if (type === "hash_submit_error") {
@@ -487,7 +656,16 @@ export class AgentRelayClient {
487
656
  // Carry the relay's `detail` through — see `SubmitResult`. Reading the class and discarding
488
657
  // what happened is how a refusal arrives as a bare code with no cause attached to it.
489
658
  const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
490
- this.#settlePending({ ok: false, reason, ...(detail ? { detail } : {}) });
659
+ // DOD-M15-RELAYABUSE-1: the relay knows when its window clears and says so. Carried, not
660
+ // dropped — `#doSubmit` waits it out and resubmits, so a throttle never reaches the operator.
661
+ const rawRetry = frame["retry_after_ms"];
662
+ const retry_after_ms = typeof rawRetry === "number" && Number.isFinite(rawRetry) && rawRetry > 0 ? rawRetry : undefined;
663
+ this.#settlePending({
664
+ ok: false,
665
+ reason,
666
+ ...(detail ? { detail } : {}),
667
+ ...(retry_after_ms !== undefined ? { retry_after_ms } : {}),
668
+ });
491
669
  }
492
670
  else if (type === "assignment_ok") {
493
671
  // The relay verified + recorded our client-presented assignment.
@@ -499,7 +677,23 @@ export class AgentRelayClient {
499
677
  else if (type === "assignment_invalid") {
500
678
  // The relay rejected the assignment (e.g. directory_signature_invalid — not signed by any
501
679
  // consortium directory). Fail LOUD: the session has no relay witness until this is resolved.
502
- this.#logger.warn("session.relay.assignment.invalid", { relayPeerId: this.#relayPeerId, reason: typeof frame["reason"] === "string" ? frame["reason"] : "unknown" });
680
+ const reason = typeof frame["reason"] === "string" ? frame["reason"] : "unknown";
681
+ this.#logger.warn("session.relay.assignment.invalid", { relayPeerId: this.#relayPeerId, reason });
682
+ /**
683
+ * DOD-M15-RELAYSLOTS-1 review M2 — **the tuple cap has to reach the operator too.**
684
+ *
685
+ * Clause 7 says EVERY refusal reaches them with a cause and an affordance, and this one was
686
+ * arriving as `assignment_invalid` in a log. It is also the refusal most likely to hit a real
687
+ * person, for the reason the order itself gives: nobody knows what sessions they have open, so
688
+ * whoever hits it believes they have none. Routed through the same classifier and onto the
689
+ * same surface as every other relay refusal.
690
+ */
691
+ const concurrent = typeof frame["concurrent_sessions"] === "number" ? frame["concurrent_sessions"] : undefined;
692
+ const cap = typeof frame["session_cap"] === "number" ? frame["session_cap"] : undefined;
693
+ this.#lastAuthRefusal = classifyRelayAuthRefusal(reason, {
694
+ ...(concurrent !== undefined ? { slotsHeld: concurrent } : {}),
695
+ ...(cap !== undefined ? { slotCap: cap } : {}),
696
+ });
503
697
  const r = this.#pendingRecord;
504
698
  this.#pendingRecord = null;
505
699
  if (r)
@@ -559,6 +753,161 @@ export class AgentRelayClient {
559
753
  });
560
754
  }
561
755
  }
756
+ else if (type === "session_witness_alert") {
757
+ /**
758
+ * DOD-M15-CORROBORATE-1 — **the relay is telling us what it saw, and this is where it lands.**
759
+ *
760
+ * A relay is not trusted to send a well-formed frame any more than a peer is, so every field
761
+ * is checked before anything is reported. A frame that does not decode is a MISBEHAVING OR
762
+ * SKEWED RELAY, not an alert: reporting it as one would let a broken build manufacture
763
+ * accusations against a counterparty who did nothing. It is logged loudly and goes no further.
764
+ */
765
+ const sid = frame["session_id"];
766
+ const rawRelayId = frame["relay_id"];
767
+ const observedAt = frame["observed_at"];
768
+ const submitterIsCounterparty = frame["submitter_is_counterparty"];
769
+ const sidBytes = sid instanceof Uint8Array || Buffer.isBuffer(sid) ? toU8(sid) : new Uint8Array();
770
+ const wellFormed = sidBytes.length === 16
771
+ && frame["reason"] === "leaf_signed_by_neither_participant"
772
+ && typeof observedAt === "number" && Number.isFinite(observedAt)
773
+ && typeof submitterIsCounterparty === "boolean"
774
+ && (rawRelayId === undefined || typeof rawRelayId === "string");
775
+ const unreadable = (why) => {
776
+ this.#logger.error("session.relay.witness.malformed", {
777
+ relayPeerId: this.#relayPeerId,
778
+ why,
779
+ impact: "this relay sent a witness alert this build cannot read, so NOTHING has been " +
780
+ "reported to the operator about it. Treat it as a relay fault or a version skew, not " +
781
+ "as evidence about either participant.",
782
+ });
783
+ /**
784
+ * Review F7 — **and it reaches a surface, not only this file.** If a version skew makes
785
+ * every relay's alert unreadable, the witness layer is silently dead and the operator would
786
+ * have no way to find that out. Deliberately carries NO session and NO party: it says a
787
+ * relay sent something we could not read, and claims nothing about anyone.
788
+ */
789
+ this.#onWitnessUnreadable?.(this.#relayPeerId, why);
790
+ };
791
+ if (!wellFormed) {
792
+ unreadable("field_shape");
793
+ return;
794
+ }
795
+ const sessionIdHex = Buffer.from(sidBytes).toString("hex");
796
+ /**
797
+ * ⚠️ **IT MUST BE A SESSION THIS CLIENT ACTUALLY HOLDS ON THIS RELAY** — review F2.
798
+ *
799
+ * `wellFormed` checks shape and shape only. Without this, any relay we are authenticated to
800
+ * could push alerts naming arbitrary session ids — including conversations carried by a
801
+ * DIFFERENT relay — and they would land in the operator's inbox as statements of fact about
802
+ * a counterparty. Combined with a bounded notice list that is the cheap mute: flood
803
+ * fabrications until the real one is gone.
804
+ */
805
+ if (!this.#sessions.has(sessionIdHex)) {
806
+ /**
807
+ * ⚠️ **REFUSED AS AN ALERT, BUT NOT BINNED IN SILENCE** — fallback-finder HIGH 1.
808
+ *
809
+ * It cannot be reported as an observation about a conversation: a relay naming a session we
810
+ * do not hold is exactly the fabrication the check above exists to stop, and rendering it
811
+ * would put a claim about a counterparty in front of an operator on a stranger's say-so.
812
+ *
813
+ * But dropping it entirely was worse than it looked, because the relay's own copy is GONE by
814
+ * then: its queue is keyed by PUBKEY and `drainWitnessAlerts` splices, so it hands over
815
+ * everything it held for this agent the moment any client authenticates. The detached seal
816
+ * client registers exactly ONE session and then authenticates — so a restart-then-seal on
817
+ * one conversation destroyed a held alert about a different one, at both ends, and the
818
+ * operator's inbox looked clean.
819
+ *
820
+ * So it goes to the same neutral surface a version skew does: something arrived that this
821
+ * daemon could not place. No session, no party, no claim.
822
+ */
823
+ this.#logger.error("session.relay.witness.unknown_session", {
824
+ relayPeerId: this.#relayPeerId,
825
+ session: sessionIdHex,
826
+ impact: "not rendered as an observation — this relay named a session this client is not " +
827
+ "holding. Reported to the operator as an unplaceable witness report, never as a claim " +
828
+ "about a counterparty.",
829
+ });
830
+ this.#onWitnessUnreadable?.(this.#relayPeerId, "session_not_held_here");
831
+ return;
832
+ }
833
+ /**
834
+ * A DECLARED IDENTITY MUST BE PROVEN — review F3. `relay_id` is the hex of the key that signs
835
+ * every `hash_submit_ack`, so the same check that verifies a receipt verifies this. Missing,
836
+ * malformed and mismatched take ONE path: omitting the proof is the cheapest way to dodge it.
837
+ */
838
+ const rawSig = frame["witness_signature"];
839
+ const sigBytes = rawSig instanceof Uint8Array || Buffer.isBuffer(rawSig) ? toU8(rawSig) : null;
840
+ let verifiable = false;
841
+ if (typeof rawRelayId === "string") {
842
+ if (!/^[0-9a-fA-F]{64}$/.test(rawRelayId)) {
843
+ unreadable("relay_id_not_a_pubkey");
844
+ return;
845
+ }
846
+ if (!sigBytes || sigBytes.length !== 64) {
847
+ unreadable("declared_relay_id_without_signature");
848
+ return;
849
+ }
850
+ const tbs = buildWitnessAlertTbs(sidBytes, "leaf_signed_by_neither_participant", observedAt, submitterIsCounterparty);
851
+ if (!verify(new Uint8Array(Buffer.from(rawRelayId, "hex")), tbs, sigBytes)) {
852
+ unreadable("witness_signature_invalid");
853
+ return;
854
+ }
855
+ verifiable = true;
856
+ }
857
+ const alert = {
858
+ sessionIdHex,
859
+ reason: "leaf_signed_by_neither_participant",
860
+ relayId: typeof rawRelayId === "string" ? rawRelayId : null,
861
+ observedAt,
862
+ submitterIsCounterparty,
863
+ witnessPeerId: this.#relayPeerId,
864
+ verifiable,
865
+ };
866
+ // BOTH halves, per Invariant 2: the log is the durable forensic record, and the callback is
867
+ // the half that actually reaches a person.
868
+ this.#logger.error("session.relay.witness.alert", {
869
+ relayPeerId: this.#relayPeerId,
870
+ session: alert.sessionIdHex,
871
+ relayId: alert.relayId ?? "(unnamed)",
872
+ submitterIsCounterparty: alert.submitterIsCounterparty,
873
+ verifiable: alert.verifiable,
874
+ observation: "one relay refused a leaf on this session because it verified against neither participant key",
875
+ impact: "nothing was added to the conversation record. This is ONE relay's observation and " +
876
+ "establishes only that it saw and refused that submission — not who sent it.",
877
+ });
878
+ if (this.#onWitnessAlert)
879
+ this.#onWitnessAlert(alert);
880
+ }
881
+ else if (type === "relay_slot_reclaimed") {
882
+ /**
883
+ * DOD-M15-RELAYSLOTS-1 clause 8 — **the reaped party is told, and this is where it lands.**
884
+ *
885
+ * The relay reclaimed this agent's circuit reservation to free capacity. Without a branch
886
+ * here the frame fell off the end of this chain and was discarded in silence, which is the
887
+ * trap the order records in its own words: a refusal that only reaches the relay's log does
888
+ * not exist. From the agent's side the reservation simply stops working.
889
+ *
890
+ * Recorded as a refusal so it reaches `cello_status` through the same surface as every other
891
+ * relay refusal, with the same shape: a cause, and what to do about it.
892
+ */
893
+ const idleMs = typeof frame["idle_ms"] === "number" ? frame["idle_ms"] : undefined;
894
+ const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
895
+ this.#lastAuthRefusal = {
896
+ reason: "slot_reclaimed",
897
+ advice: detail ?? "This relay reclaimed your circuit reservation to free capacity because it " +
898
+ "had carried no traffic for a long time. Your agent stays online and rebuilds its receiver " +
899
+ "automatically; a new session will take a fresh reservation.",
900
+ // Not the relay's fault and not ours — it was under pressure and we were the quietest. The
901
+ // client rebuilds against the same pool, so there is nothing to fail over from.
902
+ tryAnotherRelay: false,
903
+ };
904
+ this.#logger.warn("session.relay.slot_reclaimed", {
905
+ relayPeerId: this.#relayPeerId,
906
+ ...(idleMs !== undefined ? { idleHours: Math.round(idleMs / 3_600_000) } : {}),
907
+ impact: "this relay reclaimed our circuit reservation to free capacity. Until a receiver is " +
908
+ "rebuilt, this agent is reachable only over a direct connection.",
909
+ });
910
+ }
562
911
  // session_interrupted / content_park_notify are out of scope here — session interruption
563
912
  // is handled by the session node manager's dedicated relay-stream watcher.
564
913
  }
@@ -570,6 +919,61 @@ export class AgentRelayClient {
570
919
  async connect(node) {
571
920
  return this.#ensureConnected(node);
572
921
  }
922
+ /**
923
+ * DOD-M15-RELAYAUTH-1 review HIGH-1 — prove key possession FROM THIS NODE, on its own stream.
924
+ *
925
+ * ⚠️ **`connect()` CANNOT be used for this, and using it was the defect.** `#ensureConnected`
926
+ * returns `true` the moment `#stream` is non-null, and `#stream` belongs to whichever node
927
+ * connected FIRST. An agent legitimately runs several nodes against one relay — the node promoted
928
+ * into a live session, plus the replacement standing receiver built behind it — and they share
929
+ * one `AgentRelayClient` because the cache is keyed `${agent}::${relay}`. So calling `connect()`
930
+ * from the replacement receiver short-circuited on the session node's stream, sent nothing, and
931
+ * the relay never saw that receiver's transport identity: it revoked the reservation ~15s later,
932
+ * the watchdog rebuilt, and the agent churned on a ~45s loop holding no usable circuit address
933
+ * for as long as the conversation lasted. Any future "reuse the existing connection" optimisation
934
+ * here reintroduces exactly that.
935
+ *
936
+ * So this always opens its own short-lived stream from `node`, and marks it
937
+ * `purpose: "reservation"` so the relay proves possession WITHOUT rebinding the agent's delivery
938
+ * stream (which would steal the live session's inbound leaves — see the relay-side dispatch).
939
+ */
940
+ async proveReservation(node) {
941
+ if (this.#closed)
942
+ return false;
943
+ for (const addr of this.#relayAddrs) {
944
+ try {
945
+ await node.dial(addr);
946
+ break;
947
+ }
948
+ catch { /* try the next address */ }
949
+ }
950
+ let stream;
951
+ try {
952
+ stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
953
+ }
954
+ catch (err) {
955
+ this.#logger.warn("session.relay.reservation_proof.failed", {
956
+ relayPeerId: this.#relayPeerId, reason: "stream", error: extractErrorMessage(err),
957
+ });
958
+ return false;
959
+ }
960
+ try {
961
+ const iter = lp.decode(stream)[Symbol.asyncIterator]();
962
+ const ok = await this.#authenticate(stream, iter, "reservation");
963
+ this.#logger.info("session.relay.reservation_proof.result", {
964
+ relayPeerId: this.#relayPeerId,
965
+ nodePeerId: node.getPeerId(),
966
+ ok,
967
+ // DOD-M15-RELAYSLOTS-1: name the cause here too. `ok: false` alone sent people looking at
968
+ // the transport for what is usually a token or a cap.
969
+ ...(ok ? {} : { refusalReason: this.#lastAuthRefusal?.reason ?? "no_relay_verdict" }),
970
+ });
971
+ return ok;
972
+ }
973
+ finally {
974
+ await stream.close().catch(() => { });
975
+ }
976
+ }
573
977
  /** Ensure an authenticated stream exists, (re)dialing from `node` if needed. */
574
978
  async #ensureConnected(node) {
575
979
  if (this.#closed)
@@ -622,7 +1026,7 @@ export class AgentRelayClient {
622
1026
  this.#startReader(stream, iter);
623
1027
  return true;
624
1028
  }
625
- async #authenticate(stream, iter) {
1029
+ async #authenticate(stream, iter, purpose) {
626
1030
  const challengeRes = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
627
1031
  if (challengeRes.done || challengeRes.value === undefined) {
628
1032
  this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "no_challenge" });
@@ -646,8 +1050,37 @@ export class AgentRelayClient {
646
1050
  return false;
647
1051
  }
648
1052
  const authSig = await this.#keyProvider.sign(buildRelayAuthPayload(nonce, this.#senderPubkey));
1053
+ /**
1054
+ * DOD-M15-RELAYSLOTS-1: read the token NOW, not at construction — it is reissued on every
1055
+ * signaling reconnect and the one this client was built with is usually already gone.
1056
+ *
1057
+ * When there is none we send the auth anyway. Declining to try would replace a named refusal
1058
+ * from the relay (`online_token_required`, which says what is wrong and what to do) with
1059
+ * silence on both sides — and silence is what an operator reads as "the product is broken".
1060
+ */
1061
+ const onlineToken = this.#onlineToken?.();
1062
+ if (!onlineToken) {
1063
+ this.#logger.warn("session.relay.auth.no_online_token", {
1064
+ relayPeerId: this.#relayPeerId,
1065
+ impact: "authenticating without the directory's online token. The relay will refuse this and " +
1066
+ "will not let this node keep a circuit reservation, so the agent is reachable by nobody " +
1067
+ "over this relay. The usual cause is that no directory connection has been established " +
1068
+ "yet; the next signaling connect issues a token and the receiver re-authenticates.",
1069
+ });
1070
+ }
649
1071
  try {
650
- stream.send(lp.encode.single(encodeCbor({ type: "relay_auth_response", pubkey: this.#senderPubkey, signature: authSig })));
1072
+ stream.send(lp.encode.single(encodeCbor({
1073
+ type: "relay_auth_response",
1074
+ pubkey: this.#senderPubkey,
1075
+ signature: authSig,
1076
+ // DOD-M15-RELAYAUTH-1: absent for the ordinary session auth (which also registers this
1077
+ // stream as the agent's delivery target). `"reservation"` proves possession from THIS
1078
+ // node's transport identity and nothing more — see proveReservation().
1079
+ ...(purpose ? { purpose } : {}),
1080
+ // DOD-M15-RELAYSLOTS-1: opaque bytes from the directory, forwarded verbatim. The client
1081
+ // never parses them — a format it does not read is a format it cannot get wrong.
1082
+ ...(onlineToken ? { online_token: onlineToken } : {}),
1083
+ })));
651
1084
  }
652
1085
  catch (err) {
653
1086
  this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "response_send", error: extractErrorMessage(err) });
@@ -667,12 +1100,50 @@ export class AgentRelayClient {
667
1100
  return false;
668
1101
  }
669
1102
  if (ackFrame["type"] !== "relay_auth_ok") {
1103
+ /**
1104
+ * DOD-M15-RELAYABUSE-1 review F2 — **THE RELAY SAYS WHY, AND WE USED TO THROW IT AWAY.**
1105
+ *
1106
+ * `relay_auth_failed` carries a `reason` — `rate_limited`, `signature_invalid`, `nonce_expired`,
1107
+ * `nonce_reused`, `nonce_unknown` — and, when throttled, a `retry_after_ms`. This branch
1108
+ * collapsed all of them into the single word `auth_rejected`, so a throttled agent looked
1109
+ * exactly like a bad signature, which looked exactly like a dead relay. That is precisely the
1110
+ * distinction the order that added those refusals set out to create, undone at the last hop.
1111
+ *
1112
+ * The difference matters to whoever is looking: `rate_limited` clears by itself and says when;
1113
+ * `signature_invalid` never clears and means a key or clock problem; a nonce failure means the
1114
+ * handshake raced and an immediate retry is the right move. One label for all three sends
1115
+ * someone to look for a broken relay in all three cases.
1116
+ */
1117
+ const relayReason = typeof ackFrame["reason"] === "string" ? ackFrame["reason"] : undefined;
1118
+ const retryAfterMs = typeof ackFrame["retry_after_ms"] === "number" ? ackFrame["retry_after_ms"] : undefined;
1119
+ /**
1120
+ * DOD-M15-RELAYSLOTS-1: keep the refusal, do not merely log it.
1121
+ *
1122
+ * Everything below writes an excellent warn line into a file nobody opens. The operator who
1123
+ * runs `cello_use_agent` and finds their agent unreachable never sees it, and the daemon
1124
+ * deciding whether another relay would help cannot read it either. So the classified refusal
1125
+ * — reason, what to do about it, and whether to fail over — is stored where both can reach it.
1126
+ */
1127
+ const slotsHeld = typeof ackFrame["slots_held"] === "number" ? ackFrame["slots_held"] : undefined;
1128
+ const slotCap = typeof ackFrame["slot_cap"] === "number" ? ackFrame["slot_cap"] : undefined;
1129
+ this.#lastAuthRefusal = classifyRelayAuthRefusal(ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame", {
1130
+ ...(slotsHeld !== undefined ? { slotsHeld } : {}),
1131
+ ...(slotCap !== undefined ? { slotCap } : {}),
1132
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
1133
+ });
670
1134
  this.#logger.warn("session.relay.auth.failed", {
671
1135
  relayPeerId: this.#relayPeerId,
672
- reason: ackFrame["type"] === "relay_auth_failed" ? "auth_rejected" : "unexpected_frame",
1136
+ reason: ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame",
1137
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
1138
+ impact: relayReason === "rate_limited"
1139
+ ? "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"
1140
+ : "this agent could not authenticate to this relay, so it cannot witness leaves or hold a reservation here",
673
1141
  });
674
1142
  return false;
675
1143
  }
1144
+ // DOD-M15-RELAYSLOTS-1: a success clears the stored refusal, so a stale one is never reported
1145
+ // as the current state of a relay that has since started admitting us.
1146
+ this.#lastAuthRefusal = null;
676
1147
  return true;
677
1148
  }
678
1149
  #startReader(stream, iter) {
@@ -906,6 +1377,21 @@ export class AgentRelayClient {
906
1377
  * and returned as-is — retrying those would be pointless traffic masking a real state.
907
1378
  */
908
1379
  static #SESSION_NOT_FOUND_ATTEMPTS = 3;
1380
+ /**
1381
+ * DOD-M15-RELAYABUSE-1 review F1: how many times a throttled submit waits out the relay's stated
1382
+ * window before the refusal is surfaced. Three, because the window is a fixed sliding minute —
1383
+ * two waits clear any ordinary burst, and a third failure means something other than this
1384
+ * sender's own volume is going on, which the operator should hear about.
1385
+ */
1386
+ static #RATE_LIMITED_ATTEMPTS = 3;
1387
+ /** Used only when the relay names no window (an older relay, or a malformed value). */
1388
+ static #RATE_LIMITED_FALLBACK_MS = 5_000;
1389
+ /**
1390
+ * Ceiling on a single wait, so a relay reporting an implausible window cannot park a send
1391
+ * indefinitely — a hostile or misconfigured relay must not be able to stall a sender by
1392
+ * answering `retry_after_ms: 3600000`. Past this the send fails and says so.
1393
+ */
1394
+ static #RATE_LIMITED_MAX_WAIT_MS = 65_000;
909
1395
  async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes) {
910
1396
  const sessionIdHex = Buffer.from(sessionId).toString("hex");
911
1397
  // Snapshotted BEFORE the first attempt, and it is the whole safety of this loop.
@@ -945,6 +1431,51 @@ export class AgentRelayClient {
945
1431
  });
946
1432
  result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
947
1433
  }
1434
+ /**
1435
+ * DOD-M15-RELAYABUSE-1 review F1 — **A THROTTLE IS BACK-PRESSURE, NOT AN ERROR.** (Andre,
1436
+ * 2026-08-31: retry on the relay's own timing; surface only if the retry also fails.)
1437
+ *
1438
+ * Without this the relay's `rate_limited` fell into the caller's catch-all: one log line, and
1439
+ * `cello_send` returned `{ok:true, delivered:true}` for a message the relay had refused to
1440
+ * witness — on the parked path telling the operator it was *"sealed, witnessed and on its way"*.
1441
+ * The leaf went out unwitnessed with no sequence number, and the seal later covered a transcript
1442
+ * missing it.
1443
+ *
1444
+ * This is the ONE refusal that is safely retryable and self-clearing, and the relay tells us
1445
+ * exactly when. So we wait it out here, where the wait is invisible, rather than handing the
1446
+ * agent an error for a condition that resolves in under a minute. Bounded: if the window is
1447
+ * absent or implausible we fall back to a fixed wait, and after
1448
+ * `#RATE_LIMITED_ATTEMPTS` the refusal is returned and the caller surfaces it — Option 2 as
1449
+ * the fallback, not the first move.
1450
+ */
1451
+ for (let attempt = 1; attempt < _a.#RATE_LIMITED_ATTEMPTS
1452
+ && !result.ok
1453
+ && result.reason === "rate_limited"
1454
+ && !this.#closed; attempt++) {
1455
+ const waitMs = Math.min(result.retry_after_ms !== undefined ? result.retry_after_ms : _a.#RATE_LIMITED_FALLBACK_MS, _a.#RATE_LIMITED_MAX_WAIT_MS);
1456
+ this.#logger.info("session.relay.submit.throttled", {
1457
+ relayPeerId: this.#relayPeerId,
1458
+ sessionShort: sessionIdHex.slice(0, 16),
1459
+ attempt,
1460
+ waitMs,
1461
+ retryAfterMsFromRelay: result.retry_after_ms,
1462
+ 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",
1463
+ });
1464
+ await new Promise((r) => setTimeout(r, waitMs));
1465
+ if (this.#closed)
1466
+ break;
1467
+ result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
1468
+ }
1469
+ if (!result.ok && result.reason === "rate_limited") {
1470
+ // Option 2, the fallback: it did not clear within our budget, so the caller must hear it
1471
+ // rather than be told the message was witnessed.
1472
+ this.#logger.warn("session.relay.submit.throttle_persisted", {
1473
+ relayPeerId: this.#relayPeerId,
1474
+ sessionShort: sessionIdHex.slice(0, 16),
1475
+ attempts: _a.#RATE_LIMITED_ATTEMPTS,
1476
+ impact: "this message was NOT witnessed by the relay — it has no sequence number and will not appear in the notarized record",
1477
+ });
1478
+ }
948
1479
  // The relay lost a session we had successfully recorded — sealed, idle-swept, or restarted.
949
1480
  // Report THAT, rather than letting the caller read a bare `session_not_found` that reads like
950
1481
  // the first-message race. Never re-present here: recreating it is the resurrection above.
@@ -1041,6 +1572,7 @@ export class AgentRelayClient {
1041
1572
  // Remember this submit's sender-signed structure1_cbor so its ack can return the full
1042
1573
  // ordering record (the ack itself carries only the relay's structure2_cbor).
1043
1574
  this.#pendingStructure1 = structure1;
1575
+ this.#pendingSignature = signature;
1044
1576
  this.#pendingLeafKind = leafKind;
1045
1577
  try {
1046
1578
  stream.send(lp.encode.single(frame));
@@ -1050,6 +1582,7 @@ export class AgentRelayClient {
1050
1582
  this.#pendingAck = null;
1051
1583
  this.#pendingAckSessionHex = null;
1052
1584
  this.#pendingStructure1 = null;
1585
+ this.#pendingSignature = null;
1053
1586
  this.#pendingLeafKind = null;
1054
1587
  }
1055
1588
  this.#logger.warn("session.relay.submit.send.failed", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
@@ -1072,6 +1605,7 @@ export class AgentRelayClient {
1072
1605
  this.#pendingAck = null;
1073
1606
  this.#pendingAckSessionHex = null;
1074
1607
  this.#pendingStructure1 = null;
1608
+ this.#pendingSignature = null;
1075
1609
  this.#pendingLeafKind = null;
1076
1610
  }
1077
1611
  }