@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.
- package/CHANGELOG.md +165 -0
- package/README.md +68 -3
- package/dist/adapters/vti-mediator.d.ts +112 -5
- package/dist/adapters/vti-mediator.d.ts.map +1 -1
- package/dist/adapters/vti-mediator.js +370 -28
- package/dist/adapters/vti-mediator.js.map +1 -1
- package/dist/encounter/ceremony.d.ts +10 -0
- package/dist/encounter/ceremony.d.ts.map +1 -1
- package/dist/encounter/ceremony.js +70 -13
- package/dist/encounter/ceremony.js.map +1 -1
- package/dist/encounter.d.ts +4 -0
- package/dist/encounter.d.ts.map +1 -1
- package/dist/encounter.js +3 -1
- package/dist/encounter.js.map +1 -1
- package/dist/holder-api.d.ts +18 -2
- package/dist/holder-api.d.ts.map +1 -1
- package/dist/holder-api.js +16 -4
- package/dist/holder-api.js.map +1 -1
- package/dist/identity-state.d.ts +13 -1
- package/dist/identity-state.d.ts.map +1 -1
- package/dist/identity-state.js +36 -1
- package/dist/identity-state.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 +4 -0
- package/dist/results.d.ts.map +1 -1
- package/dist/results.js.map +1 -1
- package/dist/views.d.ts +14 -0
- package/dist/views.d.ts.map +1 -1
- package/dist/views.js +34 -5
- package/dist/views.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
+
// 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
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
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.
|
|
210
|
-
// inside the
|
|
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
|
-
|
|
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)
|