@real-life/trust-protocol 0.4.0 → 0.6.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +91 -24
  3. package/dist/adapters/vti-mediator.d.ts +36 -8
  4. package/dist/adapters/vti-mediator.d.ts.map +1 -1
  5. package/dist/adapters/vti-mediator.js +193 -29
  6. package/dist/adapters/vti-mediator.js.map +1 -1
  7. package/dist/codec.d.ts +2 -1
  8. package/dist/codec.d.ts.map +1 -1
  9. package/dist/codec.js +75 -26
  10. package/dist/codec.js.map +1 -1
  11. package/dist/engine.d.ts +77 -4
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +419 -31
  14. package/dist/engine.js.map +1 -1
  15. package/dist/entropy.d.ts +1 -1
  16. package/dist/entropy.d.ts.map +1 -1
  17. package/dist/entropy.js +1 -1
  18. package/dist/entropy.js.map +1 -1
  19. package/dist/events.d.ts +21 -0
  20. package/dist/events.d.ts.map +1 -1
  21. package/dist/events.js.map +1 -1
  22. package/dist/holder-api.d.ts +44 -2
  23. package/dist/holder-api.d.ts.map +1 -1
  24. package/dist/holder-api.js +40 -5
  25. package/dist/holder-api.js.map +1 -1
  26. package/dist/{holder-state-v1.d.ts → holder-state-v3.d.ts} +261 -201
  27. package/dist/holder-state-v3.d.ts.map +1 -0
  28. package/dist/holder-state-v3.js +2 -0
  29. package/dist/holder-state-v3.js.map +1 -0
  30. package/dist/index.d.ts +3 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +10 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/internal.d.ts +2 -2
  35. package/dist/internal.d.ts.map +1 -1
  36. package/dist/internal.js +1 -1
  37. package/dist/internal.js.map +1 -1
  38. package/dist/introduction/holder.d.ts.map +1 -1
  39. package/dist/introduction/holder.js +7 -0
  40. package/dist/introduction/holder.js.map +1 -1
  41. package/dist/invariants.d.ts.map +1 -1
  42. package/dist/invariants.js +70 -0
  43. package/dist/invariants.js.map +1 -1
  44. package/dist/membership/holder.d.ts +3 -5
  45. package/dist/membership/holder.d.ts.map +1 -1
  46. package/dist/membership/holder.js +165 -88
  47. package/dist/membership/holder.js.map +1 -1
  48. package/dist/membership.d.ts +31 -0
  49. package/dist/membership.d.ts.map +1 -1
  50. package/dist/membership.js +25 -2
  51. package/dist/membership.js.map +1 -1
  52. package/dist/message/holder.d.ts +49 -0
  53. package/dist/message/holder.d.ts.map +1 -0
  54. package/dist/message/holder.js +290 -0
  55. package/dist/message/holder.js.map +1 -0
  56. package/dist/message.d.ts +43 -0
  57. package/dist/message.d.ts.map +1 -0
  58. package/dist/message.js +51 -0
  59. package/dist/message.js.map +1 -0
  60. package/dist/receive.d.ts +4 -3
  61. package/dist/receive.d.ts.map +1 -1
  62. package/dist/receive.js +23 -4
  63. package/dist/receive.js.map +1 -1
  64. package/dist/results.d.ts +22 -3
  65. package/dist/results.d.ts.map +1 -1
  66. package/dist/results.js.map +1 -1
  67. package/dist/schemas.d.ts.map +1 -1
  68. package/dist/schemas.js +9 -6
  69. package/dist/schemas.js.map +1 -1
  70. package/dist/state.d.ts +88 -14
  71. package/dist/state.d.ts.map +1 -1
  72. package/dist/state.js +2 -1
  73. package/dist/state.js.map +1 -1
  74. package/dist/trust/continuity.d.ts.map +1 -1
  75. package/dist/trust/continuity.js +7 -1
  76. package/dist/trust/continuity.js.map +1 -1
  77. package/dist/trust/entries.d.ts.map +1 -1
  78. package/dist/trust/entries.js +103 -2
  79. package/dist/trust/entries.js.map +1 -1
  80. package/dist/views.d.ts +34 -2
  81. package/dist/views.d.ts.map +1 -1
  82. package/dist/views.js +19 -1
  83. package/dist/views.js.map +1 -1
  84. package/dist/wire.d.ts +47 -6
  85. package/dist/wire.d.ts.map +1 -1
  86. package/dist/wire.js.map +1 -1
  87. package/package.json +6 -4
  88. package/schemas/{holder-state-v1.schema.json → holder-state-v3.schema.json} +300 -12
  89. package/dist/holder-state-v1.d.ts.map +0 -1
  90. package/dist/holder-state-v1.js +0 -2
  91. package/dist/holder-state-v1.js.map +0 -1
package/dist/engine.js CHANGED
@@ -37,19 +37,26 @@
37
37
  // act whose apply returns the identical state object and nothing else
38
38
  // commits nothing only when lastWhen stays too. A human act always
39
39
  // commits, because its journal entry is the result. Refusals and
40
- // journal replays commit nothing, lastWhen included
40
+ // journal replays commit nothing, lastWhen included — but for a replay
41
+ // that finds one of its documents past its lifetime or the horizon:
42
+ // that give-up runs first as an operation of its own on the one commit
43
+ // path (resume's CommitOp, its timer carried to the replay's answer)
41
44
  // - outbound of an act starts at attempt 1 with nextAttemptAt =
42
45
  // when + ceil(backoff(1)); resume() re-emits only items that are due
43
46
  // and pauses re-emission while the last step is awaiting-transport
44
47
  // (the adapter holds the item) and never re-emits an accepted one. It
45
48
  // gives up every non-terminal item (submitted, held or accepted) at
46
49
  // first submission + horizonMs, never by attempt
47
- // count; nextDueAt is the earliest due re-emission or horizon expiry
48
- // - the holder's give-up is failed(expired-by-adapter-policy); a later
50
+ // count, or earlier at the sender lifetime its type declared
51
+ // (expiresAt, Delivery 7020); nextDueAt is the earliest due
52
+ // re-emission, horizon or lifetime
53
+ // - the holder's give-up is failed(expired-by-adapter-policy), at a
54
+ // lifetime failed(task-expired) (Delivery 6110); a later
49
55
  // delivered report is late by that history, whatever the adapter says
50
56
  // - replay of a human act returns the journaled events and the outbound
51
- // of its documents that are not terminal (delivered, failed), at their
52
- // current attempt. Idempotency is bounded by the journal's retention:
57
+ // of its documents that are not terminal (delivered, failed) and not
58
+ // due for the give-up, at their current attempt; one due for it is
59
+ // given up in a commit of its own first (6115: never handed out again). Idempotency is bounded by the journal's retention:
53
60
  // max(P90D, retry.horizonMs), the key-retention of Delivery §7 that
54
61
  // bounds effects and their acks; after it the same actId is a new act
55
62
  // - KeyProvider, Log and Access rejections inside a preparation become
@@ -68,13 +75,48 @@
68
75
  // stages again, which re-checks the effect cache at stage 4
69
76
  // - an ack is stored sealed (StoredAck: envelope, digest, recipient) in
70
77
  // its effect, so every re-send is byte-identical down to the envelope.
71
- // It stays `sent: false` until any report on its digest. Unsent, it has
72
- // the outbox's retry schedule: attempt 1 at the receive, re-offered by
73
- // resume when due (attempt + 1, backoff), counted into nextDueAt, and at
74
- // its horizon (effect commit + horizonMs) marked sent by the horizon
75
- // (`sentBy: 'horizon'`, no event: acks have no trias). A duplicate's
76
- // re-send carries attempt + 1 and does not move the schedule (it
77
- // commits nothing beyond lastWhen)
78
+ // It stays `sent: false` until a service takes it (report accepted or
79
+ // delivered: `sentBy: 'accepted'`), the adapter reports it failed
80
+ // (`'failed'`), or its horizon passes (effect commit + horizonMs:
81
+ // `'horizon'`, no event: acks have no trias) — Delivery RLTP-DEL-6330.
82
+ // An awaiting-transport report keeps it unsent and records `held`; a
83
+ // held ack is not re-offered by resume (the adapter has it) and stays
84
+ // in pendingTransport. Unsent and not held, it has the outbox's retry
85
+ // schedule: attempt 1 at the receive, re-offered by resume when due
86
+ // (attempt + 1, backoff), counted into nextDueAt. Sent or not, it stays
87
+ // with its effect until the effect expires (RLTP-DEL-6335). A
88
+ // duplicate's re-send carries attempt + 1 and does not move the
89
+ // schedule; it commits the answer it makes due (6330), else nothing
90
+ // beyond lastWhen
91
+ // - custody (Delivery RLTP-DEL-6120 to 6135): pendingTransport(when) is
92
+ // the set the holder keeps for transport — every outbox item neither
93
+ // terminal nor accepted, held ones included, and every unsent ack —
94
+ // each with its deadline (notAfter: the horizon from the first
95
+ // submission, or the type's sender lifetime if earlier) and without
96
+ // those whose deadline has passed at `when`, whether or not a resume
97
+ // has recorded the give-up yet. It answers only after a resume of this
98
+ // engine has completed (a resume that throws withdraws that again), and
99
+ // its entries are issued to this engine: mayHandOver(entry, when)
100
+ // accepts only an entry this engine issued, re-reads the store and says
101
+ // yes only while the engine is open and the entry is still pending
102
+ // there with the same envelope before its deadline. handOver(entry,
103
+ // when, start) is the same check with the deposit's start inside the
104
+ // queue: nothing of this engine runs between the check and the start.
105
+ // A duplicate makes an answer due, stored with the ack (answers,
106
+ // answerDue: kept across a lock): a `resend` entry numbered by it,
107
+ // beside the ack's retries and independent of their state, deadline the
108
+ // effect's retention; the begun hand-over ends it (a commit): it is one
109
+ // hand-over with no retries (Delivery 6330), so a deposit the wire
110
+ // refuses is not tried again — the sender's next redelivery asks anew;
111
+ // a duplicate while one is due is answered by it (the same bytes); a resume that throws, its settling
112
+ // included, withdraws the authority until one completes. The check is
113
+ // this engine's: a second engine over the same store commits through
114
+ // CAS but is not serialized with this one's hand-overs, so one opening
115
+ // per store at a time is the app's to keep (its writer lock). close() ends the
116
+ // engine: every later operation rejects with InfraError('closed'),
117
+ // mayHandOver answers false, and no entry issued before authorizes
118
+ // anything. An operation already running when close() is called
119
+ // completes
78
120
  // - outbound of one commit, in order: outbox re-emissions, released
79
121
  // acks, new documents
80
122
  // - resume runs duties after the outbox, ordered by dueAt (absent: due
@@ -348,6 +390,7 @@ function renameAcks(effects, renames) {
348
390
  }
349
391
  return out ?? effects;
350
392
  }
393
+ const closedError = () => new InfraError('closed', 'the holder was closed');
351
394
  const checkWhen = (when) => {
352
395
  if (!Number.isSafeInteger(when) || when < 0)
353
396
  throw new TypeError(`when must be non-negative integer milliseconds, got ${String(when)}`);
@@ -388,6 +431,7 @@ class HolderEngine {
388
431
  #ownRetries;
389
432
  #keepKeys;
390
433
  #retain;
434
+ #accepted;
391
435
  #deadlines;
392
436
  #builtins;
393
437
  #check;
@@ -396,6 +440,12 @@ class HolderEngine {
396
440
  #chain = Promise.resolve();
397
441
  /** a commit whose outcome is unknown, settled before the next operation */
398
442
  #unsettled = null;
443
+ /** close() was called: nothing more runs, nothing issued authorizes */
444
+ #closed = false;
445
+ /** a resume of this engine completed (and none threw since) */
446
+ #resumed = false;
447
+ /** the transport entries this engine issued (RLTP-DEL-6130) */
448
+ #issued = new WeakSet();
399
449
  constructor(ports, crypto, modules, opts = {}) {
400
450
  if (opts.check !== undefined && typeof opts.check !== 'function')
401
451
  throw new TypeError('check must be a function');
@@ -442,6 +492,7 @@ class HolderEngine {
442
492
  const ownRetries = new Set();
443
493
  const keepKeys = [];
444
494
  const retain = [];
495
+ const accepted = [];
445
496
  const deadlines = [];
446
497
  const names = new Set();
447
498
  for (const m of modules) {
@@ -530,6 +581,11 @@ class HolderEngine {
530
581
  throw new TypeError(`deadlines (module ${m.name}) must be a function`);
531
582
  deadlines.push(m.deadlines);
532
583
  }
584
+ if (m.accepted !== undefined) {
585
+ if (typeof m.accepted !== 'function')
586
+ throw new TypeError(`accepted (module ${m.name}) must be a function`);
587
+ accepted.push(m.accepted);
588
+ }
533
589
  }
534
590
  this.#commands = commands;
535
591
  this.#tasks = tasks;
@@ -541,6 +597,7 @@ class HolderEngine {
541
597
  this.#keepKeys = Object.freeze(keepKeys);
542
598
  this.#deadlines = Object.freeze(deadlines);
543
599
  this.#retain = Object.freeze(retain);
600
+ this.#accepted = Object.freeze(accepted);
544
601
  this.#builtins = Object.freeze({ tick: this.#tickDef(), resume: this.#resumeDef(), report: this.#reportDef(), receive: this.#receiveDef() });
545
602
  this.registry = Object.freeze({
546
603
  commands: Object.freeze([...commands.keys()]),
@@ -586,7 +643,14 @@ class HolderEngine {
586
643
  }
587
644
  // ── the queue ────────────────────────────────────────────────────────
588
645
  #enqueue(fn) {
589
- const p = this.#chain.then(async () => { await this.#settle(); return fn(); });
646
+ if (this.#closed)
647
+ return Promise.reject(closedError());
648
+ const p = this.#chain.then(async () => {
649
+ if (this.#closed)
650
+ throw closedError();
651
+ await this.#settle();
652
+ return fn();
653
+ });
590
654
  this.#chain = p.then(() => undefined, () => undefined);
591
655
  return p;
592
656
  }
@@ -615,6 +679,11 @@ class HolderEngine {
615
679
  // ── the four steps ───────────────────────────────────────────────────
616
680
  /** `input` is already bound (bindInput); `inputHash` its journal hash for human acts. */
617
681
  async #run(def, op, input, when, actId, inputHash) {
682
+ /** a replay's give-up work (below): its timer, carried to the replay's answer */
683
+ let replayWake;
684
+ /** give-ups a replay ran: each moves at least one of its finitely many
685
+ * journaled documents to failed, so they are bounded by that number */
686
+ let gaveUp = 0;
618
687
  for (let attempt = 1; attempt <= this.#policy.commitAttempts; attempt++) {
619
688
  // 1 — snapshot, when
620
689
  const { state, version } = await this.#read();
@@ -623,9 +692,35 @@ class HolderEngine {
623
692
  const rec = state.acts[actId];
624
693
  if (rec.inputHash !== inputHash)
625
694
  return { ok: false, code: 'actid-conflict' };
626
- const live = rec.outboundDigests.filter((d) => { const t = triasOf(this.#itemOf(state, d)); return t !== 'delivered' && t !== 'failed'; });
627
695
  const events = structuredClone(rec.events);
628
- return { ok: true, outbound: live.map((d) => this.#outboundOf(state, d)), events: freezeDeep(events) };
696
+ // the act's documents are handed out again only while live; one past
697
+ // its sender lifetime or the horizon is given up first, in an
698
+ // operation of its own on the engine's one commit path (as resume's
699
+ // give-up: Delivery 6110, 6115), and is never handed out — the
700
+ // journal's events are not announced again
701
+ const live = [];
702
+ const expired = [];
703
+ for (const d of rec.outboundDigests) {
704
+ const it = this.#itemOf(state, d);
705
+ const t = triasOf(it);
706
+ if (t === 'delivered' || t === 'failed')
707
+ continue;
708
+ if (this.#giveUp(it, at))
709
+ expired.push(d);
710
+ else
711
+ live.push(d);
712
+ }
713
+ if (expired.length) {
714
+ const g = await this.#run(this.#giveUpDef(), { kind: 'resume' }, expired, at);
715
+ if (!isHalt(g) && g.ok && g.nextDueAt !== undefined)
716
+ replayWake = replayWake === undefined ? g.nextDueAt : Math.min(replayWake, g.nextDueAt);
717
+ // re-read: the act's documents as the give-up left them, outside
718
+ // the CAS budget (no conflict happened); bounded by the act's documents
719
+ if (gaveUp++ <= rec.outboundDigests.length)
720
+ attempt--;
721
+ continue;
722
+ }
723
+ return { ok: true, outbound: live.map((d) => this.#outboundOf(state, d)), events: freezeDeep(events), ...(replayWake !== undefined ? { nextDueAt: replayWake } : {}) };
629
724
  }
630
725
  // 2 — prepare, bound to this snapshot
631
726
  const ctx = Object.freeze({
@@ -679,12 +774,16 @@ class HolderEngine {
679
774
  freezeDeep(events);
680
775
  const outbound = drafts.map((o) => ({ digest: o.digest, envelope: o.envelope, type: o.type, threadId: o.threadId, recipient: o.recipient, attempt: 1 }));
681
776
  const outbox = { ...r.state.outbox };
682
- for (const o of outbound) {
777
+ for (const [i, o] of outbound.entries()) {
683
778
  if (Object.hasOwn(outbox, o.digest))
684
779
  throw new Error(`engine: outbound ${o.digest} is already in the outbox`);
780
+ const life = drafts[i].expiresAt;
781
+ if (life !== undefined && (!Number.isSafeInteger(life) || life <= at))
782
+ throw new Error(`engine: outbound ${o.digest} expires at ${life}, not after ${at}`);
685
783
  outbox[o.digest] = {
686
784
  type: o.type, threadId: o.threadId, recipient: o.recipient, envelope: o.envelope, attempt: 1,
687
785
  history: [{ status: 'submitted', at, attempt: 1 }], nextAttemptAt: dueAt(at, this.#policy.backoff(1)),
786
+ ...(life !== undefined ? { expiresAt: life } : {}),
688
787
  };
689
788
  }
690
789
  const duties = { ...r.state.duties };
@@ -819,6 +918,50 @@ class HolderEngine {
819
918
  }
820
919
  return { state: cur, events };
821
920
  }
921
+ /** The give-up of the outbox items named (a replay found them due, see
922
+ * #run): as resume's, the failed step, the status event, the failure
923
+ * handlers in the same transaction; items no longer due are left alone. */
924
+ #giveUpDef() {
925
+ const giveUp = (it, when) => this.#giveUp(it, when);
926
+ const failed = (st, digests, when) => this.#failed(st, digests, when);
927
+ return {
928
+ human: false,
929
+ apply(state, _p, digests, when) {
930
+ const outbox = { ...state.outbox };
931
+ const events = [];
932
+ const gave = [];
933
+ for (const d of digests) {
934
+ const it = state.outbox[d];
935
+ const t = triasOf(it);
936
+ if (t === 'delivered' || t === 'failed')
937
+ continue;
938
+ const why = giveUp(it, when);
939
+ if (!why)
940
+ continue;
941
+ const { nextAttemptAt: _, ...rest } = it;
942
+ outbox[d] = { ...rest, history: [...rest.history, { status: 'failed', at: when, reason: why }] };
943
+ events.push({ type: 'delivery.status', digest: d, threadId: it.threadId, status: 'failed' });
944
+ gave.push(d);
945
+ }
946
+ if (!gave.length)
947
+ return { state: state };
948
+ const f = failed({ ...state, outbox }, gave, when);
949
+ return { state: f.state, events: [...events, ...f.events] };
950
+ },
951
+ };
952
+ }
953
+ /** Why a non-terminal outbox item is given up at `when`, or null: its
954
+ * sender lifetime, where it ends no later than the horizon
955
+ * (failed(task-expired), Delivery 6110), else the horizon
956
+ * (failed(expired-by-adapter-policy)). */
957
+ #giveUp(it, when) {
958
+ const first = it.history.length ? it.history[0].at : when;
959
+ const expiry = Math.min(first + this.#policy.horizonMs, Number.MAX_SAFE_INTEGER);
960
+ const life = it.expiresAt;
961
+ if (life !== undefined && when >= life && life <= expiry)
962
+ return 'task-expired';
963
+ return when >= expiry ? 'expired-by-adapter-policy' : null;
964
+ }
822
965
  #itemOf(state, digest) {
823
966
  if (!Object.hasOwn(state.outbox, digest))
824
967
  throw new Error(`engine: journaled outbound ${digest} is missing from the outbox`);
@@ -882,8 +1025,9 @@ class HolderEngine {
882
1025
  if (!t.deactivated)
883
1026
  live.add(t.rkid);
884
1027
  // and the rkids a module still needs (a deactivated prior whose continuity flow is open)
1028
+ // (at this tick's time: a claim that ends now ends in this tick)
885
1029
  for (const k of keepers())
886
- for (const rkid of k(held))
1030
+ for (const rkid of k(held, when))
887
1031
  live.add(rkid);
888
1032
  const tombstones = [...held.addresses.tombstones];
889
1033
  let gone = false;
@@ -935,6 +1079,7 @@ class HolderEngine {
935
1079
  const sweeps = () => this.#sweeps;
936
1080
  const failed = (st, digests, when) => this.#failed(st, digests, when);
937
1081
  const ownRetries = () => this.#ownRetries;
1082
+ const giveUp = (it, when) => this.#giveUp(it, when);
938
1083
  return {
939
1084
  human: false,
940
1085
  // the due duties' and the sweeps' preparations, against the snapshot apply will see
@@ -963,17 +1108,20 @@ class HolderEngine {
963
1108
  const trias = triasOf(it);
964
1109
  if (trias === 'delivered' || trias === 'failed')
965
1110
  continue;
966
- // the horizon applies to every non-terminal item: held, accepted or not
967
- const first = it.history.length ? it.history[0].at : when;
968
- const expiry = Math.min(first + p.horizonMs, Number.MAX_SAFE_INTEGER);
969
- if (when >= expiry) {
1111
+ // the horizon applies to every non-terminal item: held, accepted or
1112
+ // not; a sender lifetime the type declares ends it earlier, as
1113
+ // failed(task-expired), never handed out again (Delivery 6110, 6115)
1114
+ const why = giveUp(it, when);
1115
+ if (why) {
970
1116
  const { nextAttemptAt: _, ...rest } = it;
971
- outbox[digest] = { ...rest, history: [...it.history, { status: 'failed', at: when, reason: 'expired-by-adapter-policy' }] };
1117
+ outbox[digest] = { ...rest, history: [...it.history, { status: 'failed', at: when, reason: why }] };
972
1118
  events.push({ type: 'delivery.status', digest, threadId: it.threadId, status: 'failed' });
973
1119
  gaveUp.push(digest);
974
1120
  continue;
975
1121
  }
976
- due(expiry);
1122
+ const first = it.history.length ? it.history[0].at : when;
1123
+ const expiry = Math.min(first + p.horizonMs, Number.MAX_SAFE_INTEGER);
1124
+ due(it.expiresAt !== undefined ? Math.min(it.expiresAt, expiry) : expiry);
977
1125
  // never re-emitted: an accepted item (the service has it) and one the
978
1126
  // adapter holds (awaiting-transport, until it reports again)
979
1127
  if (trias === 'accepted' || it.history[it.history.length - 1]?.status === 'awaiting-transport')
@@ -1102,11 +1250,14 @@ class HolderEngine {
1102
1250
  const expiry = Math.min(a.at + p.horizonMs, Number.MAX_SAFE_INTEGER);
1103
1251
  effects ??= { ...cur.effects };
1104
1252
  if (when >= expiry) {
1105
- const { nextAttemptAt: _, ...rest } = a;
1253
+ const { nextAttemptAt: _, held: _h, ...rest } = a;
1106
1254
  effects[d] = { ...cur.effects[d], ack: { ...rest, sent: true, sentBy: 'horizon' } };
1107
1255
  continue;
1108
1256
  }
1109
1257
  due(expiry);
1258
+ // held: the adapter has it (awaiting-transport); pendingTransport offers it
1259
+ if (a.held !== undefined)
1260
+ continue;
1110
1261
  if (a.nextAttemptAt !== undefined && a.nextAttemptAt > when) {
1111
1262
  due(a.nextAttemptAt);
1112
1263
  continue;
@@ -1131,21 +1282,57 @@ class HolderEngine {
1131
1282
  },
1132
1283
  };
1133
1284
  }
1285
+ /** an answer's hand-over began: it is no longer due (handOver) */
1286
+ #answeredDef() {
1287
+ return {
1288
+ human: false,
1289
+ apply(state, _p, input) {
1290
+ const eff = Object.keys(state.effects).find((d) => state.effects[d].ack?.digest === input.digest);
1291
+ if (eff === undefined)
1292
+ return { state: state };
1293
+ const a = state.effects[eff].ack;
1294
+ if (a.answerDue !== true || a.answers !== input.n)
1295
+ return { state: state };
1296
+ const { answerDue: _d, ...rest } = a;
1297
+ return { state: { ...state, effects: { ...state.effects, [eff]: { ...state.effects[eff], ack: rest } } } };
1298
+ },
1299
+ };
1300
+ }
1134
1301
  #reportDef() {
1135
1302
  const failed = (st, digests, when) => this.#failed(st, digests, when);
1136
1303
  return {
1137
1304
  human: false,
1138
1305
  apply(state, _p, report, when) {
1139
1306
  if (!Object.hasOwn(state.outbox, report.digest)) {
1140
- // an ack (outbox-less): any report says the app handed it over
1307
+ // an ack (outbox-less, no trias; Delivery RLTP-DEL-6330): sent once
1308
+ // a service took it or the adapter gave it up; awaiting-transport
1309
+ // records that the adapter holds it and ends nothing
1141
1310
  const eff = Object.keys(state.effects).find((d) => state.effects[d].ack?.digest === report.digest);
1142
1311
  if (eff === undefined)
1143
1312
  return { ok: false, code: 'unknown-digest' };
1144
1313
  const e = state.effects[eff];
1145
- if (e.ack.sent)
1314
+ const a = e.ack;
1315
+ if (a.sent)
1146
1316
  return { state: state };
1147
- const { nextAttemptAt: _, ...ack } = e.ack;
1148
- return { state: { ...state, effects: { ...state.effects, [eff]: { ...e, ack: { ...ack, sent: true, sentBy: 'report' } } } } };
1317
+ const { nextAttemptAt: _, held: _h, ...rest } = a;
1318
+ let ack;
1319
+ switch (report.status) {
1320
+ case 'awaiting-transport':
1321
+ if (a.held === report.reason)
1322
+ return { state: state };
1323
+ ack = { ...rest, held: report.reason };
1324
+ break;
1325
+ case 'accepted':
1326
+ case 'delivered':
1327
+ ack = { ...rest, sent: true, sentBy: 'accepted' };
1328
+ break;
1329
+ case 'failed':
1330
+ ack = { ...rest, sent: true, sentBy: 'failed' };
1331
+ break;
1332
+ default:
1333
+ throw new TypeError(`unknown report status ${String(report.status)}`);
1334
+ }
1335
+ return { state: { ...state, effects: { ...state.effects, [eff]: { ...e, ack } } } };
1149
1336
  }
1150
1337
  const it = state.outbox[report.digest];
1151
1338
  const trias = triasOf(it);
@@ -1199,6 +1386,7 @@ class HolderEngine {
1199
1386
  #receiveDef() {
1200
1387
  const tasks = () => this.#tasks;
1201
1388
  const acks = () => this.#acks;
1389
+ const acceptedHooks = () => this.#accepted;
1202
1390
  const policy = () => this.#policy;
1203
1391
  return {
1204
1392
  human: false,
@@ -1211,8 +1399,14 @@ class HolderEngine {
1211
1399
  case 'duplicate': {
1212
1400
  // the stored ack, byte-identical; a terminal document has none
1213
1401
  // (its outbound attempt is one past the schedule's, which it does not move)
1402
+ // and an answer due (Delivery RLTP-DEL-6330): a hand-over of its
1403
+ // own, kept across a lock until it begins; while one is due, a
1404
+ // further duplicate is answered by it (the same bytes)
1214
1405
  const a = state.effects[plan.digest].ack;
1215
- return { state: state, release: a ? [ackOutbound(a, a.attempt + 1)] : [], detail: { disposition: 'duplicate-known', digest: plan.digest } };
1406
+ const next = a && a.answerDue === undefined
1407
+ ? { ...state, effects: { ...state.effects, [plan.digest]: { ...state.effects[plan.digest], ack: { ...a, answers: (a.answers ?? 0) + 1, answerDue: true } } } }
1408
+ : state;
1409
+ return { state: next, release: a ? [ackOutbound(a, a.attempt + 1)] : [], detail: { disposition: 'duplicate-known', digest: plan.digest } };
1216
1410
  }
1217
1411
  case 'ack': {
1218
1412
  // terminal: the sender-status update and its cache entry, no ack;
@@ -1228,6 +1422,13 @@ class HolderEngine {
1228
1422
  next = r.state;
1229
1423
  events.push(...(r.events ?? []));
1230
1424
  }
1425
+ // a verified ack is an accepted document of its issuer too: the
1426
+ // modules' accepted hooks, in the same transaction
1427
+ for (const hook of acceptedHooks()) {
1428
+ next = hook(next, plan.doc, plan.arrival);
1429
+ if (next.effects !== state.effects)
1430
+ throw new Error('engine: an accepted hook wrote the effect cache');
1431
+ }
1231
1432
  return {
1232
1433
  state: { ...next, effects: { ...next.effects, [plan.digest]: cached(null) } },
1233
1434
  events,
@@ -1253,8 +1454,15 @@ class HolderEngine {
1253
1454
  }
1254
1455
  if (r.state.effects !== state.effects)
1255
1456
  throw new Error(`engine: the handler of ${plan.type} wrote the effect cache; the core writes it`);
1457
+ // the modules' accepted hooks, in the same transaction
1458
+ let after = r.state;
1459
+ for (const hook of acceptedHooks()) {
1460
+ after = hook(after, plan.doc, plan.arrival);
1461
+ if (after.effects !== state.effects)
1462
+ throw new Error('engine: an accepted hook wrote the effect cache');
1463
+ }
1256
1464
  return {
1257
- state: { ...r.state, effects: { ...state.effects, [plan.digest]: cached({ ...plan.ack, at: when, attempt: 1, nextAttemptAt: dueAt(when, policy().backoff(1)), sent: false }) } },
1465
+ state: { ...after, effects: { ...state.effects, [plan.digest]: cached({ ...plan.ack, at: when, attempt: 1, nextAttemptAt: dueAt(when, policy().backoff(1)), sent: false }) } },
1258
1466
  ...(r.outbound ? { outbound: r.outbound } : {}),
1259
1467
  ...(r.events ? { events: r.events } : {}),
1260
1468
  ...(r.duties ? { duties: r.duties } : {}),
@@ -1314,12 +1522,16 @@ class HolderEngine {
1314
1522
  catch (e) {
1315
1523
  return Promise.reject(e);
1316
1524
  }
1525
+ // fail closed: a resume that throws anywhere — the settling of an
1526
+ // earlier commit included — leaves nothing to go out until one completes
1317
1527
  return this.#enqueue(async () => {
1528
+ this.#resumed = false;
1318
1529
  const r = await this.#run(this.#builtins.resume, { kind: 'resume' }, null, when);
1319
1530
  if (isHalt(r) || !r.ok)
1320
1531
  throw new Error('engine: resume does not refuse');
1532
+ this.#resumed = true;
1321
1533
  return { outbound: r.outbound, events: r.events, ...(r.nextDueAt !== undefined ? { nextDueAt: r.nextDueAt } : {}) };
1322
- });
1534
+ }).catch((e) => { this.#resumed = false; throw e; });
1323
1535
  }
1324
1536
  report(report, when) {
1325
1537
  let bound;
@@ -1377,6 +1589,182 @@ class HolderEngine {
1377
1589
  return Object.freeze({ state, commitSeq: state.commitSeq });
1378
1590
  });
1379
1591
  }
1592
+ pendingTransport(when) {
1593
+ try {
1594
+ checkWhen(when);
1595
+ }
1596
+ catch (e) {
1597
+ return Promise.reject(e);
1598
+ }
1599
+ return this.#enqueue(async () => {
1600
+ if (!this.#resumed)
1601
+ return Object.freeze({ ok: false, code: 'not-resumed' });
1602
+ const { state } = await this.#read();
1603
+ // the holder's time never runs back: an earlier `when` reads at lastWhen
1604
+ const at = Math.max(when, state.lastWhen);
1605
+ const entries = transportEntries(state, at, this.#policy.horizonMs).map((e) => {
1606
+ const f = freezeDeep(e);
1607
+ this.#issued.add(f);
1608
+ return f;
1609
+ });
1610
+ return Object.freeze({ ok: true, commitSeq: state.commitSeq, at, entries: Object.freeze(entries) });
1611
+ });
1612
+ }
1613
+ mayHandOver(entry, when) {
1614
+ try {
1615
+ checkWhen(when);
1616
+ }
1617
+ catch (e) {
1618
+ return Promise.reject(e);
1619
+ }
1620
+ if (this.#closed || entry === null || typeof entry !== 'object' || !this.#issued.has(entry))
1621
+ return Promise.resolve(false);
1622
+ return this.#enqueue(() => this.#mayGo(entry, when)).catch((e) => {
1623
+ if (e instanceof InfraError && e.code === 'closed')
1624
+ return false;
1625
+ throw e;
1626
+ });
1627
+ }
1628
+ handOver(entry, when, start) {
1629
+ try {
1630
+ checkWhen(when);
1631
+ }
1632
+ catch (e) {
1633
+ return Promise.reject(e);
1634
+ }
1635
+ if (typeof start !== 'function')
1636
+ return Promise.reject(new TypeError('handOver needs a start function'));
1637
+ if (this.#closed || entry === null || typeof entry !== 'object' || !this.#issued.has(entry))
1638
+ return Promise.resolve(false);
1639
+ return this.#enqueue(async () => {
1640
+ if (!this.#resumed)
1641
+ return false;
1642
+ const { state } = await this.#read();
1643
+ // no await from here to start(): close() and every other operation
1644
+ // of this engine come before the check or after the start
1645
+ if (!this.#passes(state, entry, when))
1646
+ return false;
1647
+ if (start() !== true)
1648
+ return false;
1649
+ // an answer's hand-over has begun: it is no longer due (a later
1650
+ // duplicate grants the next); a commit that fails leaves it due, and
1651
+ // it may go out once more — the receiver absorbs it
1652
+ if (entry.resend !== undefined) {
1653
+ try {
1654
+ await this.#run(this.#answeredDef(), { kind: 'report', status: 'answered' }, { digest: entry.digest, n: entry.resend }, Math.max(when, state.lastWhen));
1655
+ }
1656
+ catch { /* stays due */ }
1657
+ }
1658
+ return true;
1659
+ }).catch((e) => {
1660
+ if (e instanceof InfraError && e.code === 'closed')
1661
+ return false;
1662
+ throw e;
1663
+ });
1664
+ }
1665
+ /** the gate's check, inside the queue: open, resumed, still pending in
1666
+ * the store as issued, before its deadline */
1667
+ async #mayGo(entry, when) {
1668
+ if (!this.#resumed)
1669
+ return false;
1670
+ const { state } = await this.#read();
1671
+ return this.#passes(state, entry, when);
1672
+ }
1673
+ /** synchronous: open, resumed, the entry pending in `state` as issued,
1674
+ * before its deadline at max(when, lastWhen) */
1675
+ #passes(state, entry, when) {
1676
+ if (this.#closed || !this.#resumed)
1677
+ return false;
1678
+ const now = transportEntryOf(state, entry.kind, entry.digest, Math.max(when, state.lastWhen), this.#policy.horizonMs, entry.resend);
1679
+ return now !== null && now.resend === entry.resend && jcs(now.envelope) === jcs(entry.envelope);
1680
+ }
1681
+ close() {
1682
+ this.#closed = true;
1683
+ return this.#chain.then(() => undefined, () => undefined);
1684
+ }
1685
+ get closed() { return this.#closed; }
1686
+ }
1687
+ /** The send deadline of an outbox item: the horizon from its first
1688
+ * submission, or the sender lifetime its type declared if that is
1689
+ * earlier (#giveUp's two bounds as one instant). */
1690
+ const notAfterOf = (it, horizonMs) => {
1691
+ if (!it.history.length)
1692
+ return null;
1693
+ const expiry = Math.min(it.history[0].at + horizonMs, Number.MAX_SAFE_INTEGER);
1694
+ return it.expiresAt !== undefined ? Math.min(it.expiresAt, expiry) : expiry;
1695
+ };
1696
+ /** The transport entry of one pending document or ack at `when`, or null:
1697
+ * terminal, accepted, sent, past its deadline, or absent (RLTP-DEL-6135:
1698
+ * absent means stop). */
1699
+ function transportEntryOf(state, kind, digest, when, horizonMs, resend) {
1700
+ if (kind === 'document') {
1701
+ if (!Object.hasOwn(state.outbox, digest))
1702
+ return null;
1703
+ const it = state.outbox[digest];
1704
+ const trias = triasOf(it);
1705
+ if (trias !== 'submitted')
1706
+ return null;
1707
+ const notAfter = notAfterOf(it, horizonMs);
1708
+ if (notAfter === null || when >= notAfter)
1709
+ return null;
1710
+ const last = it.history[it.history.length - 1];
1711
+ return structuredClone({
1712
+ digest, kind, envelope: it.envelope, type: it.type, threadId: it.threadId, recipient: it.recipient, attempt: it.attempt, since: it.history[0].at, notAfter,
1713
+ ...(last.status === 'awaiting-transport' ? { held: last.reason } : it.nextAttemptAt !== undefined ? { nextAttemptAt: it.nextAttemptAt } : {}),
1714
+ });
1715
+ }
1716
+ for (const d of Object.keys(state.effects)) {
1717
+ const a = state.effects[d].ack;
1718
+ if (a === null || a.digest !== digest)
1719
+ continue;
1720
+ if (resend !== undefined) {
1721
+ // a duplicate's answer: its own hand-over, apart from the retries and
1722
+ // whatever state they are in, while due and the effect keeps the ack (6335)
1723
+ if (a.answerDue !== true || a.answers !== resend || when >= state.effects[d].expiresAt)
1724
+ return null;
1725
+ return structuredClone({
1726
+ digest, kind, envelope: a.envelope, type: ACK_TYPE, threadId: a.threadId, recipient: a.recipient, attempt: a.attempt, since: a.at,
1727
+ notAfter: state.effects[d].expiresAt, resend,
1728
+ });
1729
+ }
1730
+ const notAfter = Math.min(a.at + horizonMs, Number.MAX_SAFE_INTEGER);
1731
+ if (a.sent || when >= notAfter)
1732
+ return null;
1733
+ return structuredClone({
1734
+ digest, kind, envelope: a.envelope, type: ACK_TYPE, threadId: a.threadId, recipient: a.recipient, attempt: a.attempt, since: a.at, notAfter,
1735
+ ...(a.held !== undefined ? { held: a.held } : a.nextAttemptAt !== undefined ? { nextAttemptAt: a.nextAttemptAt } : {}),
1736
+ });
1737
+ }
1738
+ return null;
1739
+ }
1740
+ /** Everything the holder keeps for transport at `when`: the outbox items
1741
+ * by due time, then digest, then the unsent acks by effect digest. */
1742
+ function transportEntries(state, when, horizonMs) {
1743
+ const out = [];
1744
+ const order = Object.keys(state.outbox).sort((a, b) => ((state.outbox[a].nextAttemptAt ?? 0) - (state.outbox[b].nextAttemptAt ?? 0)) || (a < b ? -1 : a > b ? 1 : 0));
1745
+ for (const d of order) {
1746
+ const e = transportEntryOf(state, 'document', d, when, horizonMs);
1747
+ if (e)
1748
+ out.push(e);
1749
+ }
1750
+ for (const d of Object.keys(state.effects).sort()) {
1751
+ const a = state.effects[d].ack;
1752
+ if (a === null)
1753
+ continue;
1754
+ const e = transportEntryOf(state, 'ack', a.digest, when, horizonMs);
1755
+ if (e)
1756
+ out.push(e);
1757
+ }
1758
+ // the duplicates' answers due, each its own entry beside the retries
1759
+ for (const d of Object.keys(state.effects).sort()) {
1760
+ const a = state.effects[d].ack;
1761
+ if (a === null || a.answerDue !== true)
1762
+ continue;
1763
+ const e = transportEntryOf(state, 'ack', a.digest, when, horizonMs, a.answers);
1764
+ if (e)
1765
+ out.push(e);
1766
+ }
1767
+ return out;
1380
1768
  }
1381
1769
  /**
1382
1770
  * Build an engine over the ports and modules: resolves and probes