@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.
- package/CHANGELOG.md +143 -0
- package/README.md +48 -3
- package/dist/adapters/vti-mediator.d.ts +80 -5
- package/dist/adapters/vti-mediator.d.ts.map +1 -1
- package/dist/adapters/vti-mediator.js +280 -26
- package/dist/adapters/vti-mediator.js.map +1 -1
- package/dist/engine.d.ts +4 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +104 -8
- package/dist/engine.js.map +1 -1
- package/dist/holder-api.d.ts +7 -1
- package/dist/holder-api.d.ts.map +1 -1
- package/dist/holder-api.js +15 -0
- package/dist/holder-api.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/results.d.ts +37 -2
- package/dist/results.d.ts.map +1 -1
- package/dist/results.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
+
// 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
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
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.
|
|
210
|
-
// inside the
|
|
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
|
-
|
|
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)
|