@optimystic/db-core 0.22.0 → 0.24.1

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 (141) hide show
  1. package/README.md +336 -336
  2. package/dist/src/cluster/structs.d.ts +39 -1
  3. package/dist/src/cluster/structs.d.ts.map +1 -1
  4. package/dist/src/cluster/structs.js +24 -0
  5. package/dist/src/cluster/structs.js.map +1 -1
  6. package/dist/src/collection/collection.d.ts +17 -0
  7. package/dist/src/collection/collection.d.ts.map +1 -1
  8. package/dist/src/collection/collection.js +24 -2
  9. package/dist/src/collection/collection.js.map +1 -1
  10. package/dist/src/collections/tree/tree.d.ts +5 -0
  11. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  12. package/dist/src/collections/tree/tree.js +7 -0
  13. package/dist/src/collections/tree/tree.js.map +1 -1
  14. package/dist/src/network/i-peer-network.d.ts +16 -0
  15. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  16. package/dist/src/network/struct.d.ts +39 -2
  17. package/dist/src/network/struct.d.ts.map +1 -1
  18. package/dist/src/network/struct.js +18 -0
  19. package/dist/src/network/struct.js.map +1 -1
  20. package/dist/src/testing/test-transactor.d.ts +95 -8
  21. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  22. package/dist/src/testing/test-transactor.js +121 -8
  23. package/dist/src/testing/test-transactor.js.map +1 -1
  24. package/dist/src/transaction/transaction.d.ts +1 -1
  25. package/dist/src/transaction/transaction.js +1 -1
  26. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  27. package/dist/src/transactor/network-transactor.js +48 -13
  28. package/dist/src/transactor/network-transactor.js.map +1 -1
  29. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  30. package/dist/src/transactor/transactor-source.js +25 -2
  31. package/dist/src/transactor/transactor-source.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cluster/membership.ts +85 -85
  34. package/src/cluster/structs.ts +43 -4
  35. package/src/cohort-topic/addressing.ts +120 -120
  36. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  37. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  38. package/src/cohort-topic/antidos/index.ts +5 -5
  39. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  40. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  41. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  42. package/src/cohort-topic/antiflood/index.ts +2 -2
  43. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  44. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  45. package/src/cohort-topic/coldstart.ts +237 -237
  46. package/src/cohort-topic/dmax.ts +88 -88
  47. package/src/cohort-topic/gossip/bus.ts +254 -254
  48. package/src/cohort-topic/gossip/index.ts +3 -3
  49. package/src/cohort-topic/gossip/records.ts +45 -45
  50. package/src/cohort-topic/gossip/view.ts +91 -91
  51. package/src/cohort-topic/index.ts +20 -20
  52. package/src/cohort-topic/load/barometer.ts +134 -134
  53. package/src/cohort-topic/load/index.ts +1 -1
  54. package/src/cohort-topic/member-engine.ts +430 -430
  55. package/src/cohort-topic/membership/index.ts +3 -3
  56. package/src/cohort-topic/membership/publisher.ts +163 -163
  57. package/src/cohort-topic/membership/source.ts +41 -41
  58. package/src/cohort-topic/membership/verifier.ts +461 -461
  59. package/src/cohort-topic/ports.ts +157 -157
  60. package/src/cohort-topic/promotion.ts +405 -405
  61. package/src/cohort-topic/registration/bytes.ts +37 -37
  62. package/src/cohort-topic/registration/handoff.ts +154 -154
  63. package/src/cohort-topic/registration/index.ts +6 -6
  64. package/src/cohort-topic/registration/renewal.ts +495 -495
  65. package/src/cohort-topic/registration/sharding.ts +61 -61
  66. package/src/cohort-topic/registration/store.ts +81 -81
  67. package/src/cohort-topic/registration/types.ts +91 -91
  68. package/src/cohort-topic/ring-hash.ts +50 -50
  69. package/src/cohort-topic/service.ts +416 -416
  70. package/src/cohort-topic/sig/index.ts +2 -2
  71. package/src/cohort-topic/sig/payloads.ts +59 -59
  72. package/src/cohort-topic/sig/threshold.ts +64 -64
  73. package/src/cohort-topic/tiers.ts +74 -74
  74. package/src/cohort-topic/traffic.ts +233 -233
  75. package/src/cohort-topic/walk.ts +326 -326
  76. package/src/cohort-topic/willingness.ts +237 -237
  77. package/src/cohort-topic/wire/codec.ts +216 -216
  78. package/src/cohort-topic/wire/index.ts +18 -18
  79. package/src/cohort-topic/wire/payloads.ts +126 -126
  80. package/src/cohort-topic/wire/primitives.ts +188 -188
  81. package/src/cohort-topic/wire/types.ts +475 -475
  82. package/src/cohort-topic/wire/validate.ts +512 -512
  83. package/src/collection/collection-type-registry.ts +37 -37
  84. package/src/collection/collection.ts +25 -2
  85. package/src/collections/diary/diary.ts +68 -68
  86. package/src/collections/tree/readme.md +4 -0
  87. package/src/collections/tree/tree.ts +320 -312
  88. package/src/matchmaking/capability-filter.ts +45 -45
  89. package/src/matchmaking/config.ts +98 -98
  90. package/src/matchmaking/index.ts +21 -21
  91. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  92. package/src/matchmaking/provider.ts +123 -123
  93. package/src/matchmaking/query-eval.ts +105 -105
  94. package/src/matchmaking/seeker-walk.ts +127 -127
  95. package/src/matchmaking/seeker.ts +86 -86
  96. package/src/matchmaking/topic-anchor.ts +90 -90
  97. package/src/matchmaking/voting-quorum.ts +394 -394
  98. package/src/matchmaking/wire.ts +603 -603
  99. package/src/network/i-peer-network.ts +17 -0
  100. package/src/network/stale-failure.ts +43 -43
  101. package/src/network/struct.ts +41 -2
  102. package/src/network/types.ts +37 -37
  103. package/src/reactivity/backfill.ts +220 -220
  104. package/src/reactivity/backpressure.ts +191 -191
  105. package/src/reactivity/checkpoint.ts +308 -308
  106. package/src/reactivity/config.ts +172 -172
  107. package/src/reactivity/dedupe.ts +132 -132
  108. package/src/reactivity/forwarder.ts +87 -87
  109. package/src/reactivity/index.ts +34 -34
  110. package/src/reactivity/notification.ts +123 -123
  111. package/src/reactivity/policy.ts +79 -79
  112. package/src/reactivity/push-state.ts +310 -310
  113. package/src/reactivity/recover.ts +153 -153
  114. package/src/reactivity/replay-buffer.ts +141 -141
  115. package/src/reactivity/resume.ts +549 -549
  116. package/src/reactivity/rotation.ts +415 -415
  117. package/src/reactivity/subscriber.ts +132 -132
  118. package/src/reactivity/subscription.ts +66 -66
  119. package/src/reactivity/topic-anchor.ts +71 -71
  120. package/src/reactivity/verify.ts +73 -73
  121. package/src/reactivity/wire-validate.ts +13 -13
  122. package/src/reactivity/wire.ts +224 -224
  123. package/src/testing/async-wait.ts +65 -65
  124. package/src/testing/index.ts +2 -2
  125. package/src/testing/test-transactor.ts +638 -502
  126. package/src/transaction/errors.ts +91 -91
  127. package/src/transaction/operations-hash.ts +196 -196
  128. package/src/transaction/read-dependency-collector.ts +78 -78
  129. package/src/transaction/transaction.ts +1 -1
  130. package/src/transactor/change-notifier.ts +80 -80
  131. package/src/transactor/index.ts +5 -5
  132. package/src/transactor/network-transactor.ts +49 -14
  133. package/src/transactor/transactor-source.ts +25 -2
  134. package/src/transform/atomic-proxy.ts +92 -92
  135. package/src/transform/helpers.ts +159 -159
  136. package/src/utility/backoff.ts +95 -95
  137. package/src/utility/batch-coordinator.ts +191 -191
  138. package/dist/src/transaction/context.d.ts +0 -60
  139. package/dist/src/transaction/context.d.ts.map +0 -1
  140. package/dist/src/transaction/context.js +0 -91
  141. package/dist/src/transaction/context.js.map +0 -1
@@ -1,549 +1,549 @@
1
- /**
2
- * Reactivity — Resume RPC: classification, serving, and subscriber-side apply
3
- * (`docs/reactivity.md` §Resume, §Parent checkpoint summaries, §Wire formats Resume).
4
- *
5
- * A subscriber waking from sleep sends one {@link ResumeV1} to its cached primary (or any cohort member).
6
- * The serving cohort classifies the request against its **stacked** recovery windows — the `W`-deep replay
7
- * ring sits on top of the `W_checkpoint`-span parent checkpoint immediately below it, so a single round
8
- * trip recovers `W + W_checkpoint` revisions — and answers with one of four {@link ResumeReplyV1} variants
9
- * (`docs/reactivity.md` §Replay window, the authoritative stacked semantics):
10
- *
11
- * - **Backfill** — `fromRevision` is within the replay ring (`fromRevision ≥ ringLow`). Returns the
12
- * `[fromRevision, currentRevision]` slice + `currentRevision`. One RT.
13
- * - **CheckpointWindow** — `fromRevision` is below the ring but within the parent checkpoint span
14
- * (`chain low ≤ fromRevision < ringLow`). Returns an ordered, contiguous **chain** of
15
- * {@link CheckpointSummary}s (`checkpoints`, low→high) + the ring's `recentEntries`. The subscriber
16
- * verifies every link's endpoints, applies each link's merged digest, advances its contiguity head past
17
- * the whole chain, then replays the recent entries deduped against `lastRevision`. One RT. The chain is a
18
- * single link in steady state; a new tail that took over across a rotation serves a **two-link**
19
- * `[inherited, rolling]` chain when a `fromRevision` below both the ring and the rolling checkpoint falls
20
- * inside the inherited handoff window — so the full stacked range recovers in one reply regardless of
21
- * where the new tail's own rolling checkpoint has formed (`docs/reactivity.md` §Tail rotation step 5 —
22
- * "the new tail holds the old checkpoint").
23
- * - **OutOfWindow** — older than even the checkpoint. Returns `currentTailId` + `currentRevision`; the
24
- * subscriber falls back to a chain read, then a fresh subscribe.
25
- * - **TailRotated** — the request's `latestKnownTailId` does not match the cohort's current tail (the
26
- * subscriber slept across a rotation). Returns `newTailId` + `newRevisionAtRotation`; the subscriber
27
- * re-registers under the new tail. This is checked **first**: a stale tail means the whole tree moved,
28
- * so the lag-against-windows classification is moot. The rotation *lifecycle* is owned by
29
- * [reactivity-rotation-backpressure-policy]; this module only **detects** the stale tail and emits the
30
- * reply so resume composes with rotation.
31
- *
32
- * Wire conventions match the rest of reactivity: JSON, byte fields base64url, unix-ms timestamps, `v: 1`,
33
- * per-message structural validation on decode. `ResumeV1` is signed by the subscriber's peer key
34
- * (`signature`); replay protection rides the request envelope as elsewhere in the substrate.
35
- */
36
-
37
- import {
38
- decodeCohortMessage,
39
- encodeCohortMessage,
40
- DEFAULT_MAX_MESSAGE_BYTES,
41
- } from "../cohort-topic/wire/codec.js";
42
- import {
43
- asObject,
44
- b64urlField,
45
- failWire,
46
- reqFiniteNumber,
47
- reqIntInRange,
48
- reqString,
49
- requireV1,
50
- } from "./wire-validate.js";
51
- import { validateCheckpointSummary, verifyCheckpointEndpoints, type CheckpointSummary, type RollingCheckpoint } from "./checkpoint.js";
52
- import { checkpointCovers } from "./rotation.js";
53
- import type { ReplayBuffer } from "./replay-buffer.js";
54
- import type { ReactivitySubscriber } from "./subscriber.js";
55
- import type { NotificationVerifier } from "./verify.js";
56
- import { validateNotificationArray, type NotificationV1 } from "./wire.js";
57
-
58
- /** The four resume classifications (`docs/reactivity.md` §Resume). */
59
- export type ResumeResult = "backfill" | "checkpoint_window" | "out_of_window" | "tail_rotated";
60
-
61
- /** A subscriber's resume request after waking from sleep. */
62
- export interface ResumeV1 {
63
- v: 1;
64
- /** Collection id, base64url. */
65
- collectionId: string;
66
- /** First revision the subscriber still needs (`lastRevision + 1`). */
67
- fromRevision: number;
68
- /** Tail block id the subscriber believes is current, base64url (the stale-tail detector). */
69
- latestKnownTailId: string;
70
- /** The subscriber's ring coordinate, base64url (for the cohort to route the reply). */
71
- subscriberCoord: string;
72
- /** Unix ms. */
73
- timestamp: number;
74
- /** Subscriber peer-key signature over the request. */
75
- signature: string;
76
- }
77
-
78
- /** The cohort's classified reply. Fields are populated per the `result` discriminant. */
79
- export interface ResumeReplyV1 {
80
- v: 1;
81
- result: ResumeResult;
82
- // --- backfill ---
83
- /** The `[fromRevision, currentRevision]` slice held in the replay ring (backfill). */
84
- entries?: NotificationV1[];
85
- /** Highest committed revision the serving cohort knows (backfill / checkpoint_window / out_of_window). */
86
- currentRevision?: number;
87
- // --- checkpoint_window ---
88
- /**
89
- * The parent-checkpoint chain spanning `[chain[0].fromRevision, ringLow − 1]` (checkpoint_window), ordered
90
- * low→high and **contiguous**: each `checkpoints[i].fromRevision === checkpoints[i-1].toRevision + 1`. A
91
- * single link in steady state; a cross-rotation resume carries the two-link `[inherited, rolling]` bridge.
92
- * The subscriber verifies and applies every link in sequence (see {@link applyResumeReply}).
93
- */
94
- checkpoints?: CheckpointSummary[];
95
- /** The replay ring's entries, replayed deduped against `lastRevision` after the digests apply. */
96
- recentEntries?: NotificationV1[];
97
- // --- out_of_window ---
98
- /** Current tail block id, base64url — the subscriber re-subscribes after a chain read (out_of_window). */
99
- currentTailId?: string;
100
- // --- tail_rotated ---
101
- /** New tail block id the topic rotated to, base64url (tail_rotated). */
102
- newTailId?: string;
103
- /** Revision at which the rotation took effect (tail_rotated). */
104
- newRevisionAtRotation?: number;
105
- }
106
-
107
- // --- validation ---
108
-
109
- /** Narrow an already-parsed value to {@link ResumeV1}, throwing on any defect. */
110
- export function validateResumeV1(value: unknown): ResumeV1 {
111
- const what = "ResumeV1";
112
- const obj = asObject(value, what);
113
- requireV1(obj, what);
114
- return {
115
- v: 1,
116
- collectionId: b64urlField(reqString(obj, "collectionId", what), "collectionId", what),
117
- fromRevision: reqIntInRange(obj, "fromRevision", what, 0),
118
- latestKnownTailId: b64urlField(reqString(obj, "latestKnownTailId", what), "latestKnownTailId", what),
119
- subscriberCoord: b64urlField(reqString(obj, "subscriberCoord", what), "subscriberCoord", what),
120
- timestamp: reqFiniteNumber(obj, "timestamp", what),
121
- signature: b64urlField(reqString(obj, "signature", what), "signature", what),
122
- };
123
- }
124
-
125
- /** Narrow an already-parsed value to {@link ResumeReplyV1}, throwing on any defect. */
126
- export function validateResumeReplyV1(value: unknown): ResumeReplyV1 {
127
- const what = "ResumeReplyV1";
128
- const obj = asObject(value, what);
129
- requireV1(obj, what);
130
- const result = obj["result"];
131
- if (result !== "backfill" && result !== "checkpoint_window" && result !== "out_of_window" && result !== "tail_rotated") {
132
- failWire(`${what}: field "result" must be one of backfill|checkpoint_window|out_of_window|tail_rotated`);
133
- }
134
- const out: ResumeReplyV1 = { v: 1, result };
135
- switch (result) {
136
- case "backfill": {
137
- out.entries = validateNotificationArray(obj["entries"], what);
138
- out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
139
- break;
140
- }
141
- case "checkpoint_window": {
142
- out.checkpoints = validateCheckpointChain(obj["checkpoints"], `${what}.checkpoints`);
143
- out.recentEntries = validateNotificationArray(obj["recentEntries"], what);
144
- if (obj["currentRevision"] !== undefined) {
145
- out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
146
- }
147
- break;
148
- }
149
- case "out_of_window": {
150
- out.currentTailId = b64urlField(reqString(obj, "currentTailId", what), "currentTailId", what);
151
- out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
152
- break;
153
- }
154
- case "tail_rotated": {
155
- out.newTailId = b64urlField(reqString(obj, "newTailId", what), "newTailId", what);
156
- out.newRevisionAtRotation = reqIntInRange(obj, "newRevisionAtRotation", what, 0);
157
- break;
158
- }
159
- }
160
- return out;
161
- }
162
-
163
- /**
164
- * Validate a `checkpoint_window` reply's checkpoint chain: a **non-empty**, **ascending**, internally
165
- * **contiguous** list of {@link CheckpointSummary}s sharing one `collectionId`. Each link is validated via
166
- * {@link validateCheckpointSummary}, and for `i ≥ 1` the chain must satisfy
167
- * `checkpoints[i].fromRevision === checkpoints[i-1].toRevision + 1` (a gapped, overlapping, or misordered
168
- * chain is rejected). Runs on decode, so a malformed chain never reaches {@link applyResumeReply} over the
169
- * wire.
170
- */
171
- function validateCheckpointChain(value: unknown, what: string): CheckpointSummary[] {
172
- if (!Array.isArray(value) || value.length === 0) {
173
- failWire(`${what}: must be a non-empty array of checkpoint summaries`);
174
- }
175
- const chain = value.map((el, i) => validateCheckpointSummary(el, `${what}[${i}]`));
176
- for (let i = 0; i < chain.length; i++) {
177
- if (chain[i]!.collectionId !== chain[0]!.collectionId) {
178
- failWire(`${what}: all checkpoints must share one collectionId`);
179
- }
180
- if (i >= 1 && chain[i]!.fromRevision !== chain[i - 1]!.toRevision + 1) {
181
- failWire(`${what}: chain must be ascending and contiguous (checkpoints[${i}].fromRevision must equal checkpoints[${i - 1}].toRevision + 1)`);
182
- }
183
- }
184
- return chain;
185
- }
186
-
187
- // --- canonical signing payload ---
188
-
189
- const utf8 = new TextEncoder();
190
-
191
- /** A {@link ResumeV1} minus its `signature` — the canonical bytes the subscriber peer-key-signs. */
192
- export type ResumeSignable = Omit<ResumeV1, "signature">;
193
-
194
- /**
195
- * Canonical signed byte image of a {@link ResumeV1} (every field except `signature`). Mirrors
196
- * {@link import("./backfill.js").backfillSigningPayload}: an explicitly-ordered, type-tagged JSON array
197
- * encoded as UTF-8. The `"ResumeV1"` tag means a resume image can never collide with the `"BackfillV1"`
198
- * image. `subscriberCoord` is part of the image, so a verifier recomputes over whatever coordinate the
199
- * request carries — the field order is the byte-for-byte contract between signer and verifier.
200
- */
201
- export function resumeSigningPayload(body: ResumeSignable): Uint8Array {
202
- return utf8.encode(JSON.stringify([
203
- "ResumeV1",
204
- body.v,
205
- body.collectionId,
206
- body.fromRevision,
207
- body.latestKnownTailId,
208
- body.subscriberCoord,
209
- body.timestamp,
210
- ]));
211
- }
212
-
213
- // --- codecs (length-framed JSON, mirroring the rest of reactivity) ---
214
-
215
- /** Encode a {@link ResumeV1} as a length-prefixed UTF-8 JSON frame. */
216
- export function encodeResumeV1(msg: ResumeV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
217
- return encodeCohortMessage(validateResumeV1(msg), maxMessageBytes);
218
- }
219
-
220
- /** Decode a length-prefixed {@link ResumeV1} frame. */
221
- export function decodeResumeV1(bytes: Uint8Array, maxMessageBytes?: number): ResumeV1 {
222
- return validateResumeV1(decodeCohortMessage(bytes, maxMessageBytes));
223
- }
224
-
225
- /** Encode a {@link ResumeReplyV1} as a length-prefixed UTF-8 JSON frame. */
226
- export function encodeResumeReplyV1(msg: ResumeReplyV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
227
- return encodeCohortMessage(validateResumeReplyV1(msg), maxMessageBytes);
228
- }
229
-
230
- /** Decode a length-prefixed {@link ResumeReplyV1} frame. */
231
- export function decodeResumeReplyV1(bytes: Uint8Array, maxMessageBytes?: number): ResumeReplyV1 {
232
- return validateResumeReplyV1(decodeCohortMessage(bytes, maxMessageBytes));
233
- }
234
-
235
- // --- serving-cohort classification ---
236
-
237
- /** The rolling checkpoint's summary; non-undefined because the caller already proved it abuts the ring (so non-empty). */
238
- function requireRollingSummary(rolling: RollingCheckpoint): CheckpointSummary {
239
- const summary = rolling.summary();
240
- if (summary === undefined) {
241
- throw new Error("resumeCheckpointChain: rolling checkpoint abuts the ring yet produced no summary (invariant violation)");
242
- }
243
- return summary;
244
- }
245
-
246
- /**
247
- * Build the served checkpoint chain for a `checkpoint_window` resume: the **shortest** contiguous low→high
248
- * chain of {@link CheckpointSummary}s that both covers `fromRevision` and **abuts the ring's low edge**
249
- * (`top.toRevision === ringLow − 1`), or `undefined` when no gap-free chain exists. Shared by
250
- * {@link classifyResume} and {@link serveResume} so they cannot disagree about the served shape.
251
- *
252
- * Cases are evaluated in precedence order (this ordering preserves every existing classification test):
253
- *
254
- * - **A — rolling wins.** The rolling checkpoint abuts the ring (it is fed by ring eviction, so it always
255
- * does) and covers `fromRevision`: the fresher, narrower single link `[rolling]`.
256
- * - **B1 — inherited abuts the ring directly.** No new-tail rolling checkpoint has formed between the
257
- * inherited handoff and the ring yet (the `12.51` abut case): the single link `[inherited]`.
258
- * - **B2 — the bridge.** The inherited handoff abuts the new tail's rolling checkpoint, which abuts the
259
- * ring: the two-link `[inherited, rolling]` chain spans inherited → rolling → ring with no gap.
260
- *
261
- * Any other shape (a gap below `fromRevision`, or a gap between the windows) returns `undefined` → the
262
- * request falls to `out_of_window` (an honest chain read). `ringLow === undefined` (no live ring to abut /
263
- * no `recentEntries`) also returns `undefined`.
264
- */
265
- function resumeCheckpointChain(
266
- fromRevision: number,
267
- ringLow: number | undefined,
268
- rolling: RollingCheckpoint | undefined,
269
- inherited: CheckpointSummary | undefined,
270
- ): CheckpointSummary[] | undefined {
271
- if (ringLow === undefined) {
272
- return undefined;
273
- }
274
- const ringAbut = ringLow - 1;
275
-
276
- // Case A — the rolling checkpoint wins (fresher, narrower). `toRevision === ringAbut` implies it is
277
- // non-empty, so the summary is defined (asserted in requireRollingSummary).
278
- if (rolling !== undefined && rolling.toRevision === ringAbut && rolling.covers(fromRevision)) {
279
- return [requireRollingSummary(rolling)];
280
- }
281
- // Case B1 — the inherited handoff abuts the ring directly (the 12.51 abut case): a single link.
282
- if (inherited !== undefined && inherited.toRevision === ringAbut && checkpointCovers(inherited, fromRevision)) {
283
- return [inherited];
284
- }
285
- // Case B2 — the bridge: inherited abuts the rolling checkpoint, which abuts the ring. The two-link chain
286
- // carries inherited → rolling → ring with no gap, and each link keeps its own already-correct merged
287
- // digest (nothing is re-folded, so a per-collection override fold composes trivially).
288
- if (
289
- inherited !== undefined &&
290
- rolling !== undefined &&
291
- rolling.toRevision === ringAbut &&
292
- inherited.toRevision + 1 === rolling.fromRevision &&
293
- checkpointCovers(inherited, fromRevision)
294
- ) {
295
- return [inherited, requireRollingSummary(rolling)];
296
- }
297
- return undefined;
298
- }
299
-
300
- /**
301
- * Classify a resume request against the serving cohort's stacked windows. The decision order is:
302
- *
303
- * 1. **tail_rotated** — `req.latestKnownTailId !== currentTailId`. A stale tail means the tree migrated,
304
- * so the lag classification below is moot.
305
- * 2. **backfill** — `fromRevision` is at/above the replay ring's low edge (within, or already current).
306
- * 3. **checkpoint_window** — below the ring but a gap-free {@link resumeCheckpointChain} covers it: the
307
- * rolling checkpoint (steady state), the inherited cross-rotation handoff (`docs/reactivity.md` §Tail
308
- * rotation step 5 — "the new tail holds the old checkpoint"), or the two-link `[inherited, rolling]`
309
- * bridge spanning both when the new tail's own rolling checkpoint has formed between the handoff and the
310
- * ring.
311
- * 4. **out_of_window** — older than even the deepest window, or a gap leaves no contiguous chain to the
312
- * ring (a chain read), or every window is empty/absent.
313
- */
314
- export function classifyResume(
315
- req: ResumeV1,
316
- buffer: ReplayBuffer,
317
- checkpoint: RollingCheckpoint | undefined,
318
- currentTailId: string,
319
- inherited?: CheckpointSummary,
320
- ): ResumeResult {
321
- if (req.latestKnownTailId !== currentTailId) {
322
- return "tail_rotated";
323
- }
324
- const low = buffer.lowRevision;
325
- if (low !== undefined && req.fromRevision >= low) {
326
- return "backfill";
327
- }
328
- return resumeCheckpointChain(req.fromRevision, low, checkpoint, inherited) !== undefined
329
- ? "checkpoint_window"
330
- : "out_of_window";
331
- }
332
-
333
- /** Construction inputs for {@link serveResume}. */
334
- export interface ResumeServingDeps {
335
- /** The cohort's replay ring (the live `PushState.replayBuffer`). */
336
- readonly buffer: ReplayBuffer;
337
- /** The cohort's rolling parent checkpoint (the live `PushState.checkpoint`). */
338
- readonly checkpoint?: RollingCheckpoint;
339
- /**
340
- * The checkpoint migrated from the **outgoing** tail when this cohort became the new tail across a
341
- * rotation (the live `PushState.inheritedCheckpoint`). Consulted when the rolling {@link checkpoint} does
342
- * not cover `fromRevision`: a resume whose span crosses the rotation is answered from the inherited window
343
- * — either alone (it abuts the ring directly) or as the lower link of the two-link `[inherited, rolling]`
344
- * bridge when the new tail's own rolling checkpoint has formed between the handoff and the ring (see
345
- * {@link resumeCheckpointChain}) — instead of falling to `out_of_window` (`docs/reactivity.md` §Tail
346
- * rotation step 5).
347
- */
348
- readonly inheritedCheckpoint?: CheckpointSummary;
349
- /** The cohort's current tail block id, base64url. */
350
- readonly currentTailId: string;
351
- /** Highest committed revision the cohort knows (the live `PushState.lastRevision`). */
352
- readonly currentRevision: number;
353
- /** Revision the current tail became effective (for a tail_rotated reply); defaults to `currentRevision`. */
354
- readonly rotationRevision?: number;
355
- /** Collection this cohort serves, base64url — a foreign-collection request is rejected. */
356
- readonly expectedCollectionId: string;
357
- }
358
-
359
- /**
360
- * Serve a {@link ResumeV1} from a cohort's stacked windows, producing the classified {@link ResumeReplyV1}.
361
- * Pure over the supplied snapshot — no I/O, no clock. A request whose `collectionId` does not match
362
- * `expectedCollectionId` is rejected (a resume is collection-scoped).
363
- */
364
- export function serveResume(req: ResumeV1, deps: ResumeServingDeps): ResumeReplyV1 {
365
- if (req.collectionId !== deps.expectedCollectionId) {
366
- failWire(`serveResume: request collectionId does not match this cohort's collection`);
367
- }
368
- const result = classifyResume(req, deps.buffer, deps.checkpoint, deps.currentTailId, deps.inheritedCheckpoint);
369
- switch (result) {
370
- case "backfill": {
371
- const high = deps.buffer.highRevision ?? deps.currentRevision;
372
- const entries = deps.buffer.range(req.fromRevision, high).map((e) => e.payload);
373
- return { v: 1, result, entries, currentRevision: deps.currentRevision };
374
- }
375
- case "checkpoint_window": {
376
- // The contiguous chain the helper built — rolling alone, inherited alone, or the two-link bridge.
377
- // classifyResume returned checkpoint_window iff this chain is present, so it is non-undefined here
378
- // (the two calls share inputs, so they cannot disagree).
379
- const chain = resumeCheckpointChain(req.fromRevision, deps.buffer.lowRevision, deps.checkpoint, deps.inheritedCheckpoint);
380
- if (chain === undefined) {
381
- throw new Error("serveResume: checkpoint_window classification produced no checkpoint chain (invariant violation)");
382
- }
383
- const recentEntries = deps.buffer.entries().map((e) => e.payload);
384
- return { v: 1, result, checkpoints: chain, recentEntries, currentRevision: deps.currentRevision };
385
- }
386
- case "out_of_window":
387
- return { v: 1, result, currentTailId: deps.currentTailId, currentRevision: deps.currentRevision };
388
- case "tail_rotated":
389
- return {
390
- v: 1,
391
- result,
392
- newTailId: deps.currentTailId,
393
- newRevisionAtRotation: deps.rotationRevision ?? deps.currentRevision,
394
- };
395
- }
396
- }
397
-
398
- // --- subscriber-side apply ---
399
-
400
- /** The outcome of applying a {@link ResumeReplyV1} subscriber-side. */
401
- export type ResumeApplyOutcome =
402
- /** Backfill entries were replayed through the delivery path; the subscriber is current. */
403
- | "backfilled"
404
- /** Checkpoint endpoints verified, digest applied, recent entries replayed; the subscriber is current. */
405
- | "checkpoint_applied"
406
- /** Checkpoint bracketing endpoints failed to verify (forged/tampered) — the subscriber must chain-read. */
407
- | "checkpoint_untrusted"
408
- /** Out of every window — the subscriber must chain-read then re-subscribe. */
409
- | "out_of_window"
410
- /** Tail rotated — the subscriber must re-register under the new tail. */
411
- | "tail_rotated";
412
-
413
- /** Sinks for {@link applyResumeReply} — each non-backfill variant escalates to the application. */
414
- export interface ResumeApplyDeps {
415
- /** Feeds replayed entries through the verify → contiguity → deliver path (closing the gap, deduping). */
416
- readonly subscriber: ReactivitySubscriber;
417
- /** Verifies a checkpoint's bracketing endpoints are real committed revisions. */
418
- readonly verifier: NotificationVerifier;
419
- /** Apply a verified checkpoint's merged digest (e.g. invalidate the changed keys). */
420
- readonly onCheckpointDigest?: (summary: CheckpointSummary) => void;
421
- /** Fall back to a chain read + fresh subscribe (out_of_window, or an untrusted checkpoint). */
422
- readonly onChainRead?: (currentTailId: string | undefined, currentRevision: number | undefined) => void;
423
- /** Re-register under the rotated tail (tail_rotated). */
424
- readonly onTailRotated?: (newTailId: string, newRevisionAtRotation: number) => void;
425
- }
426
-
427
- /**
428
- * Apply a {@link ResumeReplyV1} on the subscriber side, returning the {@link ResumeApplyOutcome}. Backfill
429
- * and checkpoint-window entries re-enter through {@link ReactivitySubscriber.onNotification} (so they are
430
- * verified, contiguity-checked, and deduped exactly like live notifications); the other variants escalate
431
- * to the application via the supplied sinks. A checkpoint whose bracketing endpoints do not verify is
432
- * **not** applied — the subscriber chain-reads instead (a forged checkpoint must never advance state).
433
- */
434
- export async function applyResumeReply(reply: ResumeReplyV1, deps: ResumeApplyDeps): Promise<ResumeApplyOutcome> {
435
- switch (reply.result) {
436
- case "backfill": {
437
- for (const n of reply.entries ?? []) {
438
- await deps.subscriber.onNotification(n);
439
- }
440
- return "backfilled";
441
- }
442
- case "checkpoint_window": {
443
- const summaries = reply.checkpoints;
444
- if (summaries === undefined || summaries.length === 0) {
445
- failWire(`applyResumeReply: checkpoint_window reply is missing its checkpoint chain`);
446
- }
447
- // Low-edge guard on the **lowest** link: endpoint verification proves each link's bounds are *real*
448
- // committed revisions, but not that the chain connects to the subscriber's contiguity head. If the
449
- // lowest `fromRevision` sits above `lastRevision + 1` there is an un-summarized gap below the chain;
450
- // rebaselining would silently skip those revisions. The honest cohort never sends such a reply
451
- // (`classifyResume` only picks `checkpoint_window` when the chain covers `lastRevision + 1`), so a
452
- // non-abutting low edge is a forged/buggy reply — chain-read rather than advance past a gap.
453
- if (summaries[0]!.fromRevision > deps.subscriber.lastRevision + 1) {
454
- deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
455
- return "checkpoint_untrusted";
456
- }
457
- // Intra-chain contiguity (defense in depth: the codec already checks every *decoded* reply, but an
458
- // in-process reply may bypass the codec). A gap/overlap between links would skip or double revisions.
459
- for (let i = 1; i < summaries.length; i++) {
460
- if (summaries[i]!.fromRevision !== summaries[i - 1]!.toRevision + 1) {
461
- deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
462
- return "checkpoint_untrusted";
463
- }
464
- }
465
- // Verify EVERY link's bracketing endpoints **before applying anything** — a single forged link kills
466
- // the whole reply, so nothing is delivered and `lastRevision` is unchanged (no partial advance).
467
- for (const summary of summaries) {
468
- const verdict = await verifyCheckpointEndpoints(summary, deps.verifier);
469
- if (verdict !== "verified") {
470
- deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
471
- return "checkpoint_untrusted";
472
- }
473
- }
474
- // Apply in order: the application sees each link's merged-digest hint, then the contiguity head
475
- // advances past that link. `rebaseline` is monotone, so the head lands at the top link's
476
- // `toRevision`; the recent entries (immediately above it) then replay gap-free — any recent entry
477
- // at/below the head dedupes (`docs/reactivity.md` §Parent checkpoint summaries).
478
- for (const summary of summaries) {
479
- deps.onCheckpointDigest?.(summary);
480
- deps.subscriber.rebaseline(summary.toRevision);
481
- }
482
- for (const n of reply.recentEntries ?? []) {
483
- await deps.subscriber.onNotification(n);
484
- }
485
- return "checkpoint_applied";
486
- }
487
- case "out_of_window": {
488
- deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
489
- return "out_of_window";
490
- }
491
- case "tail_rotated": {
492
- deps.onTailRotated?.(reply.newTailId ?? "", reply.newRevisionAtRotation ?? 0);
493
- return "tail_rotated";
494
- }
495
- }
496
- }
497
-
498
- // --- Edge sticky cohort-hint cache (one-RT resume after a flap) ---
499
-
500
- /**
501
- * A cached cohort hint for fast reactivity re-attach (`docs/reactivity.md` §Edge profile: "`cohortHint`
502
- * is sticky-cached across reconnects so brief network flaps don't trigger re-walk"). Distinct from the
503
- * cohort-topic `CohortHint` (the full resolved cohort) — this is the minimal sticky-cached slice the
504
- * subscriber dials a resume at directly. All ids base64url.
505
- */
506
- export interface ReactivityCohortHint {
507
- /** The collection's tail-anchored topic id at cache time. */
508
- readonly topicId: string;
509
- /** The cached serving primary. */
510
- readonly primary: string;
511
- /** The cohort member set for fast re-attach. */
512
- readonly cohortHint: readonly string[];
513
- }
514
-
515
- /**
516
- * A sticky per-collection cache of the last-known serving cohort, keyed by collection id. **Sticky** means
517
- * it survives reconnects — a brief network flap reuses the cached primary so resume is one round trip
518
- * instead of a re-walk from `d_max`. It is invalidated only on an explicit signal (a `TailRotated` reply,
519
- * or a confirmed-stale primary), never merely on a transient failure.
520
- */
521
- export interface StickyCohortHintCache {
522
- /** The cached hint for `collectionId`, or `undefined` if none. */
523
- get(collectionId: string): ReactivityCohortHint | undefined;
524
- /** Cache (or refresh) the hint for `collectionId`. */
525
- set(collectionId: string, hint: ReactivityCohortHint): void;
526
- /** Drop the cached hint for `collectionId` (on rotation / confirmed staleness). */
527
- invalidate(collectionId: string): void;
528
- }
529
-
530
- class MapStickyCohortHintCache implements StickyCohortHintCache {
531
- private readonly byCollection = new Map<string, ReactivityCohortHint>();
532
-
533
- get(collectionId: string): ReactivityCohortHint | undefined {
534
- return this.byCollection.get(collectionId);
535
- }
536
-
537
- set(collectionId: string, hint: ReactivityCohortHint): void {
538
- this.byCollection.set(collectionId, hint);
539
- }
540
-
541
- invalidate(collectionId: string): void {
542
- this.byCollection.delete(collectionId);
543
- }
544
- }
545
-
546
- /** Build a {@link StickyCohortHintCache} (a plain per-collection map; sticky across reconnects). */
547
- export function createStickyCohortHintCache(): StickyCohortHintCache {
548
- return new MapStickyCohortHintCache();
549
- }
1
+ /**
2
+ * Reactivity — Resume RPC: classification, serving, and subscriber-side apply
3
+ * (`docs/reactivity.md` §Resume, §Parent checkpoint summaries, §Wire formats Resume).
4
+ *
5
+ * A subscriber waking from sleep sends one {@link ResumeV1} to its cached primary (or any cohort member).
6
+ * The serving cohort classifies the request against its **stacked** recovery windows — the `W`-deep replay
7
+ * ring sits on top of the `W_checkpoint`-span parent checkpoint immediately below it, so a single round
8
+ * trip recovers `W + W_checkpoint` revisions — and answers with one of four {@link ResumeReplyV1} variants
9
+ * (`docs/reactivity.md` §Replay window, the authoritative stacked semantics):
10
+ *
11
+ * - **Backfill** — `fromRevision` is within the replay ring (`fromRevision ≥ ringLow`). Returns the
12
+ * `[fromRevision, currentRevision]` slice + `currentRevision`. One RT.
13
+ * - **CheckpointWindow** — `fromRevision` is below the ring but within the parent checkpoint span
14
+ * (`chain low ≤ fromRevision < ringLow`). Returns an ordered, contiguous **chain** of
15
+ * {@link CheckpointSummary}s (`checkpoints`, low→high) + the ring's `recentEntries`. The subscriber
16
+ * verifies every link's endpoints, applies each link's merged digest, advances its contiguity head past
17
+ * the whole chain, then replays the recent entries deduped against `lastRevision`. One RT. The chain is a
18
+ * single link in steady state; a new tail that took over across a rotation serves a **two-link**
19
+ * `[inherited, rolling]` chain when a `fromRevision` below both the ring and the rolling checkpoint falls
20
+ * inside the inherited handoff window — so the full stacked range recovers in one reply regardless of
21
+ * where the new tail's own rolling checkpoint has formed (`docs/reactivity.md` §Tail rotation step 5 —
22
+ * "the new tail holds the old checkpoint").
23
+ * - **OutOfWindow** — older than even the checkpoint. Returns `currentTailId` + `currentRevision`; the
24
+ * subscriber falls back to a chain read, then a fresh subscribe.
25
+ * - **TailRotated** — the request's `latestKnownTailId` does not match the cohort's current tail (the
26
+ * subscriber slept across a rotation). Returns `newTailId` + `newRevisionAtRotation`; the subscriber
27
+ * re-registers under the new tail. This is checked **first**: a stale tail means the whole tree moved,
28
+ * so the lag-against-windows classification is moot. The rotation *lifecycle* is owned by
29
+ * [reactivity-rotation-backpressure-policy]; this module only **detects** the stale tail and emits the
30
+ * reply so resume composes with rotation.
31
+ *
32
+ * Wire conventions match the rest of reactivity: JSON, byte fields base64url, unix-ms timestamps, `v: 1`,
33
+ * per-message structural validation on decode. `ResumeV1` is signed by the subscriber's peer key
34
+ * (`signature`); replay protection rides the request envelope as elsewhere in the substrate.
35
+ */
36
+
37
+ import {
38
+ decodeCohortMessage,
39
+ encodeCohortMessage,
40
+ DEFAULT_MAX_MESSAGE_BYTES,
41
+ } from "../cohort-topic/wire/codec.js";
42
+ import {
43
+ asObject,
44
+ b64urlField,
45
+ failWire,
46
+ reqFiniteNumber,
47
+ reqIntInRange,
48
+ reqString,
49
+ requireV1,
50
+ } from "./wire-validate.js";
51
+ import { validateCheckpointSummary, verifyCheckpointEndpoints, type CheckpointSummary, type RollingCheckpoint } from "./checkpoint.js";
52
+ import { checkpointCovers } from "./rotation.js";
53
+ import type { ReplayBuffer } from "./replay-buffer.js";
54
+ import type { ReactivitySubscriber } from "./subscriber.js";
55
+ import type { NotificationVerifier } from "./verify.js";
56
+ import { validateNotificationArray, type NotificationV1 } from "./wire.js";
57
+
58
+ /** The four resume classifications (`docs/reactivity.md` §Resume). */
59
+ export type ResumeResult = "backfill" | "checkpoint_window" | "out_of_window" | "tail_rotated";
60
+
61
+ /** A subscriber's resume request after waking from sleep. */
62
+ export interface ResumeV1 {
63
+ v: 1;
64
+ /** Collection id, base64url. */
65
+ collectionId: string;
66
+ /** First revision the subscriber still needs (`lastRevision + 1`). */
67
+ fromRevision: number;
68
+ /** Tail block id the subscriber believes is current, base64url (the stale-tail detector). */
69
+ latestKnownTailId: string;
70
+ /** The subscriber's ring coordinate, base64url (for the cohort to route the reply). */
71
+ subscriberCoord: string;
72
+ /** Unix ms. */
73
+ timestamp: number;
74
+ /** Subscriber peer-key signature over the request. */
75
+ signature: string;
76
+ }
77
+
78
+ /** The cohort's classified reply. Fields are populated per the `result` discriminant. */
79
+ export interface ResumeReplyV1 {
80
+ v: 1;
81
+ result: ResumeResult;
82
+ // --- backfill ---
83
+ /** The `[fromRevision, currentRevision]` slice held in the replay ring (backfill). */
84
+ entries?: NotificationV1[];
85
+ /** Highest committed revision the serving cohort knows (backfill / checkpoint_window / out_of_window). */
86
+ currentRevision?: number;
87
+ // --- checkpoint_window ---
88
+ /**
89
+ * The parent-checkpoint chain spanning `[chain[0].fromRevision, ringLow − 1]` (checkpoint_window), ordered
90
+ * low→high and **contiguous**: each `checkpoints[i].fromRevision === checkpoints[i-1].toRevision + 1`. A
91
+ * single link in steady state; a cross-rotation resume carries the two-link `[inherited, rolling]` bridge.
92
+ * The subscriber verifies and applies every link in sequence (see {@link applyResumeReply}).
93
+ */
94
+ checkpoints?: CheckpointSummary[];
95
+ /** The replay ring's entries, replayed deduped against `lastRevision` after the digests apply. */
96
+ recentEntries?: NotificationV1[];
97
+ // --- out_of_window ---
98
+ /** Current tail block id, base64url — the subscriber re-subscribes after a chain read (out_of_window). */
99
+ currentTailId?: string;
100
+ // --- tail_rotated ---
101
+ /** New tail block id the topic rotated to, base64url (tail_rotated). */
102
+ newTailId?: string;
103
+ /** Revision at which the rotation took effect (tail_rotated). */
104
+ newRevisionAtRotation?: number;
105
+ }
106
+
107
+ // --- validation ---
108
+
109
+ /** Narrow an already-parsed value to {@link ResumeV1}, throwing on any defect. */
110
+ export function validateResumeV1(value: unknown): ResumeV1 {
111
+ const what = "ResumeV1";
112
+ const obj = asObject(value, what);
113
+ requireV1(obj, what);
114
+ return {
115
+ v: 1,
116
+ collectionId: b64urlField(reqString(obj, "collectionId", what), "collectionId", what),
117
+ fromRevision: reqIntInRange(obj, "fromRevision", what, 0),
118
+ latestKnownTailId: b64urlField(reqString(obj, "latestKnownTailId", what), "latestKnownTailId", what),
119
+ subscriberCoord: b64urlField(reqString(obj, "subscriberCoord", what), "subscriberCoord", what),
120
+ timestamp: reqFiniteNumber(obj, "timestamp", what),
121
+ signature: b64urlField(reqString(obj, "signature", what), "signature", what),
122
+ };
123
+ }
124
+
125
+ /** Narrow an already-parsed value to {@link ResumeReplyV1}, throwing on any defect. */
126
+ export function validateResumeReplyV1(value: unknown): ResumeReplyV1 {
127
+ const what = "ResumeReplyV1";
128
+ const obj = asObject(value, what);
129
+ requireV1(obj, what);
130
+ const result = obj["result"];
131
+ if (result !== "backfill" && result !== "checkpoint_window" && result !== "out_of_window" && result !== "tail_rotated") {
132
+ failWire(`${what}: field "result" must be one of backfill|checkpoint_window|out_of_window|tail_rotated`);
133
+ }
134
+ const out: ResumeReplyV1 = { v: 1, result };
135
+ switch (result) {
136
+ case "backfill": {
137
+ out.entries = validateNotificationArray(obj["entries"], what);
138
+ out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
139
+ break;
140
+ }
141
+ case "checkpoint_window": {
142
+ out.checkpoints = validateCheckpointChain(obj["checkpoints"], `${what}.checkpoints`);
143
+ out.recentEntries = validateNotificationArray(obj["recentEntries"], what);
144
+ if (obj["currentRevision"] !== undefined) {
145
+ out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
146
+ }
147
+ break;
148
+ }
149
+ case "out_of_window": {
150
+ out.currentTailId = b64urlField(reqString(obj, "currentTailId", what), "currentTailId", what);
151
+ out.currentRevision = reqIntInRange(obj, "currentRevision", what, 0);
152
+ break;
153
+ }
154
+ case "tail_rotated": {
155
+ out.newTailId = b64urlField(reqString(obj, "newTailId", what), "newTailId", what);
156
+ out.newRevisionAtRotation = reqIntInRange(obj, "newRevisionAtRotation", what, 0);
157
+ break;
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * Validate a `checkpoint_window` reply's checkpoint chain: a **non-empty**, **ascending**, internally
165
+ * **contiguous** list of {@link CheckpointSummary}s sharing one `collectionId`. Each link is validated via
166
+ * {@link validateCheckpointSummary}, and for `i ≥ 1` the chain must satisfy
167
+ * `checkpoints[i].fromRevision === checkpoints[i-1].toRevision + 1` (a gapped, overlapping, or misordered
168
+ * chain is rejected). Runs on decode, so a malformed chain never reaches {@link applyResumeReply} over the
169
+ * wire.
170
+ */
171
+ function validateCheckpointChain(value: unknown, what: string): CheckpointSummary[] {
172
+ if (!Array.isArray(value) || value.length === 0) {
173
+ failWire(`${what}: must be a non-empty array of checkpoint summaries`);
174
+ }
175
+ const chain = value.map((el, i) => validateCheckpointSummary(el, `${what}[${i}]`));
176
+ for (let i = 0; i < chain.length; i++) {
177
+ if (chain[i]!.collectionId !== chain[0]!.collectionId) {
178
+ failWire(`${what}: all checkpoints must share one collectionId`);
179
+ }
180
+ if (i >= 1 && chain[i]!.fromRevision !== chain[i - 1]!.toRevision + 1) {
181
+ failWire(`${what}: chain must be ascending and contiguous (checkpoints[${i}].fromRevision must equal checkpoints[${i - 1}].toRevision + 1)`);
182
+ }
183
+ }
184
+ return chain;
185
+ }
186
+
187
+ // --- canonical signing payload ---
188
+
189
+ const utf8 = new TextEncoder();
190
+
191
+ /** A {@link ResumeV1} minus its `signature` — the canonical bytes the subscriber peer-key-signs. */
192
+ export type ResumeSignable = Omit<ResumeV1, "signature">;
193
+
194
+ /**
195
+ * Canonical signed byte image of a {@link ResumeV1} (every field except `signature`). Mirrors
196
+ * {@link import("./backfill.js").backfillSigningPayload}: an explicitly-ordered, type-tagged JSON array
197
+ * encoded as UTF-8. The `"ResumeV1"` tag means a resume image can never collide with the `"BackfillV1"`
198
+ * image. `subscriberCoord` is part of the image, so a verifier recomputes over whatever coordinate the
199
+ * request carries — the field order is the byte-for-byte contract between signer and verifier.
200
+ */
201
+ export function resumeSigningPayload(body: ResumeSignable): Uint8Array {
202
+ return utf8.encode(JSON.stringify([
203
+ "ResumeV1",
204
+ body.v,
205
+ body.collectionId,
206
+ body.fromRevision,
207
+ body.latestKnownTailId,
208
+ body.subscriberCoord,
209
+ body.timestamp,
210
+ ]));
211
+ }
212
+
213
+ // --- codecs (length-framed JSON, mirroring the rest of reactivity) ---
214
+
215
+ /** Encode a {@link ResumeV1} as a length-prefixed UTF-8 JSON frame. */
216
+ export function encodeResumeV1(msg: ResumeV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
217
+ return encodeCohortMessage(validateResumeV1(msg), maxMessageBytes);
218
+ }
219
+
220
+ /** Decode a length-prefixed {@link ResumeV1} frame. */
221
+ export function decodeResumeV1(bytes: Uint8Array, maxMessageBytes?: number): ResumeV1 {
222
+ return validateResumeV1(decodeCohortMessage(bytes, maxMessageBytes));
223
+ }
224
+
225
+ /** Encode a {@link ResumeReplyV1} as a length-prefixed UTF-8 JSON frame. */
226
+ export function encodeResumeReplyV1(msg: ResumeReplyV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
227
+ return encodeCohortMessage(validateResumeReplyV1(msg), maxMessageBytes);
228
+ }
229
+
230
+ /** Decode a length-prefixed {@link ResumeReplyV1} frame. */
231
+ export function decodeResumeReplyV1(bytes: Uint8Array, maxMessageBytes?: number): ResumeReplyV1 {
232
+ return validateResumeReplyV1(decodeCohortMessage(bytes, maxMessageBytes));
233
+ }
234
+
235
+ // --- serving-cohort classification ---
236
+
237
+ /** The rolling checkpoint's summary; non-undefined because the caller already proved it abuts the ring (so non-empty). */
238
+ function requireRollingSummary(rolling: RollingCheckpoint): CheckpointSummary {
239
+ const summary = rolling.summary();
240
+ if (summary === undefined) {
241
+ throw new Error("resumeCheckpointChain: rolling checkpoint abuts the ring yet produced no summary (invariant violation)");
242
+ }
243
+ return summary;
244
+ }
245
+
246
+ /**
247
+ * Build the served checkpoint chain for a `checkpoint_window` resume: the **shortest** contiguous low→high
248
+ * chain of {@link CheckpointSummary}s that both covers `fromRevision` and **abuts the ring's low edge**
249
+ * (`top.toRevision === ringLow − 1`), or `undefined` when no gap-free chain exists. Shared by
250
+ * {@link classifyResume} and {@link serveResume} so they cannot disagree about the served shape.
251
+ *
252
+ * Cases are evaluated in precedence order (this ordering preserves every existing classification test):
253
+ *
254
+ * - **A — rolling wins.** The rolling checkpoint abuts the ring (it is fed by ring eviction, so it always
255
+ * does) and covers `fromRevision`: the fresher, narrower single link `[rolling]`.
256
+ * - **B1 — inherited abuts the ring directly.** No new-tail rolling checkpoint has formed between the
257
+ * inherited handoff and the ring yet (the `12.51` abut case): the single link `[inherited]`.
258
+ * - **B2 — the bridge.** The inherited handoff abuts the new tail's rolling checkpoint, which abuts the
259
+ * ring: the two-link `[inherited, rolling]` chain spans inherited → rolling → ring with no gap.
260
+ *
261
+ * Any other shape (a gap below `fromRevision`, or a gap between the windows) returns `undefined` → the
262
+ * request falls to `out_of_window` (an honest chain read). `ringLow === undefined` (no live ring to abut /
263
+ * no `recentEntries`) also returns `undefined`.
264
+ */
265
+ function resumeCheckpointChain(
266
+ fromRevision: number,
267
+ ringLow: number | undefined,
268
+ rolling: RollingCheckpoint | undefined,
269
+ inherited: CheckpointSummary | undefined,
270
+ ): CheckpointSummary[] | undefined {
271
+ if (ringLow === undefined) {
272
+ return undefined;
273
+ }
274
+ const ringAbut = ringLow - 1;
275
+
276
+ // Case A — the rolling checkpoint wins (fresher, narrower). `toRevision === ringAbut` implies it is
277
+ // non-empty, so the summary is defined (asserted in requireRollingSummary).
278
+ if (rolling !== undefined && rolling.toRevision === ringAbut && rolling.covers(fromRevision)) {
279
+ return [requireRollingSummary(rolling)];
280
+ }
281
+ // Case B1 — the inherited handoff abuts the ring directly (the 12.51 abut case): a single link.
282
+ if (inherited !== undefined && inherited.toRevision === ringAbut && checkpointCovers(inherited, fromRevision)) {
283
+ return [inherited];
284
+ }
285
+ // Case B2 — the bridge: inherited abuts the rolling checkpoint, which abuts the ring. The two-link chain
286
+ // carries inherited → rolling → ring with no gap, and each link keeps its own already-correct merged
287
+ // digest (nothing is re-folded, so a per-collection override fold composes trivially).
288
+ if (
289
+ inherited !== undefined &&
290
+ rolling !== undefined &&
291
+ rolling.toRevision === ringAbut &&
292
+ inherited.toRevision + 1 === rolling.fromRevision &&
293
+ checkpointCovers(inherited, fromRevision)
294
+ ) {
295
+ return [inherited, requireRollingSummary(rolling)];
296
+ }
297
+ return undefined;
298
+ }
299
+
300
+ /**
301
+ * Classify a resume request against the serving cohort's stacked windows. The decision order is:
302
+ *
303
+ * 1. **tail_rotated** — `req.latestKnownTailId !== currentTailId`. A stale tail means the tree migrated,
304
+ * so the lag classification below is moot.
305
+ * 2. **backfill** — `fromRevision` is at/above the replay ring's low edge (within, or already current).
306
+ * 3. **checkpoint_window** — below the ring but a gap-free {@link resumeCheckpointChain} covers it: the
307
+ * rolling checkpoint (steady state), the inherited cross-rotation handoff (`docs/reactivity.md` §Tail
308
+ * rotation step 5 — "the new tail holds the old checkpoint"), or the two-link `[inherited, rolling]`
309
+ * bridge spanning both when the new tail's own rolling checkpoint has formed between the handoff and the
310
+ * ring.
311
+ * 4. **out_of_window** — older than even the deepest window, or a gap leaves no contiguous chain to the
312
+ * ring (a chain read), or every window is empty/absent.
313
+ */
314
+ export function classifyResume(
315
+ req: ResumeV1,
316
+ buffer: ReplayBuffer,
317
+ checkpoint: RollingCheckpoint | undefined,
318
+ currentTailId: string,
319
+ inherited?: CheckpointSummary,
320
+ ): ResumeResult {
321
+ if (req.latestKnownTailId !== currentTailId) {
322
+ return "tail_rotated";
323
+ }
324
+ const low = buffer.lowRevision;
325
+ if (low !== undefined && req.fromRevision >= low) {
326
+ return "backfill";
327
+ }
328
+ return resumeCheckpointChain(req.fromRevision, low, checkpoint, inherited) !== undefined
329
+ ? "checkpoint_window"
330
+ : "out_of_window";
331
+ }
332
+
333
+ /** Construction inputs for {@link serveResume}. */
334
+ export interface ResumeServingDeps {
335
+ /** The cohort's replay ring (the live `PushState.replayBuffer`). */
336
+ readonly buffer: ReplayBuffer;
337
+ /** The cohort's rolling parent checkpoint (the live `PushState.checkpoint`). */
338
+ readonly checkpoint?: RollingCheckpoint;
339
+ /**
340
+ * The checkpoint migrated from the **outgoing** tail when this cohort became the new tail across a
341
+ * rotation (the live `PushState.inheritedCheckpoint`). Consulted when the rolling {@link checkpoint} does
342
+ * not cover `fromRevision`: a resume whose span crosses the rotation is answered from the inherited window
343
+ * — either alone (it abuts the ring directly) or as the lower link of the two-link `[inherited, rolling]`
344
+ * bridge when the new tail's own rolling checkpoint has formed between the handoff and the ring (see
345
+ * {@link resumeCheckpointChain}) — instead of falling to `out_of_window` (`docs/reactivity.md` §Tail
346
+ * rotation step 5).
347
+ */
348
+ readonly inheritedCheckpoint?: CheckpointSummary;
349
+ /** The cohort's current tail block id, base64url. */
350
+ readonly currentTailId: string;
351
+ /** Highest committed revision the cohort knows (the live `PushState.lastRevision`). */
352
+ readonly currentRevision: number;
353
+ /** Revision the current tail became effective (for a tail_rotated reply); defaults to `currentRevision`. */
354
+ readonly rotationRevision?: number;
355
+ /** Collection this cohort serves, base64url — a foreign-collection request is rejected. */
356
+ readonly expectedCollectionId: string;
357
+ }
358
+
359
+ /**
360
+ * Serve a {@link ResumeV1} from a cohort's stacked windows, producing the classified {@link ResumeReplyV1}.
361
+ * Pure over the supplied snapshot — no I/O, no clock. A request whose `collectionId` does not match
362
+ * `expectedCollectionId` is rejected (a resume is collection-scoped).
363
+ */
364
+ export function serveResume(req: ResumeV1, deps: ResumeServingDeps): ResumeReplyV1 {
365
+ if (req.collectionId !== deps.expectedCollectionId) {
366
+ failWire(`serveResume: request collectionId does not match this cohort's collection`);
367
+ }
368
+ const result = classifyResume(req, deps.buffer, deps.checkpoint, deps.currentTailId, deps.inheritedCheckpoint);
369
+ switch (result) {
370
+ case "backfill": {
371
+ const high = deps.buffer.highRevision ?? deps.currentRevision;
372
+ const entries = deps.buffer.range(req.fromRevision, high).map((e) => e.payload);
373
+ return { v: 1, result, entries, currentRevision: deps.currentRevision };
374
+ }
375
+ case "checkpoint_window": {
376
+ // The contiguous chain the helper built — rolling alone, inherited alone, or the two-link bridge.
377
+ // classifyResume returned checkpoint_window iff this chain is present, so it is non-undefined here
378
+ // (the two calls share inputs, so they cannot disagree).
379
+ const chain = resumeCheckpointChain(req.fromRevision, deps.buffer.lowRevision, deps.checkpoint, deps.inheritedCheckpoint);
380
+ if (chain === undefined) {
381
+ throw new Error("serveResume: checkpoint_window classification produced no checkpoint chain (invariant violation)");
382
+ }
383
+ const recentEntries = deps.buffer.entries().map((e) => e.payload);
384
+ return { v: 1, result, checkpoints: chain, recentEntries, currentRevision: deps.currentRevision };
385
+ }
386
+ case "out_of_window":
387
+ return { v: 1, result, currentTailId: deps.currentTailId, currentRevision: deps.currentRevision };
388
+ case "tail_rotated":
389
+ return {
390
+ v: 1,
391
+ result,
392
+ newTailId: deps.currentTailId,
393
+ newRevisionAtRotation: deps.rotationRevision ?? deps.currentRevision,
394
+ };
395
+ }
396
+ }
397
+
398
+ // --- subscriber-side apply ---
399
+
400
+ /** The outcome of applying a {@link ResumeReplyV1} subscriber-side. */
401
+ export type ResumeApplyOutcome =
402
+ /** Backfill entries were replayed through the delivery path; the subscriber is current. */
403
+ | "backfilled"
404
+ /** Checkpoint endpoints verified, digest applied, recent entries replayed; the subscriber is current. */
405
+ | "checkpoint_applied"
406
+ /** Checkpoint bracketing endpoints failed to verify (forged/tampered) — the subscriber must chain-read. */
407
+ | "checkpoint_untrusted"
408
+ /** Out of every window — the subscriber must chain-read then re-subscribe. */
409
+ | "out_of_window"
410
+ /** Tail rotated — the subscriber must re-register under the new tail. */
411
+ | "tail_rotated";
412
+
413
+ /** Sinks for {@link applyResumeReply} — each non-backfill variant escalates to the application. */
414
+ export interface ResumeApplyDeps {
415
+ /** Feeds replayed entries through the verify → contiguity → deliver path (closing the gap, deduping). */
416
+ readonly subscriber: ReactivitySubscriber;
417
+ /** Verifies a checkpoint's bracketing endpoints are real committed revisions. */
418
+ readonly verifier: NotificationVerifier;
419
+ /** Apply a verified checkpoint's merged digest (e.g. invalidate the changed keys). */
420
+ readonly onCheckpointDigest?: (summary: CheckpointSummary) => void;
421
+ /** Fall back to a chain read + fresh subscribe (out_of_window, or an untrusted checkpoint). */
422
+ readonly onChainRead?: (currentTailId: string | undefined, currentRevision: number | undefined) => void;
423
+ /** Re-register under the rotated tail (tail_rotated). */
424
+ readonly onTailRotated?: (newTailId: string, newRevisionAtRotation: number) => void;
425
+ }
426
+
427
+ /**
428
+ * Apply a {@link ResumeReplyV1} on the subscriber side, returning the {@link ResumeApplyOutcome}. Backfill
429
+ * and checkpoint-window entries re-enter through {@link ReactivitySubscriber.onNotification} (so they are
430
+ * verified, contiguity-checked, and deduped exactly like live notifications); the other variants escalate
431
+ * to the application via the supplied sinks. A checkpoint whose bracketing endpoints do not verify is
432
+ * **not** applied — the subscriber chain-reads instead (a forged checkpoint must never advance state).
433
+ */
434
+ export async function applyResumeReply(reply: ResumeReplyV1, deps: ResumeApplyDeps): Promise<ResumeApplyOutcome> {
435
+ switch (reply.result) {
436
+ case "backfill": {
437
+ for (const n of reply.entries ?? []) {
438
+ await deps.subscriber.onNotification(n);
439
+ }
440
+ return "backfilled";
441
+ }
442
+ case "checkpoint_window": {
443
+ const summaries = reply.checkpoints;
444
+ if (summaries === undefined || summaries.length === 0) {
445
+ failWire(`applyResumeReply: checkpoint_window reply is missing its checkpoint chain`);
446
+ }
447
+ // Low-edge guard on the **lowest** link: endpoint verification proves each link's bounds are *real*
448
+ // committed revisions, but not that the chain connects to the subscriber's contiguity head. If the
449
+ // lowest `fromRevision` sits above `lastRevision + 1` there is an un-summarized gap below the chain;
450
+ // rebaselining would silently skip those revisions. The honest cohort never sends such a reply
451
+ // (`classifyResume` only picks `checkpoint_window` when the chain covers `lastRevision + 1`), so a
452
+ // non-abutting low edge is a forged/buggy reply — chain-read rather than advance past a gap.
453
+ if (summaries[0]!.fromRevision > deps.subscriber.lastRevision + 1) {
454
+ deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
455
+ return "checkpoint_untrusted";
456
+ }
457
+ // Intra-chain contiguity (defense in depth: the codec already checks every *decoded* reply, but an
458
+ // in-process reply may bypass the codec). A gap/overlap between links would skip or double revisions.
459
+ for (let i = 1; i < summaries.length; i++) {
460
+ if (summaries[i]!.fromRevision !== summaries[i - 1]!.toRevision + 1) {
461
+ deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
462
+ return "checkpoint_untrusted";
463
+ }
464
+ }
465
+ // Verify EVERY link's bracketing endpoints **before applying anything** — a single forged link kills
466
+ // the whole reply, so nothing is delivered and `lastRevision` is unchanged (no partial advance).
467
+ for (const summary of summaries) {
468
+ const verdict = await verifyCheckpointEndpoints(summary, deps.verifier);
469
+ if (verdict !== "verified") {
470
+ deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
471
+ return "checkpoint_untrusted";
472
+ }
473
+ }
474
+ // Apply in order: the application sees each link's merged-digest hint, then the contiguity head
475
+ // advances past that link. `rebaseline` is monotone, so the head lands at the top link's
476
+ // `toRevision`; the recent entries (immediately above it) then replay gap-free — any recent entry
477
+ // at/below the head dedupes (`docs/reactivity.md` §Parent checkpoint summaries).
478
+ for (const summary of summaries) {
479
+ deps.onCheckpointDigest?.(summary);
480
+ deps.subscriber.rebaseline(summary.toRevision);
481
+ }
482
+ for (const n of reply.recentEntries ?? []) {
483
+ await deps.subscriber.onNotification(n);
484
+ }
485
+ return "checkpoint_applied";
486
+ }
487
+ case "out_of_window": {
488
+ deps.onChainRead?.(reply.currentTailId, reply.currentRevision);
489
+ return "out_of_window";
490
+ }
491
+ case "tail_rotated": {
492
+ deps.onTailRotated?.(reply.newTailId ?? "", reply.newRevisionAtRotation ?? 0);
493
+ return "tail_rotated";
494
+ }
495
+ }
496
+ }
497
+
498
+ // --- Edge sticky cohort-hint cache (one-RT resume after a flap) ---
499
+
500
+ /**
501
+ * A cached cohort hint for fast reactivity re-attach (`docs/reactivity.md` §Edge profile: "`cohortHint`
502
+ * is sticky-cached across reconnects so brief network flaps don't trigger re-walk"). Distinct from the
503
+ * cohort-topic `CohortHint` (the full resolved cohort) — this is the minimal sticky-cached slice the
504
+ * subscriber dials a resume at directly. All ids base64url.
505
+ */
506
+ export interface ReactivityCohortHint {
507
+ /** The collection's tail-anchored topic id at cache time. */
508
+ readonly topicId: string;
509
+ /** The cached serving primary. */
510
+ readonly primary: string;
511
+ /** The cohort member set for fast re-attach. */
512
+ readonly cohortHint: readonly string[];
513
+ }
514
+
515
+ /**
516
+ * A sticky per-collection cache of the last-known serving cohort, keyed by collection id. **Sticky** means
517
+ * it survives reconnects — a brief network flap reuses the cached primary so resume is one round trip
518
+ * instead of a re-walk from `d_max`. It is invalidated only on an explicit signal (a `TailRotated` reply,
519
+ * or a confirmed-stale primary), never merely on a transient failure.
520
+ */
521
+ export interface StickyCohortHintCache {
522
+ /** The cached hint for `collectionId`, or `undefined` if none. */
523
+ get(collectionId: string): ReactivityCohortHint | undefined;
524
+ /** Cache (or refresh) the hint for `collectionId`. */
525
+ set(collectionId: string, hint: ReactivityCohortHint): void;
526
+ /** Drop the cached hint for `collectionId` (on rotation / confirmed staleness). */
527
+ invalidate(collectionId: string): void;
528
+ }
529
+
530
+ class MapStickyCohortHintCache implements StickyCohortHintCache {
531
+ private readonly byCollection = new Map<string, ReactivityCohortHint>();
532
+
533
+ get(collectionId: string): ReactivityCohortHint | undefined {
534
+ return this.byCollection.get(collectionId);
535
+ }
536
+
537
+ set(collectionId: string, hint: ReactivityCohortHint): void {
538
+ this.byCollection.set(collectionId, hint);
539
+ }
540
+
541
+ invalidate(collectionId: string): void {
542
+ this.byCollection.delete(collectionId);
543
+ }
544
+ }
545
+
546
+ /** Build a {@link StickyCohortHintCache} (a plain per-collection map; sticky across reconnects). */
547
+ export function createStickyCohortHintCache(): StickyCohortHintCache {
548
+ return new MapStickyCohortHintCache();
549
+ }