@real-life/trust-protocol 0.6.1 → 0.8.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,9 +20,54 @@
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.25 (twenty-fifth casting;
24
24
  // round journals under design/adapter-review*). A
25
- // transport adapter contract — NOT a carrier at the port.
25
+ // transport adapter contract — NOT a carrier at the port. The
26
+ // counterpart's connection DID and egress identity the wire needs come
27
+ // from the counterpart's delivery hint in the card that opened the
28
+ // relationship (profile §1b); the holder hands the hints on uninterpreted
29
+ // (RelationshipView.counterpartDeliveryHints) and this adapter derives its
30
+ // own trio from the relationship's carrier nonce (RelationshipView.carrierNonce).
31
+ //
32
+ // Sending form (profile §3a, lib 0.7.0): every deposit names its mode to
33
+ // the wire. Mode `routed` (the mode of every instance from construction):
34
+ // an inner TSP `direct` message egress identity → counterpart's
35
+ // connection DID inside an outer routed message connection DID → C with
36
+ // the route [counterpart's connection DID], on the connection DID's
37
+ // session. Mode `egress-session` (fallback): the inner message alone, on
38
+ // a send-only session of the egress identity. The wire packs the layers
39
+ // (depositAddressing says who addresses whom); the adapter keeps the
40
+ // relationship's send state (the mode and the open routed deposits) in
41
+ // its ack state (RLTP-VTI-3130). Open are: every document with a digest
42
+ // (an entry of kind `document` filed by reconcile) accepted in routed and
43
+ // not yet answered — a valid acknowledgement, a validated refusal or a
44
+ // validated idConflict for its digest answers it. Acknowledgements are
45
+ // no open deposits: a side that only acknowledges never switches by the
46
+ // deadline (Open Issue 9). The adapter switches one way to
47
+ // `egress-session` when an open deposit is routed-ack-deadline (72 h)
48
+ // old, checked in an advance after a conclusion that left nothing
49
+ // pending and while nothing is pending again — "nothing pending" is the
50
+ // binding's statement through queueEmpty() (RLTP-VTI-7150); without it
51
+ // the adapter never switches — and at once on a
52
+ // validated refusal of the routed form (WireError 'routed-refused').
53
+ // At the switch the open deposits leave; nothing returns to routed. A
54
+ // submit() without a digest opens nothing. An attempt
55
+ // ends in the mode it began in; an accepted submission is never
56
+ // deposited again (RLTP-VTI-3135, 7130).
57
+ //
58
+ // The app's transport contract (web-of-trust TRANSPORT.md, rules 5 and
59
+ // 6), as far as it holds here: (5) a withdrawal — reconcile without the
60
+ // entry — means the adapter starts no deposit for that submission
61
+ // afterwards, and an outcome that resolves late changes nothing here
62
+ // (has() false, report() null, no send state) — except a validated refusal
63
+ // of the routed form, which switches the relationship (a finding about the
64
+ // mediator, RLTP-VTI-3130). A wire call already
65
+ // running cannot be recalled and may still land at the mediator (the §6
66
+ // boundary); the copy that then arrives is a duplicate the receiver
67
+ // absorbs (Delivery §6.2). (6) Every deposit is a submission with an id
68
+ // of its own, a duplicate's answer (`resend`) included; the
69
+ // DeliveryReport handed to the holder carries the digest, and the app
70
+ // pairs it with its deposit through the submission id it polls.
26
71
  //
27
72
  // Beyond the profile (lib 0.5.0): conclude(collection, now, {keep}) leaves
28
73
  // the items whose disposition is transient unconcluded and offers them
@@ -42,7 +87,7 @@
42
87
  // · A retired instance mutates nothing and refuses everything
43
88
  // except reading its ack state.
44
89
  import { jcs } from '../core.js';
45
- import { sha } from '../crypto.js';
90
+ import { anchorOfEd, edRawOfAnchor, sha } from '../crypto.js';
46
91
  import { carrierPrincipal, carrierScopedIdentity } from '../carrier-identity.js';
47
92
  // ── Declared constants (profile §2) — promises of THIS casting ──────
48
93
  // frozen: the declared promises cannot be changed at run time either
@@ -53,7 +98,112 @@ export const DECLARED = Object.freeze({
53
98
  queueFloorBytes: 1_048_576,
54
99
  maxQueueBytes: 16_777_216,
55
100
  duplicateWindowMs: 30 * 86_400_000,
101
+ routedAckDeadlineMs: 72 * 3_600_000,
56
102
  });
103
+ const HINT_MEMBERS = jcs(['id', 'rltpEgress', 'serviceEndpoint', 'type']);
104
+ /** An Ed25519 did:key in the form RLTP-VTI-1210 fixes: `did:key:z` and the
105
+ * base58btc of multicodec 0xed01 followed by the 32-byte key, rendered
106
+ * canonically (decoding and re-encoding gives the same string). */
107
+ const edDidKey = (x) => {
108
+ // the canonical form has exactly 56 characters; checked first, so no long input reaches the base58 decoder
109
+ if (typeof x !== 'string' || x.length !== 56)
110
+ return false;
111
+ const raw = edRawOfAnchor(x);
112
+ return raw !== null && anchorOfEd(raw) === x;
113
+ };
114
+ /** This side's hint for carrier `C` in the relationship of carrier nonce
115
+ * `N` (RLTP-VTI-1210, 1215): the connection DID and the egress identity
116
+ * derived here from (root, `C`, `N`), exactly as the factory derives the
117
+ * instance's trio — derived, not supplied, so the two cannot come from
118
+ * different relationships — as the JCS form of the TSPTransport service
119
+ * entry naming the connection DID (`id`, with `#tsp`), `C`
120
+ * (`serviceEndpoint`) and the egress identity (`rltpEgress`). */
121
+ export async function deliveryHint(rootIkm, carrier, nonce) {
122
+ // snapshot at entry, as create does (R8-B2): a caller's buffer changed
123
+ // between the two derivations must not couple two relationships
124
+ const ikm = new Uint8Array(rootIkm);
125
+ const n = new Uint8Array(nonce);
126
+ const connection = await carrierScopedIdentity(ikm, carrier, n, 'connection');
127
+ const egress = await carrierScopedIdentity(ikm, carrier, n, 'egress');
128
+ return jcs({ id: connection.did + '#tsp', type: 'TSPTransport', serviceEndpoint: carrier, rltpEgress: egress.did });
129
+ }
130
+ /** One hint read strictly (RLTP-VTI-1210, 1220) for carrier `C`: JSON,
131
+ * byte-equal to its JCS form, exactly the four members, type
132
+ * TSPTransport, an `id` ending in `#tsp`, both identities distinct
133
+ * canonical Ed25519 did:key, `serviceEndpoint` byte-equal to `C`.
134
+ * Total (RLTP-VTI-1220): any other input — not a string, not JSON, no JCS
135
+ * form (a lone surrogate), any other shape — is null, ignored; it never
136
+ * throws. */
137
+ export function readDeliveryHint(hint, carrier) {
138
+ try {
139
+ return readStrict(hint, carrier);
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ function readStrict(hint, carrier) {
146
+ if (typeof hint !== 'string' || typeof carrier !== 'string' || carrier.length === 0)
147
+ return null;
148
+ const o = JSON.parse(hint);
149
+ if (o === null || typeof o !== 'object' || Array.isArray(o) || jcs(o) !== hint)
150
+ return null;
151
+ const e = o;
152
+ if (jcs(Object.keys(e).sort()) !== HINT_MEMBERS || e.type !== 'TSPTransport' || e.serviceEndpoint !== carrier)
153
+ return null;
154
+ if (typeof e.id !== 'string' || !e.id.endsWith('#tsp'))
155
+ return null;
156
+ const connectionDid = e.id.slice(0, -'#tsp'.length);
157
+ if (!edDidKey(connectionDid) || !edDidKey(e.rltpEgress) || connectionDid === e.rltpEgress)
158
+ return null;
159
+ return { connectionDid, egressDid: e.rltpEgress, carrier };
160
+ }
161
+ /** The counterpart's wire identities at `C` from the hints of the card
162
+ * that opened the relationship, read by the side `self` (its own
163
+ * connection DID and egress identity of this relationship and carrier):
164
+ * every hint read as readDeliveryHint does; a hint naming one of `self`'s
165
+ * identities is ignored (RLTP-VTI-1227); of what remains, the one hint
166
+ * for `C` (RLTP-VTI-1225, 1240) — none, or more than one, is null: the
167
+ * relationship is not carried at `C` (RLTP-VTI-1235). Total: any input
168
+ * answers null or the identities, never a throw; without a well-formed
169
+ * `self` nothing is read. */
170
+ export function readDeliveryHints(hints, carrier, self) {
171
+ try {
172
+ if (!Array.isArray(hints))
173
+ return null;
174
+ const me = self;
175
+ if (me === null || typeof me !== 'object' || !edDidKey(me.connection) || !edDidKey(me.egress))
176
+ return null;
177
+ const own = new Set([me.connection, me.egress]);
178
+ const read = hints.map((h) => readDeliveryHint(h, carrier))
179
+ .filter((x) => x !== null && !own.has(x.connectionDid) && !own.has(x.egressDid));
180
+ return read.length === 1 ? read[0] : null;
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ }
186
+ const SEND_MODES = new Set(['routed', 'egress-session']);
187
+ /** The addressing of a deposit in `mode` (RLTP-VTI-1110, 1120, 3110,
188
+ * 3115, 3120): the inner `direct` message from this side's egress
189
+ * identity to the counterpart's connection DID; in mode `routed` an
190
+ * outer routed message from this side's connection DID to the carrier
191
+ * `C` with the route [counterpart's connection DID], deposited on the
192
+ * connection DID's session; in mode `egress-session` no outer layer,
193
+ * deposited on the egress identity's send-only session. */
194
+ export function depositAddressing(mode, self, peerConnection, carrier) {
195
+ if (!SEND_MODES.has(mode))
196
+ throw new Error(`unknown send mode ${String(mode)}`);
197
+ const inner = { messageType: 'direct', sender: self.egress, receiver: peerConnection };
198
+ if (mode === 'routed') {
199
+ return {
200
+ inner,
201
+ outer: { messageType: 'routed', sender: self.connection, receiver: carrier, route: [peerConnection] },
202
+ session: self.connection,
203
+ };
204
+ }
205
+ return { inner, outer: null, session: self.egress };
206
+ }
57
207
  export class WireError extends Error {
58
208
  kind;
59
209
  constructor(kind, message) {
@@ -89,6 +239,45 @@ export function concludable(r) {
89
239
  }
90
240
  /** The adapter's own copy of a wire's token set: the wire keeps no handle on it. */
91
241
  const tokenCopy = (t) => ({ accessToken: t.accessToken, accessExpiresAt: t.accessExpiresAt, refreshToken: t.refreshToken, refreshExpiresAt: t.refreshExpiresAt });
242
+ /** how long an open routed deposit stays in the send state at most
243
+ * (RLTP-VTI-3130): the state is bounded even where no switch check runs */
244
+ const OPEN_KEEP_MS = DECLARED.routedAckDeadlineMs + DECLARED.duplicateWindowMs;
245
+ const finite = (x) => typeof x === 'number' && Number.isFinite(x);
246
+ /** A restored send state, checked fail-closed (§4): exactly `mode` and
247
+ * `open`, a known mode, each entry exactly a digest and a finite
248
+ * acceptance instant not after restoredAt, each digest once, nothing open
249
+ * in egress-session. Any other field — the acknowledgement fields of an
250
+ * earlier form included — is refused, not ignored. */
251
+ function restoreSendState(send, restoredAt) {
252
+ const refuse = (why) => { throw new Error(`ack snapshot: send state ${why}`); };
253
+ if (typeof send !== 'object' || send === null)
254
+ return refuse('is not an object');
255
+ const s = send;
256
+ const exactly = (o, keys) => {
257
+ const own = Object.keys(o);
258
+ return own.length === keys.length && keys.every((k) => own.includes(k));
259
+ };
260
+ if (!exactly(s, ['mode', 'open']))
261
+ refuse('has other fields than mode and open');
262
+ if (typeof s.mode !== 'string' || !SEND_MODES.has(s.mode))
263
+ refuse('names an unknown mode');
264
+ if (!Array.isArray(s.open))
265
+ refuse('open deposits are not a list');
266
+ const open = s.open.map((r) => {
267
+ const e = r;
268
+ if (typeof e !== 'object' || e === null || !exactly(e, ['digest', 'acceptedAt']) ||
269
+ typeof e.digest !== 'string' || !finite(e.acceptedAt) || e.acceptedAt > restoredAt) {
270
+ return refuse('open deposit is malformed, non-finite or accepted after restoredAt');
271
+ }
272
+ return { digest: e.digest, acceptedAt: e.acceptedAt };
273
+ });
274
+ if (new Set(open.map((o) => o.digest)).size !== open.length)
275
+ refuse('names an open deposit twice');
276
+ // the switch empties it: an egress-session with open deposits was never produced
277
+ if (s.mode === 'egress-session' && open.length > 0)
278
+ refuse('is egress-session with open routed deposits');
279
+ return { mode: s.mode, open };
280
+ }
92
281
  const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
93
282
  // construction capability: only create() holds it
94
283
  const KEY = Symbol('MediatorCarrierAdapter.create');
@@ -126,6 +315,12 @@ export class MediatorCarrierAdapter {
126
315
  #flushing = false;
127
316
  #nextColId = 1;
128
317
  #transitions = [];
318
+ // the relationship's send state (RLTP-VTI-3130)
319
+ #mode = 'routed';
320
+ /** open routed deposits (documents), keyed by digest */
321
+ #openDeposits = new Map();
322
+ /** this instance has concluded a collection that left nothing pending */
323
+ #cleanConcluded = false;
129
324
  /** The only way to build an adapter: the trio is DERIVED here from
130
325
  * one root, one carrier, one nonce — so principal, connection DID
131
326
  * and egress identity cannot come from different relationships
@@ -189,47 +384,66 @@ export class MediatorCarrierAdapter {
189
384
  if (pending.has(queueId))
190
385
  this.#pendingAcks.add(queueId);
191
386
  }
387
+ // absent: a host that persisted none starts again in routed
388
+ if (restored.send !== undefined) {
389
+ const send = restoreSendState(restored.send, restoredAt);
390
+ this.#mode = send.mode;
391
+ // an open deposit falls routed-ack-deadline plus duplicate-window
392
+ // after its acceptance: cut here, not refused
393
+ for (const o of send.open)
394
+ if (restoredAt - o.acceptedAt < OPEN_KEEP_MS)
395
+ this.#openDeposits.set(o.digest, o.acceptedAt);
396
+ }
192
397
  }
193
398
  this.#wire.onDeliver(async (raw) => {
194
399
  if (this.#closed)
195
- return;
400
+ return false;
196
401
  // a plain copy of its own: a Node Buffer's slice() would share memory
197
402
  const bytes = new Uint8Array(raw);
198
403
  const queueId = hex(await sha(bytes));
199
404
  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.
405
+ return false;
406
+ // Absorption is checked at the moment it happens (R11-B1): the
407
+ // adapter absorbs a copy only for give-up-horizon after its
408
+ // conclusion (profile §2, §4); a later copy is a new item for the
409
+ // holder, whose absorption answers it again with the stored
410
+ // acknowledgement (RLTP-DEL-6330, 6335) — so a counterpart that
411
+ // resubmits after a lost acknowledgement is answered. The entry and
412
+ // its ack intent stand for the duplicate window, and the window
413
+ // beats everything (round-14 B): beyond it, both fall.
414
+ // buffered and outstanding go before concluded (§3): a redelivery of
415
+ // a later copy the holder has not concluded yet is ignored
416
+ if (this.#inboxDigests.has(queueId))
417
+ return true;
418
+ for (const col of this.#outstanding.values())
419
+ if (col.has(queueId))
420
+ return true;
206
421
  const concludedAt = this.#concluded.get(queueId);
207
422
  if (concludedAt !== undefined) {
208
423
  if (this.#flightAcks.has(queueId) ||
209
- this.#clock - concludedAt < DECLARED.duplicateWindowMs) {
210
- // inside the window — or deferred: an id inside an
424
+ this.#clock - concludedAt < DECLARED.giveUpHorizonMs) {
425
+ // inside the horizon — or deferred: an id inside an
211
426
  // in-flight flush keeps absorbing until that flush ends,
212
427
  // because its irrecallable ack may still land (R15-B1)
213
428
  this.#pendingAcks.add(queueId);
214
- return;
429
+ return true;
430
+ }
431
+ if (this.#clock - concludedAt >= DECLARED.duplicateWindowMs) {
432
+ this.#concluded.delete(queueId);
433
+ this.#pendingAcks.delete(queueId);
215
434
  }
216
- this.#concluded.delete(queueId);
217
- this.#pendingAcks.delete(queueId);
218
435
  }
219
- if (this.#inboxDigests.has(queueId))
220
- return;
221
- for (const col of this.#outstanding.values())
222
- if (col.has(queueId))
223
- return;
224
436
  const held = this.#inboxBytes + this.#outstandingBytes;
225
437
  const underFloor = this.#inbox.length < DECLARED.queueFloorCount &&
226
438
  held + bytes.length <= DECLARED.queueFloorBytes;
227
439
  const underCap = held + bytes.length <= DECLARED.maxQueueBytes;
440
+ // not acknowledged and not taken: the mediator keeps its copy
228
441
  if (!underFloor && !underCap)
229
- return;
442
+ return false;
230
443
  this.#inbox.push({ bytes, queueId });
231
444
  this.#inboxBytes += bytes.length;
232
445
  this.#inboxDigests.add(queueId);
446
+ return true;
233
447
  });
234
448
  }
235
449
  #now(raw) {
@@ -343,6 +557,7 @@ export class MediatorCarrierAdapter {
343
557
  attemptStartedAt: null,
344
558
  lastKind: null,
345
559
  st: null,
560
+ sentMode: null,
346
561
  };
347
562
  this.#subs.set(id, sub);
348
563
  if (!knownOffline)
@@ -408,7 +623,7 @@ export class MediatorCarrierAdapter {
408
623
  bytes: new TextEncoder().encode(jcs(entry.envelope)),
409
624
  // a duplicate's ack is a hand-over of its own, from now
410
625
  submittedAt: entry.resend !== undefined ? now : Math.min(entry.since, now),
411
- attemptStartedAt: null, lastKind: null, st: null,
626
+ attemptStartedAt: null, lastKind: null, st: null, sentMode: null,
412
627
  };
413
628
  this.#subs.set(id, sub);
414
629
  refs.push({ submissionId: id, digest: entry.digest });
@@ -449,14 +664,19 @@ export class MediatorCarrierAdapter {
449
664
  if (sub.st !== null && sub.st.state !== 'awaiting-transport')
450
665
  return;
451
666
  let next;
667
+ let routedRefused = false;
452
668
  try {
453
669
  const token = await this.#ensureAuth(now);
454
670
  if (this.#closed)
455
671
  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
672
+ // each attempt hands the wire a copy: what it does with it never reaches the next.
673
+ // The mode is read at the hand-over itself: from a switch on, every
674
+ // hand-over uses the new mode, and a call already made ends in its own
675
+ // (RLTP-VTI-3135)
457
676
  const deposit = () => {
677
+ sub.sentMode = this.#mode;
458
678
  try {
459
- return this.#wire.deposit(token, new Uint8Array(sub.bytes), this.#egressDid);
679
+ return this.#wire.deposit(token, new Uint8Array(sub.bytes), this.#egressDid, sub.sentMode);
460
680
  }
461
681
  catch (e) {
462
682
  return Promise.reject(e);
@@ -507,7 +727,13 @@ export class MediatorCarrierAdapter {
507
727
  next = { state: 'accepted' };
508
728
  }
509
729
  catch (e) {
510
- const kind = e instanceof WireError ? e.kind : 'unreachable';
730
+ let kind = e instanceof WireError ? e.kind : 'unreachable';
731
+ // a validated refusal of the routed form: the relationship switches
732
+ // below, the submission stays in custody as a retriable refusal
733
+ if (kind === 'routed-refused') {
734
+ routedRefused = sub.sentMode === 'routed';
735
+ kind = 'refused-retriable';
736
+ }
511
737
  sub.lastKind = kind;
512
738
  next = kind === 'oversize'
513
739
  ? { state: 'failed', reason: 'oversize' }
@@ -518,6 +744,24 @@ export class MediatorCarrierAdapter {
518
744
  }
519
745
  if (this.#closed)
520
746
  return;
747
+ // evidence against routed (RLTP-VTI-3130): the switch, at once
748
+ if (routedRefused)
749
+ this.#switchToEgress();
750
+ // withdrawn meanwhile (app contract rule 5): the late outcome changes nothing
751
+ if (this.#subs.get(sub.id) !== sub)
752
+ return;
753
+ // a document with a digest accepted in routed is an open routed deposit
754
+ // (RLTP-VTI-3130) — only while the relationship is still routed (an
755
+ // attempt begun before a switch opens nothing after it). Acknowledgements
756
+ // and a submit() without a digest open nothing. Answered already (the
757
+ // verdict outran the deposit's resolution): not opened. acceptedAt is the
758
+ // adapter clock when the deposit resolved, not when it was handed over:
759
+ // later by the deposit's own run time
760
+ if (next.state === 'accepted' && sub.sentMode === 'routed' && this.#mode === 'routed' &&
761
+ sub.digest !== undefined && this.#isDocument(sub) && sub.answered !== true &&
762
+ !this.#openDeposits.has(sub.digest)) {
763
+ this.#openDeposits.set(sub.digest, this.#clock);
764
+ }
521
765
  // An interim report (including a horizon-corrected one) never
522
766
  // outranks the attempt's real outcome; only a TERMINAL state —
523
767
  // a give-up, a refusal, a delivery — stands (R5-B1). Without
@@ -526,6 +770,45 @@ export class MediatorCarrierAdapter {
526
770
  if (sub.st === null || sub.st.state === 'awaiting-transport')
527
771
  sub.st = next;
528
772
  }
773
+ #isDocument(sub) {
774
+ return sub.entry === undefined || sub.entry.kind === 'document';
775
+ }
776
+ /** A verdict on a document answers its open routed deposit
777
+ * (RLTP-VTI-3130), if the send state names it at that moment. A verdict
778
+ * before the acceptance marks the submission, so it is not opened
779
+ * afterwards. Acknowledgements are answered by nothing. */
780
+ #answer(sub) {
781
+ if (!this.#isDocument(sub))
782
+ return;
783
+ sub.answered = true;
784
+ if (sub.digest !== undefined)
785
+ this.#openDeposits.delete(sub.digest);
786
+ }
787
+ /** The switch (RLTP-VTI-3130): one way; the open routed deposits leave
788
+ * the send state. */
789
+ #switchToEgress() {
790
+ this.#mode = 'egress-session';
791
+ this.#openDeposits.clear();
792
+ }
793
+ /** The current sending mode of the relationship (profile §3a). */
794
+ get sendMode() { return this.#mode; }
795
+ /** An answer the holder hands this instance for a document an earlier
796
+ * instance deposited (RLTP-VTI-3130): a valid acknowledgement, a
797
+ * validated receiver refusal or a validated `idConflict` answer for
798
+ * `digest`. If the send state names the document as an open routed
799
+ * deposit, it is answered and leaves; otherwise it answers nothing. A
800
+ * no-op on a retired instance, like acknowledged. */
801
+ answered(digest, rawNow) {
802
+ if (this.#closed)
803
+ return;
804
+ this.#now(rawNow);
805
+ this.#openDeposits.delete(digest);
806
+ // a document of this instance whose attempt still runs is answered too:
807
+ // its acceptance after this opens nothing
808
+ for (const sub of this.#subs.values())
809
+ if (sub.digest === digest && this.#isDocument(sub))
810
+ sub.answered = true;
811
+ }
529
812
  /** Host teardown aid, NOT a port operation (profile §6): awaits
530
813
  * the in-flight background tasks so a host can drain before
531
814
  * persisting ackState. On a hanging wire it waits as long as the
@@ -563,12 +846,18 @@ export class MediatorCarrierAdapter {
563
846
  }
564
847
  return sub.st === null ? null : { ...sub.st };
565
848
  }
849
+ /** A valid acknowledgement for submission `id`. Reports that a
850
+ * collection carries are applied before that collection's conclude,
851
+ * at the latest before the next advance: the switch check of
852
+ * RLTP-VTI-3130 counts on them, and a report applied late can let the
853
+ * relationship switch although its deposits were answered. */
566
854
  acknowledged(id) {
567
855
  if (this.#closed)
568
856
  return;
569
857
  const sub = this.#subs.get(id);
570
858
  if (!sub)
571
859
  return;
860
+ this.#answer(sub);
572
861
  if (sub.st?.state === 'delivered')
573
862
  return; // idempotent: a duplicate ack never rewrites `late` (R9-M4)
574
863
  const late = sub.st?.state === 'failed';
@@ -581,7 +870,9 @@ export class MediatorCarrierAdapter {
581
870
  * TERMINAL: retryable counterpart conditions (unavailable,
582
871
  * internalError) are transport outcomes, never receiver verdicts
583
872
  * — they have no entry here, and an unknown code is refused
584
- * fail-closed rather than minted into a verdict (R6-B2). */
873
+ * fail-closed rather than minted into a verdict (R6-B2). Applied, like
874
+ * acknowledged, before the conclude of the collection that carried
875
+ * it (RLTP-VTI-3130). */
585
876
  receiverRefused(id, code) {
586
877
  if (this.#closed)
587
878
  return;
@@ -591,6 +882,7 @@ export class MediatorCarrierAdapter {
591
882
  const sub = this.#subs.get(id);
592
883
  if (!sub)
593
884
  return;
885
+ this.#answer(sub);
594
886
  // A VALIDATED refusal is itself the proof of receipt — the
595
887
  // mediator can have accepted and forwarded while our deposit
596
888
  // promise is still unresolved, so the report-less phase is a
@@ -625,13 +917,21 @@ export class MediatorCarrierAdapter {
625
917
  * is transient (Delivery RLTP-DEL-5810, 5820: `failed(rate-limited)`,
626
918
  * or a transaction that could not commit, see `concludable`): they are
627
919
  * 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. */
920
+ * next collect offers them again, and the mediator keeps its copy.
921
+ * The reports the collection carries (acknowledged, receiverRefused,
922
+ * answered) are applied BEFORE this call, at the latest before the next
923
+ * advance: a conclusion that leaves nothing pending opens the switch
924
+ * check of RLTP-VTI-3130, which counts them as applied. */
629
925
  conclude(collection, rawNow, opts = {}) {
630
926
  this.#open();
631
927
  const now = this.#now(rawNow);
632
928
  const col = this.#outstanding.get(collection.id);
633
- if (!col)
929
+ if (!col) {
930
+ // an empty collection is concluded too: it may be the one that leaves nothing pending
931
+ if (collection.id === 'c-empty')
932
+ this.#concludedClean();
634
933
  return;
934
+ }
635
935
  this.#outstanding.delete(collection.id);
636
936
  const keep = new Set(opts.keep ?? []);
637
937
  for (const item of col.values()) {
@@ -646,12 +946,30 @@ export class MediatorCarrierAdapter {
646
946
  this.#concluded.set(item.queueId, now);
647
947
  this.#pendingAcks.add(item.queueId);
648
948
  }
949
+ this.#concludedClean();
649
950
  this.#startFlush(now);
650
951
  }
952
+ /** Nothing pending for this connection: the wire says the mediator
953
+ * holds nothing more (queueEmpty; a wire without it never says so),
954
+ * nothing waits in the buffer, no collection is outstanding. */
955
+ #nothingPending() {
956
+ return this.#wire.queueEmpty?.() === true && this.#inbox.length === 0 && this.#outstanding.size === 0;
957
+ }
958
+ /** After a conclusion (RLTP-VTI-3130): when it left nothing pending,
959
+ * the switch check may run in a later advance. */
960
+ #concludedClean() {
961
+ if (!this.#nothingPending())
962
+ return;
963
+ this.#cleanConcluded = true;
964
+ }
651
965
  ackState() {
652
966
  return {
653
967
  concluded: [...this.#concluded].map(([queueId, concludedAt]) => ({ queueId, concludedAt })),
654
968
  pending: [...this.#pendingAcks],
969
+ send: {
970
+ mode: this.#mode,
971
+ open: [...this.#openDeposits].map(([digest, acceptedAt]) => ({ digest, acceptedAt })),
972
+ },
655
973
  };
656
974
  }
657
975
  #startFlush(now) {
@@ -687,6 +1005,11 @@ export class MediatorCarrierAdapter {
687
1005
  // even if no prune has swept it yet — is never sent.
688
1006
  const live = ids.filter((id) => {
689
1007
  const at = this.#concluded.get(id);
1008
+ // a later copy of it that the holder has not concluded yet (in the
1009
+ // buffer or outstanding) is not acknowledged before it is handled:
1010
+ // hand off → await → ack; the intent stays for the next flush
1011
+ if (this.#inboxDigests.has(id) || [...this.#outstanding.values()].some((c) => c.has(id)))
1012
+ return false;
690
1013
  return at !== undefined && this.#clock - at < DECLARED.duplicateWindowMs;
691
1014
  });
692
1015
  if (live.length === 0)
@@ -727,6 +1050,25 @@ export class MediatorCarrierAdapter {
727
1050
  advance(rawNow) {
728
1051
  this.#open();
729
1052
  const now = this.#now(rawNow);
1053
+ // open routed deposits fall routed-ack-deadline plus duplicate-window
1054
+ // after their acceptance, also where no switch check can run (no
1055
+ // statement that nothing is pending, RLTP-VTI-7150)
1056
+ for (const [digest, at] of this.#openDeposits)
1057
+ if (now - at >= OPEN_KEEP_MS)
1058
+ this.#openDeposits.delete(digest);
1059
+ // the switch (RLTP-VTI-3130): only after a conclusion that left nothing
1060
+ // pending and while nothing is pending again at the check (an
1061
+ // acknowledgement that arrived since is collected and reported first),
1062
+ // when an open routed deposit is a deadline old — one way; checked
1063
+ // before the retries below, which hand over in the new mode
1064
+ if (this.#mode === 'routed' && this.#cleanConcluded && this.#nothingPending()) {
1065
+ for (const acceptedAt of this.#openDeposits.values()) {
1066
+ if (now - acceptedAt >= DECLARED.routedAckDeadlineMs) {
1067
+ this.#switchToEgress();
1068
+ break;
1069
+ }
1070
+ }
1071
+ }
730
1072
  for (const sub of this.#subs.values()) {
731
1073
  const live = sub.st === null || sub.st.state === 'awaiting-transport' || sub.st.state === 'accepted';
732
1074
  if (!live)