@real-life/trust-protocol 0.3.2 → 0.5.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 (277) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +209 -78
  3. package/dist/ack.d.ts +84 -0
  4. package/dist/ack.d.ts.map +1 -0
  5. package/dist/ack.js +114 -0
  6. package/dist/ack.js.map +1 -0
  7. package/dist/adapters/vti-mediator.d.ts +169 -0
  8. package/dist/adapters/vti-mediator.d.ts.map +1 -0
  9. package/dist/adapters/vti-mediator.js +659 -0
  10. package/dist/adapters/vti-mediator.js.map +1 -0
  11. package/dist/anchor.d.ts +226 -0
  12. package/dist/anchor.d.ts.map +1 -0
  13. package/dist/anchor.js +347 -0
  14. package/dist/anchor.js.map +1 -0
  15. package/dist/{holder.d.ts → carrier-holder.d.ts} +1 -1
  16. package/dist/carrier-holder.d.ts.map +1 -0
  17. package/dist/{holder.js → carrier-holder.js} +5 -4
  18. package/dist/carrier-holder.js.map +1 -0
  19. package/dist/carrier-identity.js +4 -4
  20. package/dist/carrier-identity.js.map +1 -1
  21. package/dist/carrier.d.ts.map +1 -1
  22. package/dist/carrier.js +4 -3
  23. package/dist/carrier.js.map +1 -1
  24. package/dist/ceremony.d.ts +3 -2
  25. package/dist/ceremony.d.ts.map +1 -1
  26. package/dist/ceremony.js +16 -26
  27. package/dist/ceremony.js.map +1 -1
  28. package/dist/codec.d.ts +54 -0
  29. package/dist/codec.d.ts.map +1 -0
  30. package/dist/codec.js +494 -0
  31. package/dist/codec.js.map +1 -0
  32. package/dist/continuity.d.ts +67 -0
  33. package/dist/continuity.d.ts.map +1 -0
  34. package/dist/continuity.js +110 -0
  35. package/dist/continuity.js.map +1 -0
  36. package/dist/core.d.ts +4 -0
  37. package/dist/core.d.ts.map +1 -1
  38. package/dist/core.js.map +1 -1
  39. package/dist/crypto.d.ts +23 -7
  40. package/dist/crypto.d.ts.map +1 -1
  41. package/dist/crypto.js +54 -20
  42. package/dist/crypto.js.map +1 -1
  43. package/dist/delivery.d.ts +31 -3
  44. package/dist/delivery.d.ts.map +1 -1
  45. package/dist/delivery.js +75 -28
  46. package/dist/delivery.js.map +1 -1
  47. package/dist/encounter/ceremony.d.ts +37 -0
  48. package/dist/encounter/ceremony.d.ts.map +1 -0
  49. package/dist/encounter/ceremony.js +662 -0
  50. package/dist/encounter/ceremony.js.map +1 -0
  51. package/dist/encounter.d.ts +91 -1
  52. package/dist/encounter.d.ts.map +1 -1
  53. package/dist/encounter.js +191 -12
  54. package/dist/encounter.js.map +1 -1
  55. package/dist/engine.d.ts +303 -0
  56. package/dist/engine.d.ts.map +1 -0
  57. package/dist/engine.js +1509 -0
  58. package/dist/engine.js.map +1 -0
  59. package/dist/entropy.d.ts +28 -0
  60. package/dist/entropy.d.ts.map +1 -0
  61. package/dist/entropy.js +96 -0
  62. package/dist/entropy.js.map +1 -0
  63. package/dist/events.d.ts +198 -0
  64. package/dist/events.d.ts.map +1 -0
  65. package/dist/events.js +2 -0
  66. package/dist/events.js.map +1 -0
  67. package/dist/fixtures.d.ts +30 -0
  68. package/dist/fixtures.d.ts.map +1 -0
  69. package/dist/fixtures.js +155 -0
  70. package/dist/fixtures.js.map +1 -0
  71. package/dist/forge.d.ts +47 -0
  72. package/dist/forge.d.ts.map +1 -0
  73. package/dist/forge.js +20 -0
  74. package/dist/forge.js.map +1 -0
  75. package/dist/group-star.d.ts +251 -0
  76. package/dist/group-star.d.ts.map +1 -0
  77. package/dist/group-star.js +348 -0
  78. package/dist/group-star.js.map +1 -0
  79. package/dist/holder-api.d.ts +165 -0
  80. package/dist/holder-api.d.ts.map +1 -0
  81. package/dist/holder-api.js +142 -0
  82. package/dist/holder-api.js.map +1 -0
  83. package/dist/holder-state-v1.d.ts +801 -0
  84. package/dist/holder-state-v1.d.ts.map +1 -0
  85. package/dist/holder-state-v1.js +2 -0
  86. package/dist/holder-state-v1.js.map +1 -0
  87. package/dist/holder-state-v2.d.ts +858 -0
  88. package/dist/holder-state-v2.d.ts.map +1 -0
  89. package/dist/holder-state-v2.js +2 -0
  90. package/dist/holder-state-v2.js.map +1 -0
  91. package/dist/identity-state.d.ts +43 -0
  92. package/dist/identity-state.d.ts.map +1 -0
  93. package/dist/identity-state.js +345 -0
  94. package/dist/identity-state.js.map +1 -0
  95. package/dist/identity.d.ts +24 -2
  96. package/dist/identity.d.ts.map +1 -1
  97. package/dist/identity.js +70 -11
  98. package/dist/identity.js.map +1 -1
  99. package/dist/index.d.ts +27 -1
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +81 -29
  102. package/dist/index.js.map +1 -1
  103. package/dist/internal.d.ts +37 -0
  104. package/dist/internal.d.ts.map +1 -0
  105. package/dist/internal.js +22 -0
  106. package/dist/internal.js.map +1 -0
  107. package/dist/introduction/holder.d.ts +18 -0
  108. package/dist/introduction/holder.d.ts.map +1 -0
  109. package/dist/introduction/holder.js +764 -0
  110. package/dist/introduction/holder.js.map +1 -0
  111. package/dist/introduction.d.ts +101 -0
  112. package/dist/introduction.d.ts.map +1 -0
  113. package/dist/introduction.js +132 -0
  114. package/dist/introduction.js.map +1 -0
  115. package/dist/invariants.d.ts +43 -0
  116. package/dist/invariants.d.ts.map +1 -0
  117. package/dist/invariants.js +953 -0
  118. package/dist/invariants.js.map +1 -0
  119. package/dist/keys-seed.d.ts +7 -0
  120. package/dist/keys-seed.d.ts.map +1 -0
  121. package/dist/keys-seed.js +133 -0
  122. package/dist/keys-seed.js.map +1 -0
  123. package/dist/log-memory.d.ts +12 -0
  124. package/dist/log-memory.d.ts.map +1 -0
  125. package/dist/log-memory.js +101 -0
  126. package/dist/log-memory.js.map +1 -0
  127. package/dist/membership/holder.d.ts +33 -0
  128. package/dist/membership/holder.d.ts.map +1 -0
  129. package/dist/membership/holder.js +929 -0
  130. package/dist/membership/holder.js.map +1 -0
  131. package/dist/membership.d.ts +123 -0
  132. package/dist/membership.d.ts.map +1 -0
  133. package/dist/membership.js +180 -0
  134. package/dist/membership.js.map +1 -0
  135. package/dist/message/holder.d.ts +49 -0
  136. package/dist/message/holder.d.ts.map +1 -0
  137. package/dist/message/holder.js +290 -0
  138. package/dist/message/holder.js.map +1 -0
  139. package/dist/message.d.ts +43 -0
  140. package/dist/message.d.ts.map +1 -0
  141. package/dist/message.js +51 -0
  142. package/dist/message.js.map +1 -0
  143. package/dist/ports.d.ts +237 -0
  144. package/dist/ports.d.ts.map +1 -0
  145. package/dist/ports.js +2 -0
  146. package/dist/ports.js.map +1 -0
  147. package/dist/profile.d.ts +56 -0
  148. package/dist/profile.d.ts.map +1 -0
  149. package/dist/profile.js +193 -0
  150. package/dist/profile.js.map +1 -0
  151. package/dist/prompts.d.ts +14 -0
  152. package/dist/prompts.d.ts.map +1 -0
  153. package/dist/prompts.js +69 -0
  154. package/dist/prompts.js.map +1 -0
  155. package/dist/receive.d.ts +134 -0
  156. package/dist/receive.d.ts.map +1 -0
  157. package/dist/receive.js +317 -0
  158. package/dist/receive.js.map +1 -0
  159. package/dist/results.d.ts +173 -0
  160. package/dist/results.d.ts.map +1 -0
  161. package/dist/results.js +21 -0
  162. package/dist/results.js.map +1 -0
  163. package/dist/schemas.d.ts.map +1 -1
  164. package/dist/schemas.js +45 -38
  165. package/dist/schemas.js.map +1 -1
  166. package/dist/sha256.d.ts +3 -0
  167. package/dist/sha256.d.ts.map +1 -0
  168. package/dist/sha256.js +69 -0
  169. package/dist/sha256.js.map +1 -0
  170. package/dist/star.d.ts +31 -0
  171. package/dist/star.d.ts.map +1 -0
  172. package/dist/star.js +56 -0
  173. package/dist/star.js.map +1 -0
  174. package/dist/state-builder.d.ts +42 -0
  175. package/dist/state-builder.d.ts.map +1 -0
  176. package/dist/state-builder.js +100 -0
  177. package/dist/state-builder.js.map +1 -0
  178. package/dist/state.d.ts +914 -0
  179. package/dist/state.d.ts.map +1 -0
  180. package/dist/state.js +37 -0
  181. package/dist/state.js.map +1 -0
  182. package/dist/store-contract.d.ts +11 -0
  183. package/dist/store-contract.d.ts.map +1 -0
  184. package/dist/store-contract.js +189 -0
  185. package/dist/store-contract.js.map +1 -0
  186. package/dist/store-memory.d.ts +8 -0
  187. package/dist/store-memory.d.ts.map +1 -0
  188. package/dist/store-memory.js +17 -0
  189. package/dist/store-memory.js.map +1 -0
  190. package/dist/testing.d.ts +22 -0
  191. package/dist/testing.d.ts.map +1 -0
  192. package/dist/testing.js +21 -0
  193. package/dist/testing.js.map +1 -0
  194. package/dist/trust/continuity.d.ts +71 -0
  195. package/dist/trust/continuity.d.ts.map +1 -0
  196. package/dist/trust/continuity.js +1222 -0
  197. package/dist/trust/continuity.js.map +1 -0
  198. package/dist/trust/duties.d.ts +67 -0
  199. package/dist/trust/duties.d.ts.map +1 -0
  200. package/dist/trust/duties.js +179 -0
  201. package/dist/trust/duties.js.map +1 -0
  202. package/dist/trust/entries.d.ts +139 -0
  203. package/dist/trust/entries.d.ts.map +1 -0
  204. package/dist/trust/entries.js +795 -0
  205. package/dist/trust/entries.js.map +1 -0
  206. package/dist/trust/group-star.d.ts +43 -0
  207. package/dist/trust/group-star.d.ts.map +1 -0
  208. package/dist/trust/group-star.js +188 -0
  209. package/dist/trust/group-star.js.map +1 -0
  210. package/dist/trust/mapping.d.ts +56 -0
  211. package/dist/trust/mapping.d.ts.map +1 -0
  212. package/dist/trust/mapping.js +584 -0
  213. package/dist/trust/mapping.js.map +1 -0
  214. package/dist/trust/profile.d.ts +25 -0
  215. package/dist/trust/profile.d.ts.map +1 -0
  216. package/dist/trust/profile.js +249 -0
  217. package/dist/trust/profile.js.map +1 -0
  218. package/dist/trust/star.d.ts +208 -0
  219. package/dist/trust/star.d.ts.map +1 -0
  220. package/dist/trust/star.js +588 -0
  221. package/dist/trust/star.js.map +1 -0
  222. package/dist/trust/state.d.ts +83 -0
  223. package/dist/trust/state.d.ts.map +1 -0
  224. package/dist/trust/state.js +154 -0
  225. package/dist/trust/state.js.map +1 -0
  226. package/dist/util-freeze.d.ts +3 -0
  227. package/dist/util-freeze.d.ts.map +1 -0
  228. package/dist/util-freeze.js +11 -0
  229. package/dist/util-freeze.js.map +1 -0
  230. package/dist/views.d.ts +179 -0
  231. package/dist/views.d.ts.map +1 -0
  232. package/dist/views.js +286 -0
  233. package/dist/views.js.map +1 -0
  234. package/dist/visibility.d.ts +5 -5
  235. package/dist/visibility.d.ts.map +1 -1
  236. package/dist/visibility.js +22 -28
  237. package/dist/visibility.js.map +1 -1
  238. package/dist/wire.d.ts +218 -83
  239. package/dist/wire.d.ts.map +1 -1
  240. package/dist/wire.js.map +1 -1
  241. package/package.json +60 -10
  242. package/schemas/holder-state-v1.schema.json +3880 -0
  243. package/schemas/holder-state-v2.schema.json +4123 -0
  244. package/dist/holder.d.ts.map +0 -1
  245. package/dist/holder.js.map +0 -1
  246. package/dist/probe/acks.d.ts +0 -27
  247. package/dist/probe/acks.d.ts.map +0 -1
  248. package/dist/probe/acks.js +0 -152
  249. package/dist/probe/acks.js.map +0 -1
  250. package/dist/probe/continuity.d.ts +0 -20
  251. package/dist/probe/continuity.d.ts.map +0 -1
  252. package/dist/probe/continuity.js +0 -805
  253. package/dist/probe/continuity.js.map +0 -1
  254. package/dist/probe/deps.d.ts +0 -61
  255. package/dist/probe/deps.d.ts.map +0 -1
  256. package/dist/probe/deps.js +0 -54
  257. package/dist/probe/deps.js.map +0 -1
  258. package/dist/probe/encounter.d.ts +0 -26
  259. package/dist/probe/encounter.d.ts.map +0 -1
  260. package/dist/probe/encounter.js +0 -938
  261. package/dist/probe/encounter.js.map +0 -1
  262. package/dist/probe/introduce.d.ts +0 -62
  263. package/dist/probe/introduce.d.ts.map +0 -1
  264. package/dist/probe/introduce.js +0 -306
  265. package/dist/probe/introduce.js.map +0 -1
  266. package/dist/probe/membership.d.ts +0 -96
  267. package/dist/probe/membership.d.ts.map +0 -1
  268. package/dist/probe/membership.js +0 -467
  269. package/dist/probe/membership.js.map +0 -1
  270. package/dist/probe/trust.d.ts +0 -156
  271. package/dist/probe/trust.d.ts.map +0 -1
  272. package/dist/probe/trust.js +0 -1233
  273. package/dist/probe/trust.js.map +0 -1
  274. package/dist/probe.d.ts +0 -7
  275. package/dist/probe.d.ts.map +0 -1
  276. package/dist/probe.js +0 -42
  277. package/dist/probe.js.map +0 -1
package/dist/engine.js ADDED
@@ -0,0 +1,1509 @@
1
+ // engine — the holder's small engine (lib Stage 2, note §4.1–4.4, 4.7).
2
+ //
3
+ // Modules own their rules as pure transitions and register commands,
4
+ // task handlers and duty handlers here, once, at construction; after
5
+ // that the registries are frozen. No module has a commit path of its
6
+ // own: the engine coordinates snapshot, preparation, check, commit and
7
+ // release, and nothing else.
8
+ //
9
+ // An act runs in four steps (note §4.2), serialized by one in-engine
10
+ // queue:
11
+ // 1. read the snapshot {state, version} from the store and decode it;
12
+ // raise `when` to state.lastWhen (4.7)
13
+ // 2. prepare (async, outside the transaction): crypto, entropy, ports;
14
+ // `prepared` is bound to the version and commitSeq it ran against
15
+ // 3. apply (synchronous, pure, on a deep-frozen snapshot), then one CAS
16
+ // commit of state, outbox, duties, the act journal and commitSeq;
17
+ // on `conflict` back to 1 — the queue serializes this engine, CAS
18
+ // catches every other writer — bounded by retry.commitAttempts
19
+ // 4. only after the commit: events to subscribers, the result returned
20
+ //
21
+ // A commit that throws has an unknown outcome: InfraError
22
+ // 'unknown-commit-outcome'. The engine then settles before its next
23
+ // operation: it re-reads and, if the commit landed (the stored state is
24
+ // the one it wrote, or the journal holds the act at its commitSeq),
25
+ // releases that commit's events late; otherwise drops them. A repeated
26
+ // human act finds its result in the journal (note §4.2).
27
+ //
28
+ // Choices this file makes where the note leaves them open:
29
+ // - snapshots handed to prepare/apply are always deep-frozen (also in
30
+ // production): a mutating module throws a TypeError, nothing commits
31
+ // - every act reads the store afresh; the engine caches no state
32
+ // - eventId is `<commitSeq>.<index>`: unique per holder, no entropy
33
+ // - the input is bound at the public call: a canonical, deep-frozen JSON
34
+ // copy (undefined object members dropped, anything else non-JSON
35
+ // refused); its hash, every preparation and every apply use that copy
36
+ // - a `when` above lastWhen is a state change and commits; a non-human
37
+ // act whose apply returns the identical state object and nothing else
38
+ // commits nothing only when lastWhen stays too. A human act always
39
+ // commits, because its journal entry is the result. Refusals and
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)
44
+ // - outbound of an act starts at attempt 1 with nextAttemptAt =
45
+ // when + ceil(backoff(1)); resume() re-emits only items that are due
46
+ // and pauses re-emission while the last step is awaiting-transport
47
+ // (the adapter holds the item) and never re-emits an accepted one. It
48
+ // gives up every non-terminal item (submitted, held or accepted) at
49
+ // first submission + horizonMs, never by attempt
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
55
+ // delivered report is late by that history, whatever the adapter says
56
+ // - replay of a human act returns the journaled events and the outbound
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:
60
+ // max(P90D, retry.horizonMs), the key-retention of Delivery §7 that
61
+ // bounds effects and their acks; after it the same actId is a new act
62
+ // - KeyProvider, Log and Access rejections inside a preparation become
63
+ // InfraError('key-provider' | 'log' | 'access') with the cause. Known
64
+ // limitation: a provider's argument errors (a module passing a label
65
+ // outside the registry, say) are wrapped the same way, so a module
66
+ // programming error surfaces as an infrastructure error; the original
67
+ // error stays in `cause`
68
+ // - events are deep-frozen before listeners or callers see them; a
69
+ // listener's throw or rejection is dropped
70
+ // - the next state is validated by the codec before every commit; an
71
+ // invalid state is a programming error and throws
72
+ // - receive (receive.ts) is a built-in like tick: a failed disposition
73
+ // commits nothing, lastWhen included (like a refusal); duplicate-known
74
+ // commits only a raised lastWhen; a CAS conflict re-reads and runs the
75
+ // stages again, which re-checks the effect cache at stage 4
76
+ // - an ack is stored sealed (StoredAck: envelope, digest, recipient) in
77
+ // its effect, so every re-send is byte-identical down to the envelope.
78
+ // It stays `sent: false` until any report on its digest. Unsent, it has
79
+ // the outbox's retry schedule: attempt 1 at the receive, re-offered by
80
+ // resume when due (attempt + 1, backoff), counted into nextDueAt, and at
81
+ // its horizon (effect commit + horizonMs) marked sent by the horizon
82
+ // (`sentBy: 'horizon'`, no event: acks have no trias). A duplicate's
83
+ // re-send carries attempt + 1 and does not move the schedule (it
84
+ // commits nothing beyond lastWhen)
85
+ // - outbound of one commit, in order: outbox re-emissions, released
86
+ // acks, new documents
87
+ // - resume runs duties after the outbox, ordered by dueAt (absent: due
88
+ // always, counted as 0), then id. An Applied fulfils the duty and the
89
+ // engine removes it in the same commit; 'not-due' and a refusal keep it
90
+ // without a trace. A duty of a registered kind at another version is
91
+ // announced as duty.unsupported on every resume (a commit of its own);
92
+ // a kind no module registers is kept silently. A duty with awaitHead
93
+ // runs only when its handler's awaitHolds says so (none: never). Each
94
+ // planned duty is checked again against the state the duties before it
95
+ // left: still present at the handler's version, due, its awaited head
96
+ // holding; a prepared duty whose data changed since its preparation is
97
+ // deferred to the next resume and prepared afresh there. Its handler's
98
+ // apply re-checks every other state-dependent precondition itself
99
+ // (note §4.2 step 3). nextDueAt comes from the final duty set, the
100
+ // duties created in this resume included: a future dueAt, or `when` for
101
+ // duty work runnable now that did not run; a duty that ran and stayed
102
+ // (not-due, refused) sets no timer while its record is unchanged since
103
+ // that run. A duty whose kind changed since the plan is not run by the
104
+ // planned handler; the next resume dispatches it. New documents of
105
+ // duties count in too. The duties a handler opens are folded into the
106
+ // state before the next handler runs (an id already open is kept), so
107
+ // later handlers' drop rules and dedupe see them; they run on the next
108
+ // resume (the plan is fixed at the snapshot)
109
+ // - every committed operation but resume names the duty work it opened or
110
+ // unblocked as nextDueAt on its result (ActResult, ReceiveResult
111
+ // unique): `when` where a duty that is new, changed, or newly runnable
112
+ // (its awaited head now holding) can run now, else the earliest future
113
+ // dueAt among those; absent where it opened none. It is the app's cue to
114
+ // call resume (or set a timer), as resume's own nextDueAt is. A journal
115
+ // replay of a human act carries none: its duties were announced then
116
+ // - sweeps (Trust 5.4 reconciliation, PR 4b) run in resume after the
117
+ // duties, in registration order, on the state the duties left; their
118
+ // preparations run against resume's snapshot beside the duties'. A
119
+ // piece whose inputs moved since is skipped and makes `when` the
120
+ // nextDueAt, as does sweep work that became pending during the resume
121
+ // itself (a give-up at the horizon, a duty's change) without having
122
+ // been prepared. Their new documents count into nextDueAt like the
123
+ // duties'. Every other committed operation whose state adds a key to
124
+ // a sweep's `pending` names `when` as its nextDueAt; a sweep that
125
+ // declares the state parts it reads (`reads`) is skipped there when the
126
+ // operation left all of them untouched (by reference)
127
+ // - a verified delivery-ack runs the ack handler registered for the
128
+ // acknowledged item's type in its own transaction; a report never does
129
+ // - an outbox item that turns failed (a failed report, or the holder's
130
+ // own give-up at the horizon inside resume) runs the failure handler
131
+ // registered for its type in that same transaction, once, at the
132
+ // transition; duties it opens are folded into the state there (so two
133
+ // failures of one resume cannot open one duty twice) and count into
134
+ // nextDueAt like any new duty. A late delivered after failed runs the
135
+ // ack handler as usual, never the failure handler again
136
+ // - the Encounter latch exception (receive.ts TaskRefusal, Refusal):
137
+ // a refusal carrying `latch` commits exactly that state (checked to
138
+ // shrink encounter.challenges and touch nothing else) and the raised
139
+ // lastWhen, with no journal entry, cache entry, ack, outbound or event;
140
+ // the result stays the refusal (receive: failed at stage 9)
141
+ // - a task handler's refusal commits nothing; its TaskDiscarded (an
142
+ // assembly discarded at a resource limit or rejected, Trust 5365/5366)
143
+ // commits its state and nothing else: disposition `discarded` with the
144
+ // reason, no cache entry, no ack, no event
145
+ // - a transition's `renames` (Applied) are followed in the effect cache by
146
+ // the engine after the transition: each stored ack's recipient.contactId
147
+ // that a rename names moves to its new id; the envelope, sealed to the
148
+ // key agreement, is untouched (an ack re-sent is byte-identical)
149
+ // - a module's ownRetries are task types it re-issues itself: resume
150
+ // never re-emits their outbox items (the horizon still gives them up);
151
+ // its keepKeys name own rkids tick must not tombstone though no live
152
+ // tuple carries them. Both trust the registered modules: no ownership
153
+ // of a type or a key is checked (modules are the library's own, not
154
+ // app plug-ins)
155
+ // - a failure handler gets the retry backoff, so a failure can make work
156
+ // due no earlier than its schedule allows
157
+ // - the timer contract: tick does the time-driven work (expiry,
158
+ // retention, the modules' `retain`), resume the delivery work (retries,
159
+ // due duties, sweeps, acks). nextDueAt names the earliest of both: on
160
+ // resume, the earliest future deadline of the expiry work (prompt
161
+ // expiresAt, effect expiry, the act journal's, a terminal outbox item's
162
+ // and an address key's retention, every module's `deadlines`); on an
163
+ // act or receive, also a deadline the commit newly introduced. The app
164
+ // sets one timer on it and, when it fires, calls tick and then resume
165
+ // (README "The holder")
166
+ // - after its own retention, tick runs every module's `retain` hook on
167
+ // the result (the trust module drops in-flight star deliveries whose
168
+ // outbox items have all been retained away); the address keys' retention
169
+ // comes last, its live set and keepKeys read on what the hooks left, so
170
+ // a key whose last claim (an act's pin, 8290) left in this tick leaves in
171
+ // the same tick and no passed key deadline outlives it
172
+ // - without an Access port, EngineOptions.accessFrom (createHolder: the
173
+ // membership module's localAccess) builds one over each preparation's
174
+ // own snapshot, so it describes exactly the state the commit lands on
175
+ // - EngineOptions.check (createHolder's `invariants: true`) runs on every
176
+ // commit after the codec's validation and before the CAS; its throw
177
+ // aborts the operation, nothing commits
178
+ // - tick retains with the key-retention max(P90D, horizonMs): effects
179
+ // with their acks at expiresAt; terminal outbox items once the
180
+ // retention has passed since their last step, unless a kept journal
181
+ // entry names them; an address key not shown for the retention and
182
+ // the own rkid of no tuple that is not deactivated becomes a tombstone —
183
+ // except a member key (a group/… label): an identity context of
184
+ // Identity §6.1 rather than a delivery address of Delivery §5 (one
185
+ // derivation per group, reused whenever the holder is invited to it
186
+ // again), it leaves `addresses` without a tombstone (a point for
187
+ // Delivery 0.82: addresses and identity contexts)
188
+ import { jcs, b64uOf } from './core.js';
189
+ import { CODEC, decodeState, encodeState } from './codec.js';
190
+ import { emptyState, toOpaque, triasOf } from './state.js';
191
+ import { CryptoUnavailableError, InfraError } from './results.js';
192
+ import { randomEntropy } from './entropy.js';
193
+ import { ED_PKCS8, X_PKCS8 } from './keys-seed.js';
194
+ import { freezeDeep } from './util-freeze.js';
195
+ import { ACK_TYPE } from './ack.js';
196
+ import { challengeAged } from './encounter.js';
197
+ import { ackOutbound, handlerPrepared, isTaskRefusal, markDelivered, prepareReceive } from './receive.js';
198
+ import { isTaskDiscarded } from './receive.js';
199
+ /** The Encounter latch exception (receive.ts TaskRefusal): a latch state
200
+ * differs from its base in encounter.challenges alone; every challenge it
201
+ * keeps is byte-identical, and every one it removes is aged at `when`, the
202
+ * commit's authoritative time (Encounter 5190, 5260). Anything else is a
203
+ * programming error and throws. */
204
+ function checkLatch(base, latch, when) {
205
+ for (const k of Object.keys(base)) {
206
+ if (k === 'encounter')
207
+ continue;
208
+ if (base[k] !== latch[k])
209
+ throw new Error(`engine: an aging latch changed ${k}`);
210
+ }
211
+ const e = base.encounter, l = latch.encounter;
212
+ for (const k of Object.keys(e)) {
213
+ if (k !== 'challenges' && e[k] !== l[k])
214
+ throw new Error(`engine: an aging latch changed encounter.${k}`);
215
+ }
216
+ for (const [v, c] of Object.entries(l.challenges)) {
217
+ if (!Object.hasOwn(e.challenges, v))
218
+ throw new Error('engine: an aging latch added a challenge');
219
+ if (jcs(c) !== jcs(e.challenges[v]))
220
+ throw new Error(`engine: an aging latch changed the challenge ${v}`);
221
+ }
222
+ for (const [v, c] of Object.entries(e.challenges)) {
223
+ if (!Object.hasOwn(l.challenges, v) && !challengeAged(c.tCh, when))
224
+ throw new Error(`engine: an aging latch removed the unaged challenge ${v}`);
225
+ }
226
+ }
227
+ // ── defaults ───────────────────────────────────────────────────────────
228
+ const DAY = 86_400_000;
229
+ /** Defaults of the retry policy and the retention it implies. */
230
+ export const ENGINE_DEFAULTS = Object.freeze({
231
+ /** 30 s, doubling, at most 6 h */
232
+ backoff: (attempt) => Math.min(30_000 * 2 ** Math.max(0, attempt - 1), 6 * 3_600_000),
233
+ /** P7D, inside Delivery's give-up range [P1D, P90D]; the only give-up */
234
+ horizonMs: 7 * DAY,
235
+ commitAttempts: 8,
236
+ /** max(P90D, horizonMs) with the default horizon */
237
+ retentionMs: 90 * DAY,
238
+ });
239
+ /** Delivery's give-up range for horizonMs: [P1D, P90D]. */
240
+ const HORIZON_MIN = DAY;
241
+ const HORIZON_MAX = 90 * DAY;
242
+ const BUILTIN = ['tick', 'resume', 'report', 'receive'];
243
+ const TASK_URI = /^[a-z][a-z0-9+.-]*:\S+\/[0-9]+\.[0-9]+$/;
244
+ // ── helpers ────────────────────────────────────────────────────────────
245
+ const ALGORITHMS = ['Ed25519', 'X25519', 'HKDF', 'HMAC', 'AES-GCM', 'SHA-256'];
246
+ const concat = (a, b) => { const o = new Uint8Array(a.length + b.length); o.set(a); o.set(b, a.length); return o; };
247
+ /** Resolves the WebCrypto handle and probes every algorithm the library uses. */
248
+ async function resolveCrypto(c) {
249
+ const h = c ?? globalThis.crypto;
250
+ const S = h?.subtle;
251
+ if (!S || typeof S.importKey !== 'function' || typeof S.digest !== 'function')
252
+ throw new CryptoUnavailableError(['subtle', ...ALGORITHMS]);
253
+ const k = new Uint8Array(32).fill(1);
254
+ const probes = {
255
+ Ed25519: () => S.importKey('pkcs8', concat(ED_PKCS8, k), { name: 'Ed25519' }, false, ['sign']),
256
+ X25519: () => S.importKey('pkcs8', concat(X_PKCS8, k), { name: 'X25519' }, false, ['deriveBits']),
257
+ HKDF: () => S.importKey('raw', k, 'HKDF', false, ['deriveBits']),
258
+ HMAC: () => S.importKey('raw', k, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']),
259
+ 'AES-GCM': () => S.importKey('raw', k, { name: 'AES-GCM' }, false, ['encrypt']),
260
+ 'SHA-256': () => S.digest('SHA-256', k),
261
+ };
262
+ const ok = await Promise.all(ALGORITHMS.map(async (a) => { try {
263
+ await probes[a]();
264
+ return true;
265
+ }
266
+ catch {
267
+ return false;
268
+ } }));
269
+ const missing = ALGORITHMS.filter((_, i) => !ok[i]);
270
+ if (missing.length)
271
+ throw new CryptoUnavailableError(missing);
272
+ return h;
273
+ }
274
+ const te = new TextEncoder();
275
+ /** The stack's document digest (u + multihash sha2-256 over JCS), on the resolved handle. */
276
+ const digestOf = async (S, x) => 'u' + b64uOf(concat(Uint8Array.from([0x12, 0x20]), new Uint8Array(await S.digest('SHA-256', te.encode(jcs(x))))));
277
+ /** Drops undefined object members (as JSON does) without hiding what jcs refuses. */
278
+ const dropUndefined = (x) => {
279
+ if (x === null || typeof x !== 'object')
280
+ return x;
281
+ if (Object.getOwnPropertySymbols(x).length > 0)
282
+ return x;
283
+ if (Array.isArray(x)) {
284
+ if (Object.getOwnPropertyNames(x).length !== x.length + 1)
285
+ return x;
286
+ for (let i = 0; i < x.length; i++)
287
+ if (!(i in x))
288
+ return x;
289
+ return x.map(dropUndefined);
290
+ }
291
+ const proto = Object.getPrototypeOf(x);
292
+ if (proto !== null && proto !== Object.prototype)
293
+ return x;
294
+ if (Object.getOwnPropertyNames(x).length !== Object.keys(x).length)
295
+ return x;
296
+ // own data properties, never assignment: a "__proto__" key stays a key
297
+ // (assignment would hit the inherited setter and change the prototype)
298
+ const out = {};
299
+ for (const [k, v] of Object.entries(x)) {
300
+ if (v !== undefined)
301
+ Object.defineProperty(out, k, { value: dropUndefined(v), enumerable: true, writable: true, configurable: true });
302
+ }
303
+ return out;
304
+ };
305
+ /** The act input as bound at the call: canonical JSON, deep-frozen. A
306
+ * "__proto__" member is an ordinary key throughout: jcs serializes own
307
+ * keys (Object.keys) and JSON.parse defines own data properties, so it is
308
+ * hashed, journaled and handed to prepare/apply like any other key. */
309
+ const bindInput = (x) => freezeDeep(JSON.parse(jcs(x === undefined ? null : dropUndefined(x))));
310
+ /** when + ceil(delay), clamped to safe integer milliseconds. */
311
+ const dueAt = (when, delay) => {
312
+ const d = Number.isNaN(delay) ? 0 : Math.max(0, Math.ceil(delay));
313
+ return Math.min(when + d, Number.MAX_SAFE_INTEGER);
314
+ };
315
+ /** A port whose rejections become InfraError(code); InfraError passes as is. */
316
+ function guard(port, names, code, wrapResult) {
317
+ const out = {};
318
+ for (const n of names) {
319
+ out[n] = async (...args) => {
320
+ let r;
321
+ try {
322
+ r = await port[n].apply(port, args);
323
+ }
324
+ catch (e) {
325
+ throw e instanceof InfraError ? e : new InfraError(code, `${code}: ${n} failed`, { cause: e });
326
+ }
327
+ return wrapResult ? wrapResult(r) : r;
328
+ };
329
+ }
330
+ return Object.freeze(out);
331
+ }
332
+ const inc = (d) => (BigInt(d) + 1n).toString();
333
+ const releaseOf = (state) => (rkid) => {
334
+ if (state.addresses.tombstones.includes(rkid))
335
+ return { status: 'retired' };
336
+ if (!Object.hasOwn(state.addresses.keys, rkid))
337
+ return { status: 'unknown' };
338
+ const k = state.addresses.keys[rkid];
339
+ return k.retiredAt !== undefined ? { status: 'retired' } : { status: 'live', label: k.label };
340
+ };
341
+ const isRefusal = (r) => r?.ok === false;
342
+ /** The effect cache with the stored acks' recipients renamed. */
343
+ function renameAcks(effects, renames) {
344
+ let out = null;
345
+ for (const [d, e] of Object.entries(effects)) {
346
+ let a = e.ack;
347
+ if (!a)
348
+ continue;
349
+ for (const r of renames) {
350
+ if (r.from.includes(a.recipient.contactId) && (r.rkids === null || r.rkids.includes(a.recipient.via ?? a.recipient.rkid)))
351
+ a = { ...a, recipient: { ...a.recipient, contactId: r.to } };
352
+ }
353
+ if (a !== e.ack)
354
+ (out ??= { ...effects })[d] = { ...e, ack: a };
355
+ }
356
+ return out ?? effects;
357
+ }
358
+ const checkWhen = (when) => {
359
+ if (!Number.isSafeInteger(when) || when < 0)
360
+ throw new TypeError(`when must be non-negative integer milliseconds, got ${String(when)}`);
361
+ };
362
+ /** A duty's dueAt; absent counts as 0 (due always). */
363
+ const dutyDue = (d) => (Number.isSafeInteger(d.dueAt) ? d.dueAt : 0);
364
+ /** Due, and not waiting on a head its handler's awaitHolds denies (none: waits forever). */
365
+ const dutyRunnable = (d, h, state, when) => {
366
+ if (dutyDue(d) > when)
367
+ return false;
368
+ const awaits = d.awaitHead;
369
+ return typeof awaits !== 'string' || (h.awaitHolds?.(state, d) ?? false);
370
+ };
371
+ const isHalt = (r) => r?.halt === true;
372
+ const actResult = (r) => {
373
+ if (isHalt(r))
374
+ throw new Error('engine: an act does not halt');
375
+ return r.ok ? { ok: true, outbound: r.outbound, events: r.events, ...(r.nextDueAt !== undefined ? { nextDueAt: r.nextDueAt } : {}) } : r;
376
+ };
377
+ // ── the engine ─────────────────────────────────────────────────────────
378
+ class HolderEngine {
379
+ registry;
380
+ #store;
381
+ #ports;
382
+ /** the ports a preparation sees, rejections typed (InfraError) */
383
+ #keys;
384
+ #log;
385
+ #access;
386
+ #crypto;
387
+ #entropy;
388
+ #policy;
389
+ #commands;
390
+ #tasks;
391
+ #duties;
392
+ #sweeps;
393
+ #acks;
394
+ #failures;
395
+ #ownRetries;
396
+ #keepKeys;
397
+ #retain;
398
+ #accepted;
399
+ #deadlines;
400
+ #builtins;
401
+ #check;
402
+ #accessFrom;
403
+ #listeners = new Set();
404
+ #chain = Promise.resolve();
405
+ /** a commit whose outcome is unknown, settled before the next operation */
406
+ #unsettled = null;
407
+ constructor(ports, crypto, modules, opts = {}) {
408
+ if (opts.check !== undefined && typeof opts.check !== 'function')
409
+ throw new TypeError('check must be a function');
410
+ this.#check = opts.check;
411
+ if (opts.accessFrom !== undefined && typeof opts.accessFrom !== 'function')
412
+ throw new TypeError('accessFrom must be a function');
413
+ this.#accessFrom = opts.accessFrom;
414
+ this.#store = ports.store;
415
+ this.#ports = ports;
416
+ this.#crypto = crypto;
417
+ this.#entropy = ports.entropy ?? randomEntropy(crypto);
418
+ const r = ports.retry ?? {};
419
+ const horizonMs = r.horizonMs ?? ENGINE_DEFAULTS.horizonMs;
420
+ const backoff = r.backoff ?? ENGINE_DEFAULTS.backoff;
421
+ const commitAttempts = r.commitAttempts ?? ENGINE_DEFAULTS.commitAttempts;
422
+ if (!Number.isSafeInteger(horizonMs) || horizonMs < HORIZON_MIN || horizonMs > HORIZON_MAX)
423
+ throw new RangeError(`retry.horizonMs must be integer ms within [P1D, P90D], got ${horizonMs}`);
424
+ if (typeof backoff !== 'function')
425
+ throw new RangeError('retry.backoff must be a function');
426
+ for (let n = 1; n <= 3; n++) {
427
+ const d = backoff(n);
428
+ if (typeof d !== 'number' || !Number.isFinite(d) || d < 0)
429
+ throw new RangeError(`retry.backoff(${n}) must be a finite non-negative number, got ${d}`);
430
+ }
431
+ if (!Number.isSafeInteger(commitAttempts) || commitAttempts < 1)
432
+ throw new RangeError('retry.commitAttempts must be a positive integer');
433
+ this.#policy = Object.freeze({ backoff, horizonMs, commitAttempts, retentionMs: Math.max(ENGINE_DEFAULTS.retentionMs, horizonMs) });
434
+ const carrierKey = (k) => Object.freeze({ principal: k.principal, sign: guard(k, ['sign'], 'key-provider').sign });
435
+ const base = guard(ports.keys, ['anchorOf', 'sign', 'agree'], 'key-provider');
436
+ this.#keys = Object.freeze({
437
+ anchorOf: base.anchorOf, sign: base.sign, agree: base.agree,
438
+ carrierPrincipal: guard(ports.keys, ['carrierPrincipal'], 'key-provider', carrierKey).carrierPrincipal,
439
+ });
440
+ if (ports.log)
441
+ this.#log = guard(ports.log, ['head', 'at', 'append'], 'log');
442
+ if (ports.access)
443
+ this.#access = guard(ports.access, ['memberships', 'membership'], 'access');
444
+ const commands = new Map();
445
+ const tasks = new Map();
446
+ const duties = new Map();
447
+ const sweeps = new Map();
448
+ const acks = new Map();
449
+ const failures = new Map();
450
+ const ownRetries = new Set();
451
+ const keepKeys = [];
452
+ const retain = [];
453
+ const accepted = [];
454
+ const deadlines = [];
455
+ const names = new Set();
456
+ for (const m of modules) {
457
+ if (!m || typeof m.name !== 'string' || !m.name)
458
+ throw new TypeError('a module needs a name');
459
+ if (names.has(m.name))
460
+ throw new Error(`duplicate module ${m.name}`);
461
+ names.add(m.name);
462
+ for (const [name, def] of Object.entries(m.commands ?? {})) {
463
+ if (commands.has(name) || BUILTIN.includes(name))
464
+ throw new Error(`duplicate command ${name} (module ${m.name})`);
465
+ if (typeof def?.apply !== 'function' || typeof def.human !== 'boolean')
466
+ throw new TypeError(`command ${name} (module ${m.name}) needs human and apply`);
467
+ commands.set(name, Object.freeze({ ...def }));
468
+ }
469
+ for (const [uri, h] of Object.entries(m.tasks ?? {})) {
470
+ if (!TASK_URI.test(uri))
471
+ throw new Error(`not a full task URI with version: ${uri} (module ${m.name})`);
472
+ if (uri === ACK_TYPE)
473
+ throw new Error(`${uri} is built in: acks take the common path (module ${m.name})`);
474
+ if (tasks.has(uri))
475
+ throw new Error(`duplicate task ${uri} (module ${m.name})`);
476
+ if (typeof h?.apply !== 'function' || typeof h.decode !== 'function')
477
+ throw new TypeError(`task ${uri} (module ${m.name}) needs decode and apply`);
478
+ if (h.proof !== 'absent' && h.proof !== 'required')
479
+ throw new TypeError(`task ${uri} (module ${m.name}) needs its proof declaration: 'absent' or 'required'`);
480
+ if (h.channel !== undefined && typeof h.channel !== 'function')
481
+ throw new TypeError(`task ${uri} (module ${m.name}): channel must be a function`);
482
+ if (h.pinned !== undefined && (h.pinned !== true || h.channel !== undefined))
483
+ throw new TypeError(`task ${uri} (module ${m.name}): pinned is true or absent, and only under the tuple binding`);
484
+ tasks.set(uri, Object.freeze({ ...h }));
485
+ }
486
+ for (const [kind, h] of Object.entries(m.duties ?? {})) {
487
+ if (duties.has(kind))
488
+ throw new Error(`duplicate duty ${kind} (module ${m.name})`);
489
+ if (typeof h?.apply !== 'function')
490
+ throw new TypeError(`duty ${kind} (module ${m.name}) needs apply`);
491
+ if (!Number.isSafeInteger(h.v) || h.v < 1)
492
+ throw new TypeError(`duty ${kind} (module ${m.name}) needs its version v, a positive integer`);
493
+ if (h.awaitHolds !== undefined && typeof h.awaitHolds !== 'function')
494
+ throw new TypeError(`duty ${kind} (module ${m.name}): awaitHolds must be a function`);
495
+ duties.set(kind, Object.freeze({ ...h }));
496
+ }
497
+ for (const [name, h] of Object.entries(m.sweeps ?? {})) {
498
+ if (sweeps.has(name))
499
+ throw new Error(`duplicate sweep ${name} (module ${m.name})`);
500
+ if (typeof h?.apply !== 'function' || typeof h.prepare !== 'function' || typeof h.pending !== 'function')
501
+ throw new TypeError(`sweep ${name} (module ${m.name}) needs pending, prepare and apply`);
502
+ sweeps.set(name, Object.freeze({ ...h }));
503
+ }
504
+ for (const [uri, h] of Object.entries(m.acks ?? {})) {
505
+ if (!TASK_URI.test(uri))
506
+ throw new Error(`not a full task URI with version: ${uri} (module ${m.name})`);
507
+ if (acks.has(uri))
508
+ throw new Error(`duplicate ack handler ${uri} (module ${m.name})`);
509
+ if (typeof h !== 'function')
510
+ throw new TypeError(`ack handler ${uri} (module ${m.name}) must be a function`);
511
+ acks.set(uri, h);
512
+ }
513
+ for (const [uri, h] of Object.entries(m.failures ?? {})) {
514
+ if (!TASK_URI.test(uri))
515
+ throw new Error(`not a full task URI with version: ${uri} (module ${m.name})`);
516
+ if (failures.has(uri))
517
+ throw new Error(`duplicate failure handler ${uri} (module ${m.name})`);
518
+ if (typeof h !== 'function')
519
+ throw new TypeError(`failure handler ${uri} (module ${m.name}) must be a function`);
520
+ failures.set(uri, h);
521
+ }
522
+ for (const uri of m.ownRetries ?? []) {
523
+ if (!TASK_URI.test(uri))
524
+ throw new Error(`not a full task URI with version: ${uri} (module ${m.name})`);
525
+ ownRetries.add(uri);
526
+ }
527
+ if (m.keepKeys !== undefined) {
528
+ if (typeof m.keepKeys !== 'function')
529
+ throw new TypeError(`keepKeys (module ${m.name}) must be a function`);
530
+ keepKeys.push(m.keepKeys);
531
+ }
532
+ if (m.retain !== undefined) {
533
+ if (typeof m.retain !== 'function')
534
+ throw new TypeError(`retain (module ${m.name}) must be a function`);
535
+ retain.push(m.retain);
536
+ }
537
+ if (m.deadlines !== undefined) {
538
+ if (typeof m.deadlines !== 'function')
539
+ throw new TypeError(`deadlines (module ${m.name}) must be a function`);
540
+ deadlines.push(m.deadlines);
541
+ }
542
+ if (m.accepted !== undefined) {
543
+ if (typeof m.accepted !== 'function')
544
+ throw new TypeError(`accepted (module ${m.name}) must be a function`);
545
+ accepted.push(m.accepted);
546
+ }
547
+ }
548
+ this.#commands = commands;
549
+ this.#tasks = tasks;
550
+ this.#duties = duties;
551
+ this.#sweeps = sweeps;
552
+ this.#acks = acks;
553
+ this.#failures = failures;
554
+ this.#ownRetries = ownRetries;
555
+ this.#keepKeys = Object.freeze(keepKeys);
556
+ this.#deadlines = Object.freeze(deadlines);
557
+ this.#retain = Object.freeze(retain);
558
+ this.#accepted = Object.freeze(accepted);
559
+ this.#builtins = Object.freeze({ tick: this.#tickDef(), resume: this.#resumeDef(), report: this.#reportDef(), receive: this.#receiveDef() });
560
+ this.registry = Object.freeze({
561
+ commands: Object.freeze([...commands.keys()]),
562
+ tasks: Object.freeze([...tasks.keys()]),
563
+ duties: Object.freeze([...duties.keys()]),
564
+ sweeps: Object.freeze([...sweeps.keys()]),
565
+ });
566
+ }
567
+ // ── loading ──────────────────────────────────────────────────────────
568
+ async #readRaw() {
569
+ try {
570
+ return await this.#store.read();
571
+ }
572
+ catch (e) {
573
+ throw new InfraError('store-read', undefined, { cause: e });
574
+ }
575
+ }
576
+ async #read() {
577
+ const snap = await this.#readRaw();
578
+ if (snap.state === null)
579
+ throw new Error('engine: the store holds no state');
580
+ return { state: freezeDeep(decodeState(snap.state)), version: snap.version };
581
+ }
582
+ /** Initialise an empty store with the empty state, or check the stored one decodes. */
583
+ async load() {
584
+ const snap = await this.#readRaw();
585
+ if (snap.state !== null) {
586
+ decodeState(snap.state);
587
+ return;
588
+ }
589
+ let cause;
590
+ try {
591
+ if ((await this.#store.commit(null, toOpaque(emptyState(CODEC)))).ok)
592
+ return;
593
+ }
594
+ catch (e) {
595
+ cause = e;
596
+ }
597
+ const again = await this.#readRaw(); // conflict or unknown outcome: someone (maybe we) wrote
598
+ if (again.state === null)
599
+ throw new InfraError('store-commit', 'could not initialise the store', { cause });
600
+ decodeState(again.state);
601
+ }
602
+ // ── the queue ────────────────────────────────────────────────────────
603
+ #enqueue(fn) {
604
+ const p = this.#chain.then(async () => { await this.#settle(); return fn(); });
605
+ this.#chain = p.then(() => undefined, () => undefined);
606
+ return p;
607
+ }
608
+ async #settle() {
609
+ const u = this.#unsettled;
610
+ if (!u)
611
+ return;
612
+ const { state } = await this.#read();
613
+ const landed = jcs(state) === u.text ||
614
+ (u.actId !== undefined && Object.hasOwn(state.acts, u.actId) && state.acts[u.actId].commitSeq === u.commitSeq);
615
+ this.#unsettled = null;
616
+ if (landed)
617
+ this.#announce(u.commitSeq, u.events);
618
+ }
619
+ #announce(commitSeq, events) {
620
+ for (const l of [...this.#listeners]) {
621
+ // a listener's failure (throw or rejection) neither undoes nor blocks the commit
622
+ try {
623
+ const r = l({ commitSeq, events });
624
+ if (r !== null && typeof r === 'object' && typeof r.then === 'function')
625
+ r.then(undefined, () => undefined);
626
+ }
627
+ catch { /* dropped */ }
628
+ }
629
+ }
630
+ // ── the four steps ───────────────────────────────────────────────────
631
+ /** `input` is already bound (bindInput); `inputHash` its journal hash for human acts. */
632
+ async #run(def, op, input, when, actId, inputHash) {
633
+ /** a replay's give-up work (below): its timer, carried to the replay's answer */
634
+ let replayWake;
635
+ /** give-ups a replay ran: each moves at least one of its finitely many
636
+ * journaled documents to failed, so they are bounded by that number */
637
+ let gaveUp = 0;
638
+ for (let attempt = 1; attempt <= this.#policy.commitAttempts; attempt++) {
639
+ // 1 — snapshot, when
640
+ const { state, version } = await this.#read();
641
+ const at = Math.max(when, state.lastWhen);
642
+ if (def.human && Object.hasOwn(state.acts, actId)) {
643
+ const rec = state.acts[actId];
644
+ if (rec.inputHash !== inputHash)
645
+ return { ok: false, code: 'actid-conflict' };
646
+ const events = structuredClone(rec.events);
647
+ // the act's documents are handed out again only while live; one past
648
+ // its sender lifetime or the horizon is given up first, in an
649
+ // operation of its own on the engine's one commit path (as resume's
650
+ // give-up: Delivery 6110, 6115), and is never handed out — the
651
+ // journal's events are not announced again
652
+ const live = [];
653
+ const expired = [];
654
+ for (const d of rec.outboundDigests) {
655
+ const it = this.#itemOf(state, d);
656
+ const t = triasOf(it);
657
+ if (t === 'delivered' || t === 'failed')
658
+ continue;
659
+ if (this.#giveUp(it, at))
660
+ expired.push(d);
661
+ else
662
+ live.push(d);
663
+ }
664
+ if (expired.length) {
665
+ const g = await this.#run(this.#giveUpDef(), { kind: 'resume' }, expired, at);
666
+ if (!isHalt(g) && g.ok && g.nextDueAt !== undefined)
667
+ replayWake = replayWake === undefined ? g.nextDueAt : Math.min(replayWake, g.nextDueAt);
668
+ // re-read: the act's documents as the give-up left them, outside
669
+ // the CAS budget (no conflict happened); bounded by the act's documents
670
+ if (gaveUp++ <= rec.outboundDigests.length)
671
+ attempt--;
672
+ continue;
673
+ }
674
+ return { ok: true, outbound: live.map((d) => this.#outboundOf(state, d)), events: freezeDeep(events), ...(replayWake !== undefined ? { nextDueAt: replayWake } : {}) };
675
+ }
676
+ // 2 — prepare, bound to this snapshot
677
+ const ctx = Object.freeze({
678
+ snapshot: state, keys: this.#keys, entropy: this.#entropy, access: this.#access ?? this.#accessFrom?.(state), log: this.#log,
679
+ crypto: this.#crypto, when: at, release: releaseOf(state),
680
+ });
681
+ const value = def.prepare ? await def.prepare(ctx, input) : undefined;
682
+ const prepared = Object.freeze({ value, version, commitSeq: state.commitSeq });
683
+ // 3 — apply, assemble, CAS
684
+ const r = def.apply(state, prepared, input, at);
685
+ if (isHalt(r))
686
+ return r;
687
+ if (isRefusal(r)) {
688
+ const refusal = { ok: false, code: r.code, ...(r.ref !== undefined ? { ref: r.ref } : {}) };
689
+ if (!r.latch)
690
+ return refusal;
691
+ // the Encounter latch exception: the latch and lastWhen commit, nothing else
692
+ checkLatch(state, r.latch, at);
693
+ const seq = inc(state.commitSeq);
694
+ const next = { ...r.latch, commitSeq: seq, lastWhen: at };
695
+ const text = encodeState(next);
696
+ if (this.#check)
697
+ this.#check(state, next, op.kind === 'act' ? { ...op, events: [] } : op);
698
+ let res;
699
+ try {
700
+ res = await this.#store.commit(version, toOpaque(next));
701
+ }
702
+ catch (e) {
703
+ this.#unsettled = { text, commitSeq: seq, events: [] };
704
+ throw new InfraError('unknown-commit-outcome', undefined, { cause: e });
705
+ }
706
+ if (!res.ok)
707
+ continue;
708
+ this.#announce(seq, []);
709
+ return refusal;
710
+ }
711
+ const drafts = r.outbound ?? [];
712
+ const eventDrafts = r.events ?? [];
713
+ const newDuties = r.duties ?? [];
714
+ const emit = r.emit ?? [];
715
+ const release = r.release ?? [];
716
+ // resume computes its own timer; every other operation names the duty
717
+ // work it opened or unblocked, the app's cue to call resume
718
+ const wake = r.nextDueAt ?? (def === this.#builtins.resume ? undefined : this.#wake(state, r.state, newDuties, at));
719
+ const extra = { ...(wake !== undefined ? { nextDueAt: wake } : {}), ...(r.detail !== undefined ? { detail: r.detail } : {}) };
720
+ if (!def.human && r.state === state && !r.renames?.length && at === state.lastWhen && !drafts.length && !eventDrafts.length && !newDuties.length && !emit.length) {
721
+ return { ok: true, outbound: [...release], events: [], ...extra };
722
+ }
723
+ const seq = inc(state.commitSeq);
724
+ const events = eventDrafts.map((e, i) => ({ ...e, eventId: `${seq}.${i}`, commitSeq: seq, ...(def.human ? { actId } : {}) }));
725
+ freezeDeep(events);
726
+ const outbound = drafts.map((o) => ({ digest: o.digest, envelope: o.envelope, type: o.type, threadId: o.threadId, recipient: o.recipient, attempt: 1 }));
727
+ const outbox = { ...r.state.outbox };
728
+ for (const [i, o] of outbound.entries()) {
729
+ if (Object.hasOwn(outbox, o.digest))
730
+ throw new Error(`engine: outbound ${o.digest} is already in the outbox`);
731
+ const life = drafts[i].expiresAt;
732
+ if (life !== undefined && (!Number.isSafeInteger(life) || life <= at))
733
+ throw new Error(`engine: outbound ${o.digest} expires at ${life}, not after ${at}`);
734
+ outbox[o.digest] = {
735
+ type: o.type, threadId: o.threadId, recipient: o.recipient, envelope: o.envelope, attempt: 1,
736
+ history: [{ status: 'submitted', at, attempt: 1 }], nextAttemptAt: dueAt(at, this.#policy.backoff(1)),
737
+ ...(life !== undefined ? { expiresAt: life } : {}),
738
+ };
739
+ }
740
+ const duties = { ...r.state.duties };
741
+ for (const d of newDuties) {
742
+ if (Object.hasOwn(duties, d.id))
743
+ throw new Error(`engine: duty ${d.id} already exists`);
744
+ duties[d.id] = d;
745
+ }
746
+ const acts = def.human
747
+ ? { ...r.state.acts, [actId]: { actId: actId, inputHash: inputHash, commitSeq: seq, outboundDigests: outbound.map((o) => o.digest), events, at } }
748
+ : r.state.acts;
749
+ const effects = r.renames?.length ? renameAcks(r.state.effects, r.renames) : r.state.effects;
750
+ const next = { ...r.state, effects, commitSeq: seq, lastWhen: at, outbox, duties, acts };
751
+ const text = encodeState(next); // validates; an invalid next state is a programming error
752
+ if (this.#check) {
753
+ const d = (r.detail ?? {});
754
+ const pick = (k) => (d[k] !== undefined ? { [k]: d[k] } : {});
755
+ this.#check(state, next, op.kind === 'receive' ? { kind: 'receive', ...pick('disposition'), ...pick('digest'), ...pick('type'), ...pick('ref'), ...pick('rkid') }
756
+ : op.kind === 'resume' ? { kind: 'resume', fulfilled: d.fulfilled ?? [] }
757
+ : op.kind === 'act' ? { ...op, events } : op);
758
+ }
759
+ let res;
760
+ try {
761
+ res = await this.#store.commit(version, toOpaque(next));
762
+ }
763
+ catch (e) {
764
+ this.#unsettled = { text, commitSeq: seq, events, ...(def.human ? { actId } : {}) };
765
+ throw new InfraError('unknown-commit-outcome', undefined, { cause: e });
766
+ }
767
+ if (!res.ok)
768
+ continue;
769
+ // 4 — release
770
+ this.#announce(seq, events);
771
+ return { ok: true, outbound: [...emit, ...release, ...outbound], events, ...extra };
772
+ }
773
+ throw new InfraError('commit-conflict', `no commit after ${this.#policy.commitAttempts} attempts`);
774
+ }
775
+ /** The work an operation opened or unblocked: its duty work (#dutyWake),
776
+ * or `when` where a sweep's pending keys gained one. */
777
+ /** Every time at which tick's expiry and retention change `state`: the
778
+ * engine's own and the modules' `deadlines` (the timer contract). */
779
+ #expiries(state) {
780
+ const retention = this.#policy.retentionMs;
781
+ const out = [];
782
+ for (const p of Object.values(state.prompts))
783
+ if (p.expiresAt !== undefined)
784
+ out.push(p.expiresAt);
785
+ for (const e of Object.values(state.effects))
786
+ out.push(e.expiresAt);
787
+ for (const a of Object.values(state.acts))
788
+ out.push(a.at + retention);
789
+ for (const it of Object.values(state.outbox)) {
790
+ const t = triasOf(it);
791
+ if ((t === 'delivered' || t === 'failed') && it.history.length)
792
+ out.push(it.history[it.history.length - 1].at + retention);
793
+ }
794
+ for (const k of Object.values(state.addresses.keys))
795
+ out.push(k.lastShown + retention);
796
+ for (const f of this.#deadlines)
797
+ for (const t of f(state))
798
+ out.push(t);
799
+ return out.filter((t) => Number.isSafeInteger(t));
800
+ }
801
+ /** The earliest expiry deadline after `when`, among those `only` admits. */
802
+ #nextExpiry(state, when, only = () => true) {
803
+ let next;
804
+ for (const t of this.#expiries(state))
805
+ if (t > when && only(t) && (next === undefined || t < next))
806
+ next = t;
807
+ return next;
808
+ }
809
+ #wake(before, after, added, when) {
810
+ const d0 = this.#dutyWake(before, after, added, when);
811
+ // a deadline the commit introduced (a prompt opened, an act started, …)
812
+ const had = before === after ? null : new Set(this.#expiries(before));
813
+ const x = had === null ? undefined : this.#nextExpiry(after, when, (t) => !had.has(t));
814
+ const d = d0 === undefined ? x : x === undefined ? d0 : Math.min(d0, x);
815
+ if (d === when || before === after)
816
+ return d;
817
+ for (const sw of this.#sweeps.values()) {
818
+ // a sweep whose declared inputs are untouched (by reference) cannot have new pending keys
819
+ if (sw.reads && sw.reads.every((k) => before[k] === after[k]))
820
+ continue;
821
+ const had = new Set(sw.pending(before, when));
822
+ if (sw.pending(after, when).some((k) => !had.has(k)))
823
+ return when;
824
+ }
825
+ return d;
826
+ }
827
+ /** The duty work an operation opened or unblocked: over the duties that
828
+ * are new, changed, or runnable now but not before, `when` where one
829
+ * is runnable now, else the earliest future dueAt; undefined where none. */
830
+ #dutyWake(before, after, added, when) {
831
+ let wake;
832
+ for (const d of [...Object.values(after.duties), ...added]) {
833
+ const h = this.#duties.get(d.kind);
834
+ if (!h || d.v !== h.v)
835
+ continue;
836
+ const prev = Object.hasOwn(before.duties, d.id) ? before.duties[d.id] : undefined;
837
+ const runnable = dutyRunnable(d, h, after, when);
838
+ if (prev !== undefined && jcs(prev) === jcs(d) && (!runnable || dutyRunnable(prev, h, before, when)))
839
+ continue;
840
+ const t = runnable ? when : dutyDue(d) > when ? dutyDue(d) : undefined;
841
+ if (t !== undefined)
842
+ wake = wake === undefined ? t : Math.min(wake, t);
843
+ }
844
+ return wake;
845
+ }
846
+ /** The failure handlers of the items `digests` (each just turned
847
+ * failed), in order, on `state`; their duties folded into the state. */
848
+ #failed(state, digests, when) {
849
+ let cur = state;
850
+ const events = [];
851
+ for (const digest of digests) {
852
+ const h = this.#failures.get(cur.outbox[digest].type);
853
+ if (!h)
854
+ continue;
855
+ const r = h(cur, digest, when, { backoff: this.#policy.backoff });
856
+ if (r.state.effects !== cur.effects)
857
+ throw new Error(`engine: the failure handler of ${cur.outbox[digest].type} wrote the effect cache`);
858
+ if (r.outbound?.length)
859
+ throw new Error(`engine: the failure handler of ${cur.outbox[digest].type} issued documents; it opens duties`);
860
+ cur = r.state;
861
+ events.push(...(r.events ?? []));
862
+ if (r.duties?.length) {
863
+ const duties = { ...cur.duties };
864
+ for (const d of r.duties)
865
+ if (!Object.hasOwn(duties, d.id))
866
+ duties[d.id] = d;
867
+ cur = { ...cur, duties };
868
+ }
869
+ }
870
+ return { state: cur, events };
871
+ }
872
+ /** The give-up of the outbox items named (a replay found them due, see
873
+ * #run): as resume's, the failed step, the status event, the failure
874
+ * handlers in the same transaction; items no longer due are left alone. */
875
+ #giveUpDef() {
876
+ const giveUp = (it, when) => this.#giveUp(it, when);
877
+ const failed = (st, digests, when) => this.#failed(st, digests, when);
878
+ return {
879
+ human: false,
880
+ apply(state, _p, digests, when) {
881
+ const outbox = { ...state.outbox };
882
+ const events = [];
883
+ const gave = [];
884
+ for (const d of digests) {
885
+ const it = state.outbox[d];
886
+ const t = triasOf(it);
887
+ if (t === 'delivered' || t === 'failed')
888
+ continue;
889
+ const why = giveUp(it, when);
890
+ if (!why)
891
+ continue;
892
+ const { nextAttemptAt: _, ...rest } = it;
893
+ outbox[d] = { ...rest, history: [...rest.history, { status: 'failed', at: when, reason: why }] };
894
+ events.push({ type: 'delivery.status', digest: d, threadId: it.threadId, status: 'failed' });
895
+ gave.push(d);
896
+ }
897
+ if (!gave.length)
898
+ return { state: state };
899
+ const f = failed({ ...state, outbox }, gave, when);
900
+ return { state: f.state, events: [...events, ...f.events] };
901
+ },
902
+ };
903
+ }
904
+ /** Why a non-terminal outbox item is given up at `when`, or null: its
905
+ * sender lifetime, where it ends no later than the horizon
906
+ * (failed(task-expired), Delivery 6110), else the horizon
907
+ * (failed(expired-by-adapter-policy)). */
908
+ #giveUp(it, when) {
909
+ const first = it.history.length ? it.history[0].at : when;
910
+ const expiry = Math.min(first + this.#policy.horizonMs, Number.MAX_SAFE_INTEGER);
911
+ const life = it.expiresAt;
912
+ if (life !== undefined && when >= life && life <= expiry)
913
+ return 'task-expired';
914
+ return when >= expiry ? 'expired-by-adapter-policy' : null;
915
+ }
916
+ #itemOf(state, digest) {
917
+ if (!Object.hasOwn(state.outbox, digest))
918
+ throw new Error(`engine: journaled outbound ${digest} is missing from the outbox`);
919
+ return state.outbox[digest];
920
+ }
921
+ #outboundOf(state, digest) {
922
+ const it = this.#itemOf(state, digest);
923
+ return structuredClone({ digest, envelope: it.envelope, type: it.type, threadId: it.threadId, recipient: it.recipient, attempt: it.attempt });
924
+ }
925
+ // ── built-ins ────────────────────────────────────────────────────────
926
+ #tickDef() {
927
+ const policy = () => this.#policy;
928
+ const retainers = () => this.#retain;
929
+ const keepers = () => this.#keepKeys;
930
+ return {
931
+ human: false,
932
+ apply(state, _p, _input, when) {
933
+ const retention = policy().retentionMs;
934
+ let dropped = false;
935
+ const keep = (rec, live) => {
936
+ const out = {};
937
+ for (const [k, v] of Object.entries(rec)) {
938
+ if (live(v, k))
939
+ out[k] = v;
940
+ else
941
+ dropped = true;
942
+ }
943
+ return out;
944
+ };
945
+ // effects leave with their acks (Delivery 4.2); prompts at expiry; the
946
+ // act journal after the retention
947
+ const effects = keep(state.effects, (x) => x.expiresAt > when);
948
+ const prompts = keep(state.prompts, (x) => x.expiresAt === undefined || x.expiresAt > when);
949
+ const acts = keep(state.acts, (x) => x.at + retention > when);
950
+ // terminal outbox items, the retention after their last step, unless a
951
+ // kept journal entry still answers with them (replay needs the item)
952
+ const journaled = new Set(Object.values(acts).flatMap((a) => a.outboundDigests));
953
+ const outbox = keep(state.outbox, (it, digest) => {
954
+ const t = triasOf(it);
955
+ if (t !== 'delivered' && t !== 'failed')
956
+ return true;
957
+ const last = it.history.length ? it.history[it.history.length - 1].at : 0;
958
+ return last + retention > when || journaled.has(digest);
959
+ });
960
+ let next = dropped ? { ...state, effects, prompts, acts, outbox } : state;
961
+ // the modules' own retention, on what the engine's left
962
+ for (const r of retainers()) {
963
+ const after = r(next, when);
964
+ if (after.effects !== next.effects)
965
+ throw new Error('engine: a module\'s retain wrote the effect cache');
966
+ next = after;
967
+ }
968
+ // addresses (Delivery §5), on what the retention left, so a key whose
969
+ // last claim left in this tick leaves with it: a key not shown for the
970
+ // retention, and the own rkid of no live tuple, becomes a tombstone —
971
+ // its id alone, kept forever; tombstones never shrink
972
+ const held = next;
973
+ const live = new Set();
974
+ for (const r of Object.values(held.relationships))
975
+ for (const t of r.tuples)
976
+ if (!t.deactivated)
977
+ live.add(t.rkid);
978
+ // and the rkids a module still needs (a deactivated prior whose continuity flow is open)
979
+ // (at this tick's time: a claim that ends now ends in this tick)
980
+ for (const k of keepers())
981
+ for (const rkid of k(held, when))
982
+ live.add(rkid);
983
+ const tombstones = [...held.addresses.tombstones];
984
+ let gone = false;
985
+ const keys = {};
986
+ for (const [rkid, k] of Object.entries(held.addresses.keys)) {
987
+ if (k.lastShown + retention > when || live.has(rkid)) {
988
+ keys[rkid] = k;
989
+ continue;
990
+ }
991
+ gone = true;
992
+ // a member key (group/…) is an identity context, not a delivery
993
+ // address of Delivery §5: it leaves without a tombstone, so the same
994
+ // derivation can serve the same group again (a re-invitation)
995
+ if (!k.label.startsWith('group/') && !tombstones.includes(rkid))
996
+ tombstones.push(rkid);
997
+ }
998
+ if (gone)
999
+ next = { ...held, addresses: { keys, tombstones } };
1000
+ return { state: next }; // unchanged: the engine still commits a raised lastWhen
1001
+ },
1002
+ };
1003
+ }
1004
+ /** The duties a resume plans to run, in fixed order (dueAt, then id),
1005
+ * and the registered kinds met at an unknown version. Each planned
1006
+ * duty is checked again against the current state right before its
1007
+ * handler runs (#resumeDef). */
1008
+ #dutyPlan(state, when) {
1009
+ const order = Object.values(state.duties).sort((a, b) => (dutyDue(a) - dutyDue(b)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1010
+ const run = [];
1011
+ const unsupported = [];
1012
+ for (const duty of order) {
1013
+ const h = this.#duties.get(duty.kind);
1014
+ if (!h)
1015
+ continue; // a kind no module here registers: kept, silent
1016
+ if (duty.v !== h.v) {
1017
+ unsupported.push(duty);
1018
+ continue;
1019
+ }
1020
+ if (dutyRunnable(duty, h, state, when))
1021
+ run.push({ duty, h });
1022
+ }
1023
+ return { run, unsupported };
1024
+ }
1025
+ #resumeDef() {
1026
+ const expiryAfter = (st, w) => this.#nextExpiry(st, w);
1027
+ const policy = () => this.#policy;
1028
+ const plan = (state, when) => this.#dutyPlan(state, when);
1029
+ const handlers = () => this.#duties;
1030
+ const sweeps = () => this.#sweeps;
1031
+ const failed = (st, digests, when) => this.#failed(st, digests, when);
1032
+ const ownRetries = () => this.#ownRetries;
1033
+ const giveUp = (it, when) => this.#giveUp(it, when);
1034
+ return {
1035
+ human: false,
1036
+ // the due duties' and the sweeps' preparations, against the snapshot apply will see
1037
+ async prepare(ctx) {
1038
+ const values = {};
1039
+ for (const { duty, h } of plan(ctx.snapshot, ctx.when).run)
1040
+ if (h.prepare)
1041
+ values[duty.id] = await h.prepare(ctx, duty);
1042
+ const swept = {};
1043
+ for (const [name, sw] of sweeps())
1044
+ swept[name] = await sw.prepare(ctx);
1045
+ return { duties: values, sweeps: swept };
1046
+ },
1047
+ apply(state, prepared, _input, when) {
1048
+ const p = policy();
1049
+ const outbox = { ...state.outbox };
1050
+ const emit = [];
1051
+ const events = [];
1052
+ const gaveUp = [];
1053
+ let nextDueAt;
1054
+ const due = (t) => { nextDueAt = nextDueAt === undefined ? t : Math.min(nextDueAt, t); };
1055
+ // 1 — the outbox, by due time, then digest
1056
+ 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));
1057
+ for (const digest of order) {
1058
+ const it = state.outbox[digest];
1059
+ const trias = triasOf(it);
1060
+ if (trias === 'delivered' || trias === 'failed')
1061
+ continue;
1062
+ // the horizon applies to every non-terminal item: held, accepted or
1063
+ // not; a sender lifetime the type declares ends it earlier, as
1064
+ // failed(task-expired), never handed out again (Delivery 6110, 6115)
1065
+ const why = giveUp(it, when);
1066
+ if (why) {
1067
+ const { nextAttemptAt: _, ...rest } = it;
1068
+ outbox[digest] = { ...rest, history: [...it.history, { status: 'failed', at: when, reason: why }] };
1069
+ events.push({ type: 'delivery.status', digest, threadId: it.threadId, status: 'failed' });
1070
+ gaveUp.push(digest);
1071
+ continue;
1072
+ }
1073
+ const first = it.history.length ? it.history[0].at : when;
1074
+ const expiry = Math.min(first + p.horizonMs, Number.MAX_SAFE_INTEGER);
1075
+ due(it.expiresAt !== undefined ? Math.min(it.expiresAt, expiry) : expiry);
1076
+ // never re-emitted: an accepted item (the service has it) and one the
1077
+ // adapter holds (awaiting-transport, until it reports again)
1078
+ if (trias === 'accepted' || it.history[it.history.length - 1]?.status === 'awaiting-transport')
1079
+ continue;
1080
+ // a type its module re-issues itself is never reproduced (Trust 6710)
1081
+ if (ownRetries().has(it.type))
1082
+ continue;
1083
+ if (it.nextAttemptAt !== undefined && it.nextAttemptAt > when) {
1084
+ due(it.nextAttemptAt);
1085
+ continue;
1086
+ }
1087
+ const attempt = it.attempt + 1;
1088
+ const nextAttemptAt = dueAt(when, p.backoff(attempt));
1089
+ outbox[digest] = { ...it, attempt, nextAttemptAt, history: [...it.history, { status: 'submitted', at: when, attempt }] };
1090
+ emit.push(structuredClone({ digest, envelope: it.envelope, type: it.type, threadId: it.threadId, recipient: it.recipient, attempt }));
1091
+ due(nextAttemptAt);
1092
+ }
1093
+ // 2 — duties, in the plan's order, each on the state the one before
1094
+ // left and checked against it right before its handler: still there
1095
+ // at the handler's version, due, its awaited head still holding. A
1096
+ // prepared duty whose data changed since its preparation is deferred
1097
+ // to the next resume, which prepares it afresh (due at once)
1098
+ const { run, unsupported } = plan(state, when);
1099
+ for (const d of unsupported)
1100
+ events.push({ type: 'duty.unsupported', dutyId: d.id, kind: d.kind, v: d.v });
1101
+ // the outbox changed only by a re-emission or a give-up (an event so far)
1102
+ let cur = !emit.length && !events.length ? state : { ...state, outbox };
1103
+ // the failure handlers of the items given up, in the give-up's transaction
1104
+ if (gaveUp.length) {
1105
+ const f = failed(cur, gaveUp, when);
1106
+ cur = f.state;
1107
+ events.push(...f.events);
1108
+ }
1109
+ const drafts = [];
1110
+ const renames = [];
1111
+ // the duties whose handler returned Applied (invariants.ts: fulfilment)
1112
+ const fulfilled = [];
1113
+ // ran and stayed (not-due, refused): id → its record as it ran
1114
+ const kept = new Map();
1115
+ const { duties: values, sweeps: swept } = prepared.value;
1116
+ for (const { duty, h } of run) {
1117
+ if (!Object.hasOwn(cur.duties, duty.id))
1118
+ continue; // fulfilled or dropped meanwhile
1119
+ const now = cur.duties[duty.id];
1120
+ // another kind or version since the plan: the next resume dispatches it
1121
+ if (now.kind !== duty.kind || now.id !== duty.id || now.v !== h.v || !dutyRunnable(now, h, cur, when))
1122
+ continue; // the final pass sets its timer
1123
+ if (h.prepare && jcs(now) !== jcs(duty))
1124
+ continue; // prepared on other data
1125
+ const r = h.apply(cur, now, Object.freeze({ prepared: Object.freeze({ value: values[duty.id], version: prepared.version, commitSeq: prepared.commitSeq }), backoff: p.backoff }), when);
1126
+ if (r === 'not-due' || isRefusal(r)) {
1127
+ kept.set(duty.id, jcs(now));
1128
+ continue;
1129
+ }
1130
+ // fulfilled: the duty leaves; what its handler opened is folded into
1131
+ // the state at once (an id already open is kept), so the duties
1132
+ // after it see it — their drop rules and their dedupe included
1133
+ const left = { ...r.state.duties };
1134
+ delete left[duty.id];
1135
+ for (const d of r.duties ?? [])
1136
+ if (!Object.hasOwn(left, d.id))
1137
+ left[d.id] = d;
1138
+ cur = { ...r.state, duties: left };
1139
+ fulfilled.push(duty.id);
1140
+ drafts.push(...(r.outbound ?? []));
1141
+ events.push(...(r.events ?? []));
1142
+ renames.push(...(r.renames ?? []));
1143
+ }
1144
+ // 2b — the sweeps, on the state the duties left; a prepared piece
1145
+ // whose inputs moved is skipped and due again at once
1146
+ for (const [name, sw] of sweeps()) {
1147
+ const r = sw.apply(cur, Object.freeze({ value: swept[name], version: prepared.version, commitSeq: prepared.commitSeq }), when);
1148
+ if (r.state.effects !== cur.effects)
1149
+ throw new Error(`engine: the sweep ${name} wrote the effect cache`);
1150
+ cur = r.state;
1151
+ if (r.duties?.length) {
1152
+ const all = { ...cur.duties };
1153
+ for (const d of r.duties)
1154
+ if (!Object.hasOwn(all, d.id))
1155
+ all[d.id] = d;
1156
+ cur = { ...cur, duties: all };
1157
+ }
1158
+ drafts.push(...(r.outbound ?? []));
1159
+ events.push(...(r.events ?? []));
1160
+ if (r.deferred)
1161
+ due(when);
1162
+ }
1163
+ // work a sweep owes now but did not prepare: a divergence the outbox
1164
+ // step (a give-up at the horizon) or a duty of this resume created —
1165
+ // due at once, prepared by the next resume
1166
+ if (cur !== state) {
1167
+ for (const sw of sweeps().values()) {
1168
+ const had = new Set(sw.pending(state, when));
1169
+ if (sw.pending(cur, when).some((k) => !had.has(k))) {
1170
+ due(when);
1171
+ break;
1172
+ }
1173
+ }
1174
+ }
1175
+ // the timer from the final duty set, new duties included: a future
1176
+ // dueAt, or `when` for work runnable now that did not run (deferred,
1177
+ // newly created, newly unblocked); a duty that ran and stayed
1178
+ // (not-due, refused) sets none while its record is unchanged since
1179
+ // its run — changed by a later duty, it counts like any other
1180
+ for (const d of Object.values(cur.duties)) {
1181
+ const h = handlers().get(d.kind);
1182
+ if (!h || d.v !== h.v || kept.get(d.id) === jcs(d))
1183
+ continue;
1184
+ if (dutyDue(d) > when)
1185
+ due(dutyDue(d));
1186
+ else if (dutyRunnable(d, h, cur, when))
1187
+ due(when);
1188
+ }
1189
+ // the duties' new documents enter the outbox in #run, due at backoff(1)
1190
+ if (drafts.length)
1191
+ due(dueAt(when, p.backoff(1)));
1192
+ // 3 — acks still unsent (a restart between the receive commit and the
1193
+ // send, or no report yet): re-offered byte-identical on the retry
1194
+ // policy, by effect digest; at the horizon marked sent by it
1195
+ const release = [];
1196
+ let effects = null;
1197
+ for (const d of Object.keys(cur.effects).sort()) {
1198
+ const a = cur.effects[d].ack;
1199
+ if (!a || a.sent)
1200
+ continue;
1201
+ const expiry = Math.min(a.at + p.horizonMs, Number.MAX_SAFE_INTEGER);
1202
+ effects ??= { ...cur.effects };
1203
+ if (when >= expiry) {
1204
+ const { nextAttemptAt: _, ...rest } = a;
1205
+ effects[d] = { ...cur.effects[d], ack: { ...rest, sent: true, sentBy: 'horizon' } };
1206
+ continue;
1207
+ }
1208
+ due(expiry);
1209
+ if (a.nextAttemptAt !== undefined && a.nextAttemptAt > when) {
1210
+ due(a.nextAttemptAt);
1211
+ continue;
1212
+ }
1213
+ const attempt = a.attempt + 1;
1214
+ const nextAttemptAt = dueAt(when, p.backoff(attempt));
1215
+ effects[d] = { ...cur.effects[d], ack: { ...a, attempt, nextAttemptAt } };
1216
+ release.push(ackOutbound(a, attempt));
1217
+ due(nextAttemptAt);
1218
+ }
1219
+ if (effects && jcs(effects) !== jcs(cur.effects))
1220
+ cur = { ...cur, effects };
1221
+ // the expiry work tick does (the timer contract, the header)
1222
+ const ex = expiryAfter(cur, when);
1223
+ if (ex !== undefined)
1224
+ due(ex);
1225
+ return {
1226
+ state: cur, emit, events, outbound: drafts, release, detail: { fulfilled },
1227
+ ...(renames.length ? { renames } : {}),
1228
+ ...(nextDueAt !== undefined ? { nextDueAt } : {}),
1229
+ };
1230
+ },
1231
+ };
1232
+ }
1233
+ #reportDef() {
1234
+ const failed = (st, digests, when) => this.#failed(st, digests, when);
1235
+ return {
1236
+ human: false,
1237
+ apply(state, _p, report, when) {
1238
+ if (!Object.hasOwn(state.outbox, report.digest)) {
1239
+ // an ack (outbox-less): any report says the app handed it over
1240
+ const eff = Object.keys(state.effects).find((d) => state.effects[d].ack?.digest === report.digest);
1241
+ if (eff === undefined)
1242
+ return { ok: false, code: 'unknown-digest' };
1243
+ const e = state.effects[eff];
1244
+ if (e.ack.sent)
1245
+ return { state: state };
1246
+ const { nextAttemptAt: _, ...ack } = e.ack;
1247
+ return { state: { ...state, effects: { ...state.effects, [eff]: { ...e, ack: { ...ack, sent: true, sentBy: 'report' } } } } };
1248
+ }
1249
+ const it = state.outbox[report.digest];
1250
+ const trias = triasOf(it);
1251
+ const had = (s) => it.history.some((x) => x.status === s);
1252
+ const same = { state: state };
1253
+ let step;
1254
+ switch (report.status) {
1255
+ case 'awaiting-transport': {
1256
+ const last = it.history[it.history.length - 1];
1257
+ if (trias !== 'submitted')
1258
+ return same; // no step back before the trias
1259
+ if (last?.status === 'awaiting-transport' && last.reason === report.reason)
1260
+ return same;
1261
+ step = { status: 'awaiting-transport', at: when, reason: report.reason };
1262
+ break;
1263
+ }
1264
+ case 'accepted':
1265
+ if (had('accepted') || trias !== 'submitted')
1266
+ return same;
1267
+ step = { status: 'accepted', at: when };
1268
+ break;
1269
+ case 'delivered': {
1270
+ // lateness is the holder's own history: delivered after failed
1271
+ // (its own give-up or the adapter's) is late, whatever the flag says
1272
+ const m = markDelivered(state, report.digest, when);
1273
+ return m.event ? { state: { ...state, outbox: m.outbox }, events: [m.event] } : same;
1274
+ }
1275
+ case 'failed':
1276
+ if (had('failed') || trias === 'delivered')
1277
+ return same;
1278
+ step = report.reason === 'rejected-by-receiver'
1279
+ ? { status: 'failed', at: when, reason: report.reason, code: report.code }
1280
+ : { status: 'failed', at: when, reason: report.reason };
1281
+ break;
1282
+ default:
1283
+ throw new TypeError(`unknown report status ${String(report.status)}`);
1284
+ }
1285
+ const base = step.status === 'awaiting-transport' ? it : (({ nextAttemptAt: _, ...rest }) => rest)(it);
1286
+ const outbox = { ...state.outbox, [report.digest]: { ...base, history: [...it.history, step] } };
1287
+ const events = step.status === 'awaiting-transport' ? [] : [{
1288
+ type: 'delivery.status', digest: report.digest, threadId: it.threadId, status: step.status,
1289
+ }];
1290
+ if (step.status === 'failed') {
1291
+ const f = failed({ ...state, outbox }, [report.digest], when);
1292
+ return { state: f.state, events: [...events, ...f.events] };
1293
+ }
1294
+ return { state: { ...state, outbox }, events };
1295
+ },
1296
+ };
1297
+ }
1298
+ #receiveDef() {
1299
+ const tasks = () => this.#tasks;
1300
+ const acks = () => this.#acks;
1301
+ const acceptedHooks = () => this.#accepted;
1302
+ const policy = () => this.#policy;
1303
+ return {
1304
+ human: false,
1305
+ prepare: (ctx, env) => prepareReceive(ctx, env, tasks()),
1306
+ apply(state, prepared, _env, when) {
1307
+ const plan = prepared.value;
1308
+ const cached = (ack) => ({ ack, expiresAt: Math.min(when + policy().retentionMs, Number.MAX_SAFE_INTEGER) });
1309
+ switch (plan.kind) {
1310
+ case 'failed': return { halt: true, detail: plan.result };
1311
+ case 'duplicate': {
1312
+ // the stored ack, byte-identical; a terminal document has none
1313
+ // (its outbound attempt is one past the schedule's, which it does not move)
1314
+ const a = state.effects[plan.digest].ack;
1315
+ return { state: state, release: a ? [ackOutbound(a, a.attempt + 1)] : [], detail: { disposition: 'duplicate-known', digest: plan.digest } };
1316
+ }
1317
+ case 'ack': {
1318
+ // terminal: the sender-status update and its cache entry, no ack;
1319
+ // the issuing module's ack handler runs in the same transaction
1320
+ const m = markDelivered(state, plan.ref, when);
1321
+ let next = { ...state, outbox: m.outbox };
1322
+ const events = m.event ? [m.event] : [];
1323
+ const h = acks().get(state.outbox[plan.ref].type);
1324
+ if (h) {
1325
+ const r = h(next, plan.ref, when);
1326
+ if (r.state.effects !== next.effects)
1327
+ throw new Error(`engine: the ack handler of ${state.outbox[plan.ref].type} wrote the effect cache`);
1328
+ next = r.state;
1329
+ events.push(...(r.events ?? []));
1330
+ }
1331
+ // a verified ack is an accepted document of its issuer too: the
1332
+ // modules' accepted hooks, in the same transaction
1333
+ for (const hook of acceptedHooks()) {
1334
+ next = hook(next, plan.doc, plan.arrival);
1335
+ if (next.effects !== state.effects)
1336
+ throw new Error('engine: an accepted hook wrote the effect cache');
1337
+ }
1338
+ return {
1339
+ state: { ...next, effects: { ...next.effects, [plan.digest]: cached(null) } },
1340
+ events,
1341
+ detail: { disposition: 'unique', digest: plan.digest, type: ACK_TYPE, ref: plan.ref },
1342
+ };
1343
+ }
1344
+ case 'task': {
1345
+ const h = tasks().get(plan.type);
1346
+ const r = h.apply(state, handlerPrepared(prepared, plan.value), plan.doc, plan.arrival);
1347
+ if (isTaskRefusal(r)) {
1348
+ const failedDetail = { disposition: 'failed', reason: r.reason, stage: 9, digest: plan.digest, ...(r.ref !== undefined ? { ref: r.ref } : {}) };
1349
+ if (!r.latch)
1350
+ return { halt: true, detail: failedDetail };
1351
+ // the Encounter latch exception (receive.ts): only the latch commits
1352
+ checkLatch(state, r.latch, when);
1353
+ return { state: r.latch, detail: failedDetail };
1354
+ }
1355
+ if (isTaskDiscarded(r)) {
1356
+ // a resource-limit discard: its state commits; no cache entry, no ack
1357
+ if (r.state.effects !== state.effects)
1358
+ throw new Error(`engine: the handler of ${plan.type} wrote the effect cache`);
1359
+ return { state: r.state, detail: { disposition: 'discarded', digest: plan.digest, type: plan.type, reason: r.reason, ...(r.ref !== undefined ? { ref: r.ref } : {}) } };
1360
+ }
1361
+ if (r.state.effects !== state.effects)
1362
+ throw new Error(`engine: the handler of ${plan.type} wrote the effect cache; the core writes it`);
1363
+ // the modules' accepted hooks, in the same transaction
1364
+ let after = r.state;
1365
+ for (const hook of acceptedHooks()) {
1366
+ after = hook(after, plan.doc, plan.arrival);
1367
+ if (after.effects !== state.effects)
1368
+ throw new Error('engine: an accepted hook wrote the effect cache');
1369
+ }
1370
+ return {
1371
+ state: { ...after, effects: { ...state.effects, [plan.digest]: cached({ ...plan.ack, at: when, attempt: 1, nextAttemptAt: dueAt(when, policy().backoff(1)), sent: false }) } },
1372
+ ...(r.outbound ? { outbound: r.outbound } : {}),
1373
+ ...(r.events ? { events: r.events } : {}),
1374
+ ...(r.duties ? { duties: r.duties } : {}),
1375
+ ...(r.renames ? { renames: r.renames } : {}),
1376
+ release: [ackOutbound(plan.ack, 1)],
1377
+ detail: { disposition: 'unique', digest: plan.digest, type: plan.type, rkid: plan.arrival.rkid },
1378
+ };
1379
+ }
1380
+ }
1381
+ },
1382
+ };
1383
+ }
1384
+ // ── public surface ───────────────────────────────────────────────────
1385
+ act(command, input, when, opts = {}) {
1386
+ let def;
1387
+ let bound;
1388
+ let hash;
1389
+ let actId;
1390
+ try {
1391
+ def = this.#commands.get(command);
1392
+ if (!def)
1393
+ throw new Error(BUILTIN.includes(command) ? `${command} is built in: call engine.${command}()` : `unknown command ${command}`);
1394
+ checkWhen(when);
1395
+ actId = def.human ? opts.actId : undefined;
1396
+ if (def.human && (typeof actId !== 'string' || actId === ''))
1397
+ return Promise.resolve({ ok: false, code: 'actid-missing' });
1398
+ // bound here, synchronously: later changes by the caller reach nothing
1399
+ bound = bindInput(input);
1400
+ if (def.human) {
1401
+ hash = digestOf(this.#crypto.subtle, { command, input: bound });
1402
+ hash.catch(() => undefined); // awaited in the queue; no unhandled rejection meanwhile
1403
+ }
1404
+ }
1405
+ catch (e) {
1406
+ return Promise.reject(e);
1407
+ }
1408
+ const d = def;
1409
+ return this.#enqueue(async () => {
1410
+ return actResult(await this.#run(d, { kind: 'act', command }, bound, when, actId, hash === undefined ? undefined : await hash));
1411
+ });
1412
+ }
1413
+ tick(when) {
1414
+ try {
1415
+ checkWhen(when);
1416
+ }
1417
+ catch (e) {
1418
+ return Promise.reject(e);
1419
+ }
1420
+ return this.#enqueue(async () => {
1421
+ return actResult(await this.#run(this.#builtins.tick, { kind: 'tick' }, null, when));
1422
+ });
1423
+ }
1424
+ resume(when) {
1425
+ try {
1426
+ checkWhen(when);
1427
+ }
1428
+ catch (e) {
1429
+ return Promise.reject(e);
1430
+ }
1431
+ return this.#enqueue(async () => {
1432
+ const r = await this.#run(this.#builtins.resume, { kind: 'resume' }, null, when);
1433
+ if (isHalt(r) || !r.ok)
1434
+ throw new Error('engine: resume does not refuse');
1435
+ return { outbound: r.outbound, events: r.events, ...(r.nextDueAt !== undefined ? { nextDueAt: r.nextDueAt } : {}) };
1436
+ });
1437
+ }
1438
+ report(report, when) {
1439
+ let bound;
1440
+ try {
1441
+ checkWhen(when);
1442
+ bound = bindInput(report);
1443
+ }
1444
+ catch (e) {
1445
+ return Promise.reject(e);
1446
+ }
1447
+ return this.#enqueue(async () => {
1448
+ return actResult(await this.#run(this.#builtins.report, { kind: 'report', status: bound.status }, bound, when));
1449
+ });
1450
+ }
1451
+ receive(env, when) {
1452
+ try {
1453
+ checkWhen(when);
1454
+ }
1455
+ catch (e) {
1456
+ return Promise.reject(e);
1457
+ }
1458
+ // bound at the call like any input; what is not JSON is no envelope
1459
+ let bound;
1460
+ try {
1461
+ bound = bindInput(env);
1462
+ }
1463
+ catch {
1464
+ bound = null;
1465
+ }
1466
+ return this.#enqueue(async () => {
1467
+ const r = await this.#run(this.#builtins.receive, { kind: 'receive' }, bound, when);
1468
+ if (isHalt(r))
1469
+ return r.detail;
1470
+ if (!r.ok)
1471
+ throw new Error('engine: receive does not refuse');
1472
+ if (r.detail?.disposition === 'failed')
1473
+ return r.detail; // a latch committed
1474
+ const d = r.detail;
1475
+ if (d.disposition === 'discarded')
1476
+ return { disposition: 'discarded', digest: d.digest, type: d.type, reason: d.reason, ...(d.ref !== undefined ? { ref: d.ref } : {}) };
1477
+ return d.disposition === 'unique'
1478
+ ? { disposition: 'unique', digest: d.digest, type: d.type, outbound: r.outbound, events: r.events, ...(r.nextDueAt !== undefined ? { nextDueAt: r.nextDueAt } : {}) }
1479
+ : { disposition: 'duplicate-known', digest: d.digest, outbound: r.outbound };
1480
+ });
1481
+ }
1482
+ subscribe(listener) {
1483
+ if (typeof listener !== 'function')
1484
+ throw new TypeError('listener must be a function');
1485
+ this.#listeners.add(listener);
1486
+ return () => { this.#listeners.delete(listener); };
1487
+ }
1488
+ snapshot() {
1489
+ return this.#enqueue(async () => {
1490
+ const { state } = await this.#read();
1491
+ return Object.freeze({ state, commitSeq: state.commitSeq });
1492
+ });
1493
+ }
1494
+ }
1495
+ /**
1496
+ * Build an engine over the ports and modules: resolves and probes
1497
+ * WebCrypto, freezes the registries (duplicate command names, task URIs
1498
+ * or duty kinds throw), and initialises an empty store with the empty
1499
+ * state at the current codec.
1500
+ */
1501
+ export async function createEngine(ports, modules, opts = {}) {
1502
+ if (!ports?.store || !ports.keys)
1503
+ throw new TypeError('createEngine needs a store and keys');
1504
+ const crypto = await resolveCrypto(ports.crypto);
1505
+ const engine = new HolderEngine(ports, crypto, modules, opts);
1506
+ await engine.load();
1507
+ return engine;
1508
+ }
1509
+ //# sourceMappingURL=engine.js.map