@real-life/trust-protocol 0.6.0 → 0.7.0

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.
@@ -20,10 +20,50 @@
20
20
  // about transport; the holder's receive core verifies the delivery-ack
21
21
  // itself.
22
22
  //
23
- // Profile: spec/adapter-vti-mediator.md v0.23 (twenty-third casting;
23
+ // Profile: spec/adapter-vti-mediator.md v0.24 (twenty-fourth casting;
24
24
  // round journals under design/adapter-review*). A
25
25
  // transport adapter contract — NOT a carrier at the port.
26
26
  //
27
+ // Sending form (profile §3a, lib 0.7.0): every deposit names its mode to
28
+ // the wire. Mode `routed` (the mode of every instance from construction):
29
+ // an inner TSP `direct` message egress identity → counterpart's
30
+ // connection DID inside an outer routed message connection DID → C with
31
+ // the route [counterpart's connection DID], on the connection DID's
32
+ // session. Mode `egress-session` (fallback): the inner message alone, on
33
+ // a send-only session of the egress identity. The wire packs the layers
34
+ // (depositAddressing says who addresses whom); the adapter keeps the
35
+ // relationship's send state (the mode and the open routed deposits) in
36
+ // its ack state (RLTP-VTI-3130). Open are: every document with a digest
37
+ // (an entry of kind `document` filed by reconcile) accepted in routed and
38
+ // not yet answered — a valid acknowledgement, a validated refusal or a
39
+ // validated idConflict for its digest answers it. Acknowledgements are
40
+ // no open deposits: a side that only acknowledges never switches by the
41
+ // deadline (Open Issue 9). The adapter switches one way to
42
+ // `egress-session` when an open deposit is routed-ack-deadline (72 h)
43
+ // old, checked in an advance after a conclusion that left nothing
44
+ // pending and while nothing is pending again — "nothing pending" is the
45
+ // binding's statement through queueEmpty() (RLTP-VTI-7150); without it
46
+ // the adapter never switches — and at once on a
47
+ // validated refusal of the routed form (WireError 'routed-refused').
48
+ // At the switch the open deposits leave; nothing returns to routed. A
49
+ // submit() without a digest opens nothing. An attempt
50
+ // ends in the mode it began in; an accepted submission is never
51
+ // deposited again (RLTP-VTI-3135, 7130).
52
+ //
53
+ // The app's transport contract (web-of-trust TRANSPORT.md, rules 5 and
54
+ // 6), as far as it holds here: (5) a withdrawal — reconcile without the
55
+ // entry — means the adapter starts no deposit for that submission
56
+ // afterwards, and an outcome that resolves late changes nothing here
57
+ // (has() false, report() null, no send state) — except a validated refusal
58
+ // of the routed form, which switches the relationship (a finding about the
59
+ // mediator, RLTP-VTI-3130). A wire call already
60
+ // running cannot be recalled and may still land at the mediator (the §6
61
+ // boundary); the copy that then arrives is a duplicate the receiver
62
+ // absorbs (Delivery §6.2). (6) Every deposit is a submission with an id
63
+ // of its own, a duplicate's answer (`resend`) included; the
64
+ // DeliveryReport handed to the holder carries the digest, and the app
65
+ // pairs it with its deposit through the submission id it polls.
66
+ //
27
67
  // Beyond the profile (lib 0.5.0): conclude(collection, now, {keep}) leaves
28
68
  // the items whose disposition is transient unconcluded and offers them
29
69
  // again (Delivery 0.82 RLTP-DEL-5810, 5820: failed(rate-limited) is never
@@ -53,7 +93,29 @@ export const DECLARED = Object.freeze({
53
93
  queueFloorBytes: 1_048_576,
54
94
  maxQueueBytes: 16_777_216,
55
95
  duplicateWindowMs: 30 * 86_400_000,
96
+ routedAckDeadlineMs: 72 * 3_600_000,
56
97
  });
98
+ const SEND_MODES = new Set(['routed', 'egress-session']);
99
+ /** The addressing of a deposit in `mode` (RLTP-VTI-1110, 1120, 3110,
100
+ * 3115, 3120): the inner `direct` message from this side's egress
101
+ * identity to the counterpart's connection DID; in mode `routed` an
102
+ * outer routed message from this side's connection DID to the carrier
103
+ * `C` with the route [counterpart's connection DID], deposited on the
104
+ * connection DID's session; in mode `egress-session` no outer layer,
105
+ * deposited on the egress identity's send-only session. */
106
+ export function depositAddressing(mode, self, peerConnection, carrier) {
107
+ if (!SEND_MODES.has(mode))
108
+ throw new Error(`unknown send mode ${String(mode)}`);
109
+ const inner = { messageType: 'direct', sender: self.egress, receiver: peerConnection };
110
+ if (mode === 'routed') {
111
+ return {
112
+ inner,
113
+ outer: { messageType: 'routed', sender: self.connection, receiver: carrier, route: [peerConnection] },
114
+ session: self.connection,
115
+ };
116
+ }
117
+ return { inner, outer: null, session: self.egress };
118
+ }
57
119
  export class WireError extends Error {
58
120
  kind;
59
121
  constructor(kind, message) {
@@ -89,6 +151,45 @@ export function concludable(r) {
89
151
  }
90
152
  /** The adapter's own copy of a wire's token set: the wire keeps no handle on it. */
91
153
  const tokenCopy = (t) => ({ accessToken: t.accessToken, accessExpiresAt: t.accessExpiresAt, refreshToken: t.refreshToken, refreshExpiresAt: t.refreshExpiresAt });
154
+ /** how long an open routed deposit stays in the send state at most
155
+ * (RLTP-VTI-3130): the state is bounded even where no switch check runs */
156
+ const OPEN_KEEP_MS = DECLARED.routedAckDeadlineMs + DECLARED.duplicateWindowMs;
157
+ const finite = (x) => typeof x === 'number' && Number.isFinite(x);
158
+ /** A restored send state, checked fail-closed (§4): exactly `mode` and
159
+ * `open`, a known mode, each entry exactly a digest and a finite
160
+ * acceptance instant not after restoredAt, each digest once, nothing open
161
+ * in egress-session. Any other field — the acknowledgement fields of an
162
+ * earlier form included — is refused, not ignored. */
163
+ function restoreSendState(send, restoredAt) {
164
+ const refuse = (why) => { throw new Error(`ack snapshot: send state ${why}`); };
165
+ if (typeof send !== 'object' || send === null)
166
+ return refuse('is not an object');
167
+ const s = send;
168
+ const exactly = (o, keys) => {
169
+ const own = Object.keys(o);
170
+ return own.length === keys.length && keys.every((k) => own.includes(k));
171
+ };
172
+ if (!exactly(s, ['mode', 'open']))
173
+ refuse('has other fields than mode and open');
174
+ if (typeof s.mode !== 'string' || !SEND_MODES.has(s.mode))
175
+ refuse('names an unknown mode');
176
+ if (!Array.isArray(s.open))
177
+ refuse('open deposits are not a list');
178
+ const open = s.open.map((r) => {
179
+ const e = r;
180
+ if (typeof e !== 'object' || e === null || !exactly(e, ['digest', 'acceptedAt']) ||
181
+ typeof e.digest !== 'string' || !finite(e.acceptedAt) || e.acceptedAt > restoredAt) {
182
+ return refuse('open deposit is malformed, non-finite or accepted after restoredAt');
183
+ }
184
+ return { digest: e.digest, acceptedAt: e.acceptedAt };
185
+ });
186
+ if (new Set(open.map((o) => o.digest)).size !== open.length)
187
+ refuse('names an open deposit twice');
188
+ // the switch empties it: an egress-session with open deposits was never produced
189
+ if (s.mode === 'egress-session' && open.length > 0)
190
+ refuse('is egress-session with open routed deposits');
191
+ return { mode: s.mode, open };
192
+ }
92
193
  const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
93
194
  // construction capability: only create() holds it
94
195
  const KEY = Symbol('MediatorCarrierAdapter.create');
@@ -126,6 +227,12 @@ export class MediatorCarrierAdapter {
126
227
  #flushing = false;
127
228
  #nextColId = 1;
128
229
  #transitions = [];
230
+ // the relationship's send state (RLTP-VTI-3130)
231
+ #mode = 'routed';
232
+ /** open routed deposits (documents), keyed by digest */
233
+ #openDeposits = new Map();
234
+ /** this instance has concluded a collection that left nothing pending */
235
+ #cleanConcluded = false;
129
236
  /** The only way to build an adapter: the trio is DERIVED here from
130
237
  * one root, one carrier, one nonce — so principal, connection DID
131
238
  * and egress identity cannot come from different relationships
@@ -189,47 +296,66 @@ export class MediatorCarrierAdapter {
189
296
  if (pending.has(queueId))
190
297
  this.#pendingAcks.add(queueId);
191
298
  }
299
+ // absent: a host that persisted none starts again in routed
300
+ if (restored.send !== undefined) {
301
+ const send = restoreSendState(restored.send, restoredAt);
302
+ this.#mode = send.mode;
303
+ // an open deposit falls routed-ack-deadline plus duplicate-window
304
+ // after its acceptance: cut here, not refused
305
+ for (const o of send.open)
306
+ if (restoredAt - o.acceptedAt < OPEN_KEEP_MS)
307
+ this.#openDeposits.set(o.digest, o.acceptedAt);
308
+ }
192
309
  }
193
310
  this.#wire.onDeliver(async (raw) => {
194
311
  if (this.#closed)
195
- return;
312
+ return false;
196
313
  // a plain copy of its own: a Node Buffer's slice() would share memory
197
314
  const bytes = new Uint8Array(raw);
198
315
  const queueId = hex(await sha(bytes));
199
316
  if (this.#closed)
200
- return;
201
- // Absorption is WINDOW-checked at the moment it happens
202
- // (R11-B1), and the window beats everything (round-14 B):
203
- // beyond it, entry AND any leftover intent fall, and the frame
204
- // is a new item — so no acknowledgement, owed or phantom, can
205
- // ever delete the only fresh copy.
317
+ return false;
318
+ // Absorption is checked at the moment it happens (R11-B1): the
319
+ // adapter absorbs a copy only for give-up-horizon after its
320
+ // conclusion (profile §2, §4); a later copy is a new item for the
321
+ // holder, whose absorption answers it again with the stored
322
+ // acknowledgement (RLTP-DEL-6330, 6335) — so a counterpart that
323
+ // resubmits after a lost acknowledgement is answered. The entry and
324
+ // its ack intent stand for the duplicate window, and the window
325
+ // beats everything (round-14 B): beyond it, both fall.
326
+ // buffered and outstanding go before concluded (§3): a redelivery of
327
+ // a later copy the holder has not concluded yet is ignored
328
+ if (this.#inboxDigests.has(queueId))
329
+ return true;
330
+ for (const col of this.#outstanding.values())
331
+ if (col.has(queueId))
332
+ return true;
206
333
  const concludedAt = this.#concluded.get(queueId);
207
334
  if (concludedAt !== undefined) {
208
335
  if (this.#flightAcks.has(queueId) ||
209
- this.#clock - concludedAt < DECLARED.duplicateWindowMs) {
210
- // inside the window — or deferred: an id inside an
336
+ this.#clock - concludedAt < DECLARED.giveUpHorizonMs) {
337
+ // inside the horizon — or deferred: an id inside an
211
338
  // in-flight flush keeps absorbing until that flush ends,
212
339
  // because its irrecallable ack may still land (R15-B1)
213
340
  this.#pendingAcks.add(queueId);
214
- return;
341
+ return true;
342
+ }
343
+ if (this.#clock - concludedAt >= DECLARED.duplicateWindowMs) {
344
+ this.#concluded.delete(queueId);
345
+ this.#pendingAcks.delete(queueId);
215
346
  }
216
- this.#concluded.delete(queueId);
217
- this.#pendingAcks.delete(queueId);
218
347
  }
219
- if (this.#inboxDigests.has(queueId))
220
- return;
221
- for (const col of this.#outstanding.values())
222
- if (col.has(queueId))
223
- return;
224
348
  const held = this.#inboxBytes + this.#outstandingBytes;
225
349
  const underFloor = this.#inbox.length < DECLARED.queueFloorCount &&
226
350
  held + bytes.length <= DECLARED.queueFloorBytes;
227
351
  const underCap = held + bytes.length <= DECLARED.maxQueueBytes;
352
+ // not acknowledged and not taken: the mediator keeps its copy
228
353
  if (!underFloor && !underCap)
229
- return;
354
+ return false;
230
355
  this.#inbox.push({ bytes, queueId });
231
356
  this.#inboxBytes += bytes.length;
232
357
  this.#inboxDigests.add(queueId);
358
+ return true;
233
359
  });
234
360
  }
235
361
  #now(raw) {
@@ -343,6 +469,7 @@ export class MediatorCarrierAdapter {
343
469
  attemptStartedAt: null,
344
470
  lastKind: null,
345
471
  st: null,
472
+ sentMode: null,
346
473
  };
347
474
  this.#subs.set(id, sub);
348
475
  if (!knownOffline)
@@ -408,7 +535,7 @@ export class MediatorCarrierAdapter {
408
535
  bytes: new TextEncoder().encode(jcs(entry.envelope)),
409
536
  // a duplicate's ack is a hand-over of its own, from now
410
537
  submittedAt: entry.resend !== undefined ? now : Math.min(entry.since, now),
411
- attemptStartedAt: null, lastKind: null, st: null,
538
+ attemptStartedAt: null, lastKind: null, st: null, sentMode: null,
412
539
  };
413
540
  this.#subs.set(id, sub);
414
541
  refs.push({ submissionId: id, digest: entry.digest });
@@ -449,14 +576,19 @@ export class MediatorCarrierAdapter {
449
576
  if (sub.st !== null && sub.st.state !== 'awaiting-transport')
450
577
  return;
451
578
  let next;
579
+ let routedRefused = false;
452
580
  try {
453
581
  const token = await this.#ensureAuth(now);
454
582
  if (this.#closed)
455
583
  return; // retirement between auth and deposit: nothing new starts (R5-B2)
456
- // each attempt hands the wire a copy: what it does with it never reaches the next
584
+ // each attempt hands the wire a copy: what it does with it never reaches the next.
585
+ // The mode is read at the hand-over itself: from a switch on, every
586
+ // hand-over uses the new mode, and a call already made ends in its own
587
+ // (RLTP-VTI-3135)
457
588
  const deposit = () => {
589
+ sub.sentMode = this.#mode;
458
590
  try {
459
- return this.#wire.deposit(token, new Uint8Array(sub.bytes), this.#egressDid);
591
+ return this.#wire.deposit(token, new Uint8Array(sub.bytes), this.#egressDid, sub.sentMode);
460
592
  }
461
593
  catch (e) {
462
594
  return Promise.reject(e);
@@ -507,7 +639,13 @@ export class MediatorCarrierAdapter {
507
639
  next = { state: 'accepted' };
508
640
  }
509
641
  catch (e) {
510
- const kind = e instanceof WireError ? e.kind : 'unreachable';
642
+ let kind = e instanceof WireError ? e.kind : 'unreachable';
643
+ // a validated refusal of the routed form: the relationship switches
644
+ // below, the submission stays in custody as a retriable refusal
645
+ if (kind === 'routed-refused') {
646
+ routedRefused = sub.sentMode === 'routed';
647
+ kind = 'refused-retriable';
648
+ }
511
649
  sub.lastKind = kind;
512
650
  next = kind === 'oversize'
513
651
  ? { state: 'failed', reason: 'oversize' }
@@ -518,6 +656,24 @@ export class MediatorCarrierAdapter {
518
656
  }
519
657
  if (this.#closed)
520
658
  return;
659
+ // evidence against routed (RLTP-VTI-3130): the switch, at once
660
+ if (routedRefused)
661
+ this.#switchToEgress();
662
+ // withdrawn meanwhile (app contract rule 5): the late outcome changes nothing
663
+ if (this.#subs.get(sub.id) !== sub)
664
+ return;
665
+ // a document with a digest accepted in routed is an open routed deposit
666
+ // (RLTP-VTI-3130) — only while the relationship is still routed (an
667
+ // attempt begun before a switch opens nothing after it). Acknowledgements
668
+ // and a submit() without a digest open nothing. Answered already (the
669
+ // verdict outran the deposit's resolution): not opened. acceptedAt is the
670
+ // adapter clock when the deposit resolved, not when it was handed over:
671
+ // later by the deposit's own run time
672
+ if (next.state === 'accepted' && sub.sentMode === 'routed' && this.#mode === 'routed' &&
673
+ sub.digest !== undefined && this.#isDocument(sub) && sub.answered !== true &&
674
+ !this.#openDeposits.has(sub.digest)) {
675
+ this.#openDeposits.set(sub.digest, this.#clock);
676
+ }
521
677
  // An interim report (including a horizon-corrected one) never
522
678
  // outranks the attempt's real outcome; only a TERMINAL state —
523
679
  // a give-up, a refusal, a delivery — stands (R5-B1). Without
@@ -526,6 +682,45 @@ export class MediatorCarrierAdapter {
526
682
  if (sub.st === null || sub.st.state === 'awaiting-transport')
527
683
  sub.st = next;
528
684
  }
685
+ #isDocument(sub) {
686
+ return sub.entry === undefined || sub.entry.kind === 'document';
687
+ }
688
+ /** A verdict on a document answers its open routed deposit
689
+ * (RLTP-VTI-3130), if the send state names it at that moment. A verdict
690
+ * before the acceptance marks the submission, so it is not opened
691
+ * afterwards. Acknowledgements are answered by nothing. */
692
+ #answer(sub) {
693
+ if (!this.#isDocument(sub))
694
+ return;
695
+ sub.answered = true;
696
+ if (sub.digest !== undefined)
697
+ this.#openDeposits.delete(sub.digest);
698
+ }
699
+ /** The switch (RLTP-VTI-3130): one way; the open routed deposits leave
700
+ * the send state. */
701
+ #switchToEgress() {
702
+ this.#mode = 'egress-session';
703
+ this.#openDeposits.clear();
704
+ }
705
+ /** The current sending mode of the relationship (profile §3a). */
706
+ get sendMode() { return this.#mode; }
707
+ /** An answer the holder hands this instance for a document an earlier
708
+ * instance deposited (RLTP-VTI-3130): a valid acknowledgement, a
709
+ * validated receiver refusal or a validated `idConflict` answer for
710
+ * `digest`. If the send state names the document as an open routed
711
+ * deposit, it is answered and leaves; otherwise it answers nothing. A
712
+ * no-op on a retired instance, like acknowledged. */
713
+ answered(digest, rawNow) {
714
+ if (this.#closed)
715
+ return;
716
+ this.#now(rawNow);
717
+ this.#openDeposits.delete(digest);
718
+ // a document of this instance whose attempt still runs is answered too:
719
+ // its acceptance after this opens nothing
720
+ for (const sub of this.#subs.values())
721
+ if (sub.digest === digest && this.#isDocument(sub))
722
+ sub.answered = true;
723
+ }
529
724
  /** Host teardown aid, NOT a port operation (profile §6): awaits
530
725
  * the in-flight background tasks so a host can drain before
531
726
  * persisting ackState. On a hanging wire it waits as long as the
@@ -563,12 +758,18 @@ export class MediatorCarrierAdapter {
563
758
  }
564
759
  return sub.st === null ? null : { ...sub.st };
565
760
  }
761
+ /** A valid acknowledgement for submission `id`. Reports that a
762
+ * collection carries are applied before that collection's conclude,
763
+ * at the latest before the next advance: the switch check of
764
+ * RLTP-VTI-3130 counts on them, and a report applied late can let the
765
+ * relationship switch although its deposits were answered. */
566
766
  acknowledged(id) {
567
767
  if (this.#closed)
568
768
  return;
569
769
  const sub = this.#subs.get(id);
570
770
  if (!sub)
571
771
  return;
772
+ this.#answer(sub);
572
773
  if (sub.st?.state === 'delivered')
573
774
  return; // idempotent: a duplicate ack never rewrites `late` (R9-M4)
574
775
  const late = sub.st?.state === 'failed';
@@ -581,7 +782,9 @@ export class MediatorCarrierAdapter {
581
782
  * TERMINAL: retryable counterpart conditions (unavailable,
582
783
  * internalError) are transport outcomes, never receiver verdicts
583
784
  * — they have no entry here, and an unknown code is refused
584
- * fail-closed rather than minted into a verdict (R6-B2). */
785
+ * fail-closed rather than minted into a verdict (R6-B2). Applied, like
786
+ * acknowledged, before the conclude of the collection that carried
787
+ * it (RLTP-VTI-3130). */
585
788
  receiverRefused(id, code) {
586
789
  if (this.#closed)
587
790
  return;
@@ -591,6 +794,7 @@ export class MediatorCarrierAdapter {
591
794
  const sub = this.#subs.get(id);
592
795
  if (!sub)
593
796
  return;
797
+ this.#answer(sub);
594
798
  // A VALIDATED refusal is itself the proof of receipt — the
595
799
  // mediator can have accepted and forwarded while our deposit
596
800
  // promise is still unresolved, so the report-less phase is a
@@ -625,13 +829,21 @@ export class MediatorCarrierAdapter {
625
829
  * is transient (Delivery RLTP-DEL-5810, 5820: `failed(rate-limited)`,
626
830
  * or a transaction that could not commit, see `concludable`): they are
627
831
  * not concluded and not acknowledged, they go back to the buffer and the
628
- * next collect offers them again, and the mediator keeps its copy. */
832
+ * next collect offers them again, and the mediator keeps its copy.
833
+ * The reports the collection carries (acknowledged, receiverRefused,
834
+ * answered) are applied BEFORE this call, at the latest before the next
835
+ * advance: a conclusion that leaves nothing pending opens the switch
836
+ * check of RLTP-VTI-3130, which counts them as applied. */
629
837
  conclude(collection, rawNow, opts = {}) {
630
838
  this.#open();
631
839
  const now = this.#now(rawNow);
632
840
  const col = this.#outstanding.get(collection.id);
633
- if (!col)
841
+ if (!col) {
842
+ // an empty collection is concluded too: it may be the one that leaves nothing pending
843
+ if (collection.id === 'c-empty')
844
+ this.#concludedClean();
634
845
  return;
846
+ }
635
847
  this.#outstanding.delete(collection.id);
636
848
  const keep = new Set(opts.keep ?? []);
637
849
  for (const item of col.values()) {
@@ -646,12 +858,30 @@ export class MediatorCarrierAdapter {
646
858
  this.#concluded.set(item.queueId, now);
647
859
  this.#pendingAcks.add(item.queueId);
648
860
  }
861
+ this.#concludedClean();
649
862
  this.#startFlush(now);
650
863
  }
864
+ /** Nothing pending for this connection: the wire says the mediator
865
+ * holds nothing more (queueEmpty; a wire without it never says so),
866
+ * nothing waits in the buffer, no collection is outstanding. */
867
+ #nothingPending() {
868
+ return this.#wire.queueEmpty?.() === true && this.#inbox.length === 0 && this.#outstanding.size === 0;
869
+ }
870
+ /** After a conclusion (RLTP-VTI-3130): when it left nothing pending,
871
+ * the switch check may run in a later advance. */
872
+ #concludedClean() {
873
+ if (!this.#nothingPending())
874
+ return;
875
+ this.#cleanConcluded = true;
876
+ }
651
877
  ackState() {
652
878
  return {
653
879
  concluded: [...this.#concluded].map(([queueId, concludedAt]) => ({ queueId, concludedAt })),
654
880
  pending: [...this.#pendingAcks],
881
+ send: {
882
+ mode: this.#mode,
883
+ open: [...this.#openDeposits].map(([digest, acceptedAt]) => ({ digest, acceptedAt })),
884
+ },
655
885
  };
656
886
  }
657
887
  #startFlush(now) {
@@ -687,6 +917,11 @@ export class MediatorCarrierAdapter {
687
917
  // even if no prune has swept it yet — is never sent.
688
918
  const live = ids.filter((id) => {
689
919
  const at = this.#concluded.get(id);
920
+ // a later copy of it that the holder has not concluded yet (in the
921
+ // buffer or outstanding) is not acknowledged before it is handled:
922
+ // hand off → await → ack; the intent stays for the next flush
923
+ if (this.#inboxDigests.has(id) || [...this.#outstanding.values()].some((c) => c.has(id)))
924
+ return false;
690
925
  return at !== undefined && this.#clock - at < DECLARED.duplicateWindowMs;
691
926
  });
692
927
  if (live.length === 0)
@@ -727,6 +962,25 @@ export class MediatorCarrierAdapter {
727
962
  advance(rawNow) {
728
963
  this.#open();
729
964
  const now = this.#now(rawNow);
965
+ // open routed deposits fall routed-ack-deadline plus duplicate-window
966
+ // after their acceptance, also where no switch check can run (no
967
+ // statement that nothing is pending, RLTP-VTI-7150)
968
+ for (const [digest, at] of this.#openDeposits)
969
+ if (now - at >= OPEN_KEEP_MS)
970
+ this.#openDeposits.delete(digest);
971
+ // the switch (RLTP-VTI-3130): only after a conclusion that left nothing
972
+ // pending and while nothing is pending again at the check (an
973
+ // acknowledgement that arrived since is collected and reported first),
974
+ // when an open routed deposit is a deadline old — one way; checked
975
+ // before the retries below, which hand over in the new mode
976
+ if (this.#mode === 'routed' && this.#cleanConcluded && this.#nothingPending()) {
977
+ for (const acceptedAt of this.#openDeposits.values()) {
978
+ if (now - acceptedAt >= DECLARED.routedAckDeadlineMs) {
979
+ this.#switchToEgress();
980
+ break;
981
+ }
982
+ }
983
+ }
730
984
  for (const sub of this.#subs.values()) {
731
985
  const live = sub.st === null || sub.st.state === 'awaiting-transport' || sub.st.state === 'accepted';
732
986
  if (!live)