@optimystic/db-p2p 0.24.2 → 0.25.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 (249) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,5 +1,6 @@
1
- import { Latches, applyTransform, hashString } from "@optimystic/db-core";
1
+ import { applyTransform, canonicalJson, hashString } from "@optimystic/db-core";
2
2
  import { mergeRanges } from "./helpers.js";
3
+ import { RevisionNotCoveredError } from "./i-block-storage.js";
3
4
  import { createLogger } from "../logger.js";
4
5
  const log = createLogger('block-storage');
5
6
  /**
@@ -29,13 +30,34 @@ export class BlockStorage {
29
30
  const meta = await this.storage.getMetadata(this.blockId);
30
31
  return meta?.latest;
31
32
  }
33
+ /**
34
+ * Guard every write: the token must have been minted for THIS block and must still be live. The
35
+ * type already proves the caller went through `acquireBlockWriteLatch`; this catches the two
36
+ * things the type cannot — a token for block A presented to block B's storage, and a token
37
+ * stashed by a callback and used after its scope released the latch.
38
+ */
39
+ assertLatch(latch) {
40
+ if (latch.blockId !== this.blockId) {
41
+ throw new Error(`Block ${this.blockId}: write latch was acquired for block ${latch.blockId}`);
42
+ }
43
+ if (!latch.live) {
44
+ throw new Error(`Block ${this.blockId}: write latch has already been released`);
45
+ }
46
+ }
47
+ /**
48
+ * LOCAL-ONLY read — never fetches from a peer. A revision outside `meta.ranges` throws
49
+ * {@link RevisionNotCoveredError}; the one caller allowed to heal that (`StorageRepo.get`) does so
50
+ * with {@link restoreRevision} under the block's write latch and re-reads. Keeping the fetch out of
51
+ * here is what lets the commit path hold N block latches with no network I/O inside them.
52
+ */
32
53
  async getBlock(rev) {
33
54
  const meta = await this.storage.getMetadata(this.blockId);
34
55
  if (!meta) {
35
56
  // No metadata at all ⇒ this node has never seen the block, and reads report it absent
36
57
  // WITHOUT consulting `restoreCallback`. That is deliberate, not an oversight: `restoreCallback`
37
- // is reachable only from ensureRevision below, so a never-seen block is never fetched HERE.
38
- // Attempting a fetch at this layer would turn every read of a genuinely non-existent block
58
+ // is reachable only from restoreRevision, which a caller invokes only after THIS method has
59
+ // reported a coverage gap on a block that has metadata so a never-seen block is never fetched.
60
+ // Attempting a fetch for it would turn every read of a genuinely non-existent block —
39
61
  // the common case for an insert probing for a collision — into a network round trip, because
40
62
  // storage cannot tell "nobody has this" from "I don't have this".
41
63
  //
@@ -45,56 +67,71 @@ export class BlockStorage {
45
67
  // nothing beyond the latest-query it already performed. Keep this early return as-is.
46
68
  return undefined;
47
69
  }
48
- // Pending-only state: metadata was seeded by savePendingTransaction but no revision has been
49
- // committed yet. "No committed base here" is an ABSENCE, not a fault — nothing is being
50
- // FAILED to reconstruct so both arms below answer `undefined` rather than throwing, whether
51
- // or not the caller named a revision. StorageRepo.get then applies any pending overlay over
52
- // that absent base; a throw here would instead be caught into `unavailable: 'unmaterializable'`
53
- // and a writer reading back its own not-yet-committed insert would be told it is unreadable.
54
- // `unmaterializable` must keep its one meaning: records prove the block exists and this node
55
- // cannot reconstruct it.
56
- if (meta.latest === undefined) {
57
- if (rev === undefined) {
58
- return undefined;
59
- }
60
- // A named rev still ATTEMPTS the restore: `restoreCallback` may be able to supply that
61
- // revision even though nothing is committed locally, and a successful restore serves real
62
- // content with `latest` still undefined. That capability is pinned by the 'getBlock for an
63
- // absent revision fires restoreCallback (restore not short-circuited)' test in
64
- // test/block-storage.spec.ts — do not short-circuit it away.
65
- //
66
- // Only ensureRevision's FAILURE is swallowed (no callback wired, or restore could not
67
- // supply the rev): that is precisely the "no committed base here" absence. materializeBlock
68
- // below is deliberately OUTSIDE the try — a throw from there means revision records exist
69
- // with no materialization anywhere under them, which is genuine corruption and must keep
70
- // reading as `unmaterializable`.
71
- //
72
- // NOTE: a contextful read of a pending-only block still attempts a network restore before
73
- // falling back to absent (same cost as the pre-fix throw path); if pending-only read-backs
74
- // ever show as hot, short-circuit when ranges are empty.
75
- try {
76
- await this.ensureRevision(meta, rev);
77
- }
78
- catch (err) {
79
- log('getBlock:no-committed-base blockId=%s rev=%d error=%s', this.blockId, rev, err instanceof Error ? err.message : String(err));
80
- return undefined;
81
- }
82
- return await this.materializeBlock(meta, rev);
70
+ // Pending-only state (metadata seeded by savePendingTransaction, nothing committed) with no
71
+ // revision named: "no committed base here" is an ABSENCE, not a fault — answer `undefined`.
72
+ // StorageRepo.get then applies any pending overlay over that absent base. A NAMED rev on a
73
+ // pending-only block falls through: it is covered only if an earlier restore brought it in
74
+ // (a restore can serve real content with `latest` still undefined), otherwise it reports the
75
+ // gap below and StorageRepo.get decides whether the failed restore reads as absent.
76
+ if (meta.latest === undefined && rev === undefined) {
77
+ return undefined;
83
78
  }
84
79
  const targetRev = rev ?? meta.latest.rev;
85
- await this.ensureRevision(meta, targetRev);
80
+ if (!this.inRanges(targetRev, meta.ranges)) {
81
+ throw new RevisionNotCoveredError(this.blockId, targetRev);
82
+ }
83
+ // A throw from here means revision records exist with no materialization anywhere under them —
84
+ // genuine corruption, which StorageRepo.get reports as `unmaterializable`.
86
85
  return await this.materializeBlock(meta, targetRev);
87
86
  }
87
+ async restoreRevision(rev, latch) {
88
+ this.assertLatch(latch);
89
+ // One metadata read, under the held latch: the caller's earlier `getBlock` observed a gap, but
90
+ // a queued-ahead restore or replica may have filled it before this latch was granted.
91
+ const meta = await this.storage.getMetadata(this.blockId);
92
+ if (!meta) {
93
+ // Same reasoning as getBlock's early return: a never-seen block is not restored here.
94
+ throw new Error(`Block ${this.blockId} has no metadata; a never-seen block is not restored here.`);
95
+ }
96
+ if (this.inRanges(rev, meta.ranges)) {
97
+ return;
98
+ }
99
+ const restored = await this.restoreBlock(rev);
100
+ // An archive off this wire is a peer's UNVERIFIED answer (see {@link saveRestored}), so it
101
+ // is vetted before a byte of it reaches storage. A rejected archive is indistinguishable to
102
+ // the caller from an absent one — same throw — because both mean the same thing: this node
103
+ // still cannot serve `rev`. The specific reason is logged rather than thrown so that
104
+ // `StorageRepo.get`'s healing helper keeps one rule for every restore failure.
105
+ const coverage = restored ? await this.vetRestoredArchive(restored, rev) : undefined;
106
+ if (!restored || !coverage) {
107
+ throw new Error(`Block ${this.blockId} revision ${rev} not found during restore attempt.`);
108
+ }
109
+ await this.saveRestored(restored);
110
+ // The vetted coverage, NOT `restored.range`. The declared range is checked for internal
111
+ // consistency above but is not what gets recorded — see {@link vetRestoredArchive} for why
112
+ // the pin has to be folded in, or the same restore repeats on every read forever.
113
+ meta.ranges.unshift(coverage);
114
+ meta.ranges = mergeRanges(meta.ranges);
115
+ await this.storage.saveMetadata(this.blockId, meta);
116
+ }
88
117
  async getTransaction(actionId) {
89
118
  return await this.storage.getTransaction(this.blockId, actionId);
90
119
  }
120
+ async getBlockProof(rev) {
121
+ return await this.storage.getBlockProof(this.blockId, rev);
122
+ }
123
+ async saveBlockProof(rev, proof, latch) {
124
+ this.assertLatch(latch);
125
+ await this.storage.saveBlockProof(this.blockId, rev, proof);
126
+ }
91
127
  async getPendingTransaction(actionId) {
92
128
  return await this.storage.getPendingTransaction(this.blockId, actionId);
93
129
  }
94
130
  async *listPendingTransactions() {
95
131
  yield* this.storage.listPendingTransactions(this.blockId);
96
132
  }
97
- async savePendingTransaction(actionId, transform) {
133
+ async savePendingTransaction(actionId, transform, latch) {
134
+ this.assertLatch(latch);
98
135
  log('pend blockId=%s actionId=%s', this.blockId, actionId);
99
136
  let meta = await this.storage.getMetadata(this.blockId);
100
137
  if (!meta) {
@@ -102,23 +139,29 @@ export class BlockStorage {
102
139
  // nothing yet: seed empty ranges. The first commit anchors an OPEN-ENDED span at
103
140
  // the earliest held rev E ([E, +inf)); later commits/recover merge into it via
104
141
  // setLatest/recover. Seeding open-ended `[[0]]` would falsely claim coverage of the
105
- // un-held revs below E and disable ensureRevision's restore path.
142
+ // un-held revs below E and disable restoreRevision's restore path.
143
+ //
144
+ // This read-then-seed is exactly the window a concurrent replica used to land in (the
145
+ // seed then erased its `latest`); the latch the caller holds is what closes it.
106
146
  meta = { latest: undefined, ranges: [] };
107
147
  await this.storage.saveMetadata(this.blockId, meta);
108
148
  }
109
149
  await this.storage.savePendingTransaction(this.blockId, actionId, transform);
110
150
  }
111
- async deletePendingTransaction(actionId) {
151
+ async deletePendingTransaction(actionId, latch) {
152
+ this.assertLatch(latch);
112
153
  log('cancel blockId=%s actionId=%s', this.blockId, actionId);
113
154
  await this.storage.deletePendingTransaction(this.blockId, actionId);
114
155
  }
115
156
  async *listRevisions(startRev, endRev) {
116
157
  yield* this.storage.listRevisions(this.blockId, startRev, endRev);
117
158
  }
118
- async saveMaterializedBlock(actionId, block) {
159
+ async saveMaterializedBlock(actionId, block, latch) {
160
+ this.assertLatch(latch);
119
161
  await this.storage.saveMaterializedBlock(this.blockId, actionId, block);
120
162
  }
121
- async pruneSupersededMaterialization(prior) {
163
+ async pruneSupersededMaterialization(prior, latch) {
164
+ this.assertLatch(latch);
122
165
  const meta = await this.storage.getMetadata(this.blockId);
123
166
  // No metadata / no committed tip yet ⇒ nothing has superseded `prior`; leave it.
124
167
  if (!meta || meta.latest === undefined) {
@@ -136,14 +179,17 @@ export class BlockStorage {
136
179
  await this.storage.saveMaterializedBlock(this.blockId, prior.actionId, undefined);
137
180
  log('prune blockId=%s rev=%d actionId=%s', this.blockId, prior.rev, prior.actionId);
138
181
  }
139
- async saveRevision(rev, actionId) {
182
+ async saveRevision(rev, actionId, latch) {
183
+ this.assertLatch(latch);
140
184
  await this.storage.saveRevision(this.blockId, rev, actionId);
141
185
  }
142
- async promotePendingTransaction(actionId) {
186
+ async promotePendingTransaction(actionId, latch) {
187
+ this.assertLatch(latch);
143
188
  log('commit blockId=%s actionId=%s', this.blockId, actionId);
144
189
  await this.storage.promotePendingTransaction(this.blockId, actionId);
145
190
  }
146
- async setLatest(latest) {
191
+ async setLatest(latest, latch) {
192
+ this.assertLatch(latch);
147
193
  const meta = await this.storage.getMetadata(this.blockId);
148
194
  if (!meta) {
149
195
  throw new Error(`Block ${this.blockId} not found`);
@@ -162,13 +208,14 @@ export class BlockStorage {
162
208
  // the un-held revs below E). Claim open-ended from the prior latest (>= E via merge); the first
163
209
  // commit (prevRev undefined) anchors the span at E = L. mergeRanges folds it into the existing
164
210
  // [E, +inf). Only revs BELOW E miss inRanges, which is exactly the genuine-gap/restore case.
165
- // Range + latest advance in one saveMetadata write (atomic under the commit latch), so a crash
211
+ // Range + latest advance in one saveMetadata write (atomic under the block write latch), so a crash
166
212
  // before this call advances neither.
167
213
  meta.ranges.unshift([prevRev ?? latest.rev]);
168
214
  meta.ranges = mergeRanges(meta.ranges);
169
215
  await this.storage.saveMetadata(this.blockId, meta);
170
216
  }
171
- async recover() {
217
+ async recover(latch) {
218
+ this.assertLatch(latch);
172
219
  const meta = await this.storage.getMetadata(this.blockId);
173
220
  if (!meta) {
174
221
  return { reconciled: false };
@@ -204,7 +251,8 @@ export class BlockStorage {
204
251
  }
205
252
  return { reconciled: false, latest: meta.latest };
206
253
  }
207
- async saveReplica(block, source) {
254
+ async saveReplica(block, source, proof, latch) {
255
+ this.assertLatch(latch);
208
256
  const rev = source?.rev ?? 1;
209
257
  // Deterministic fallback id when the sender carried no revision metadata, so a
210
258
  // re-push of the same block resolves to the same (rev, actionId) and stays
@@ -213,9 +261,10 @@ export class BlockStorage {
213
261
  // Replica revision carries the materialized block. `{ insert: block }` satisfies saveRestored's
214
262
  // write invariants; on the serving path materializeBlock returns the materialized block directly
215
263
  // (single rev), so this transform is never applied — see ticket notes.
216
- return await this.saveForwardRevision(rev, actionId, { action: { actionId, rev, transform: { insert: block } }, block }, 'replica');
264
+ return await this.saveForwardRevision(rev, actionId, { action: { actionId, rev, transform: { insert: block } }, block }, 'replica', proof);
217
265
  }
218
- async saveDeletion(source) {
266
+ async saveDeletion(source, latch) {
267
+ this.assertLatch(latch);
219
268
  const { rev, actionId } = source;
220
269
  // Forward tombstone: a `{ delete: true }` transform and NO materialized block. saveRestored
221
270
  // skips materialization when `block` is absent, so the reverse-apply in materializeBlock
@@ -231,102 +280,84 @@ export class BlockStorage {
231
280
  * plus the materialized `block`; a deletion carries `{ delete: true }` and no block. `rev` and
232
281
  * `actionId` are passed alongside `body` because the guard and the `latest` advance need them
233
282
  * independently of the archive body.
283
+ *
284
+ * `verifiedProof` travels OUTSIDE `body` on purpose — see {@link saveRestored}: it is the one
285
+ * channel that persists a proof, and only {@link saveReplica} (whose caller verified the proof
286
+ * against `body.block`) supplies it.
234
287
  */
235
- async saveForwardRevision(rev, actionId, body, logLabel) {
236
- // Serialize the read-modify-write on this block's metadata (mirrors ensureRevision). saveReplica
237
- // and saveDeletion deliberately SHARE this one lock id (keyed `saveReplica`, NOT per-method):
238
- // both do a read-modify-write of `meta.latest`, so they must be mutually exclusive on this block
239
- // to keep the monotonic guard sound against a concurrent replica+deletion.
240
- const lockId = `BlockStorage.saveReplica:${this.blockId}`;
241
- const release = await Latches.acquire(lockId);
242
- try {
243
- let meta = await this.storage.getMetadata(this.blockId);
244
- // Monotonic guard: an equal-or-newer revision is already held. The block (or tombstone) is
245
- // durably present; do not downgrade `latest` or rewrite the metadata.
246
- if (meta?.latest && meta.latest.rev >= rev) {
247
- log('%s:skip blockId=%s rev=%d held=%d', logLabel, this.blockId, rev, meta.latest.rev);
248
- return meta.latest;
249
- }
250
- // One-revision archive. A replica's body carries the materialized block; a deletion's body
251
- // omits it (forward tombstone). saveRestored skips materialization when `block` is absent,
252
- // so a tombstone reverse-applies to an absent block (read back as undefined).
253
- const archive = {
254
- blockId: this.blockId,
255
- revisions: {
256
- [rev]: body
257
- },
258
- range: [rev, rev + 1]
259
- };
260
- await this.saveRestored(archive);
261
- // INVARIANT P: a block never holds a pending record AND a committed record for the same
262
- // action id. On the commit path `promotePendingTransaction` maintains it by MOVING the
263
- // record atomically; this forward path writes the committed transform directly (via
264
- // saveRestored above), so it owes the deletion itself. Without it, a node that pended the
265
- // action but diverged before committing keeps a record nothing can ever promote — reported
266
- // as a phantom conflicting action by every later `pend` on the block, which under
267
- // `policy: 'f'` refuses that node's participation in the block's writes permanently.
268
- //
269
- // Deliberately on the WRITE path only: the monotonic guard above returns before here, and
270
- // that early return must stay a true no-op (the earlier call that wrote the revision is the
271
- // one that owed the deletion). Deliberately here rather than in `saveRestored`, which is
272
- // also reached from ensureRevision's historical restore under a different latch, where a
273
- // deletion could race a concurrent promotePendingTransaction; this path holds
274
- // `BlockStorage.saveReplica:<id>` and (via StorageRepo.saveReplicatedBlock) the per-block
275
- // commit latch, so it is already mutually exclusive with a live commit.
276
- //
277
- // NOTE: deletes only this revision's actionId, not every pending whose action is already
278
- // committed. A broader sweep would repair records orphaned by routes that do not carry the
279
- // committing actionId; if orphaned pendings ever show up in the field on blocks whose
280
- // committing action id differs, widen to a sweep over listPendingTransactions filtered by
281
- // getTransaction.
282
- await this.storage.deletePendingTransaction(this.blockId, actionId);
283
- // Seed metadata when absent, advance latest, and merge the covered range.
284
- const prevRev = meta?.latest?.rev;
285
- if (!meta) {
286
- meta = { latest: undefined, ranges: [] };
287
- }
288
- meta.latest = { rev, actionId };
289
- // Open-ended coverage from the earliest held rev (see setLatest): the descending walk serves
290
- // any rev >= the anchor. A prior latest at prevRev (< rev per the monotonic guard) is a
291
- // materialized point, so anchor at prevRev; the first write (prevRev undefined) anchors at
292
- // rev. Freshness of a stale replica is a separate (replication-lag) concern from what this
293
- // node can locally reconstruct, which is exactly what ranges records.
294
- meta.ranges.unshift([prevRev ?? rev]);
295
- meta.ranges = mergeRanges(meta.ranges);
296
- await this.storage.saveMetadata(this.blockId, meta);
297
- log('%s:save blockId=%s rev=%d actionId=%s', logLabel, this.blockId, rev, actionId);
288
+ async saveForwardRevision(rev, actionId, body, logLabel, verifiedProof) {
289
+ // The read-modify-write of this block's metadata below is serialized by the write latch the
290
+ // caller already holds (asserted in saveReplica / saveDeletion) the same latch every other
291
+ // writer of this block holds, so a concurrent replica, deletion, restore, commit, or pend
292
+ // cannot land inside the window between the read and the saveMetadata.
293
+ let meta = await this.storage.getMetadata(this.blockId);
294
+ // Monotonic guard: an equal-or-newer revision is already held. The block (or tombstone) is
295
+ // durably present; do not downgrade `latest` or rewrite the metadata.
296
+ //
297
+ // This skip returns before persisting anything, INCLUDING `verifiedProof` deliberately.
298
+ // The proof was verified against the PUSHED bytes; persisting it here would attach it to
299
+ // this node's HELD materialization, whose bytes at the same `(rev, actionId)` may differ if
300
+ // this holder diverged. A stored proof whose declared digest contradicts local content makes
301
+ // this node serve content that fails its own proof, and `digest-mismatch` is ATTRIBUTABLE in
302
+ // `cluster/certified-claims.ts` — every receiver would penalize it.
303
+ //
304
+ // Back-filling a proof onto an already-held revision therefore happens one layer up, in
305
+ // `StorageRepo.saveReplicatedBlock`'s non-advancing branch, which routes it through
306
+ // `backFillProof` `persistProofIfContentMatches` — the rule that persists only when the
307
+ // LOCAL materialization matches the digest the commit op declared. Keep this guard a true
308
+ // no-op; the digest check is what makes the back-fill safe, and it does not belong here.
309
+ if (meta?.latest && meta.latest.rev >= rev) {
310
+ log('%s:skip blockId=%s rev=%d held=%d', logLabel, this.blockId, rev, meta.latest.rev);
298
311
  return meta.latest;
299
312
  }
300
- finally {
301
- release();
302
- }
303
- }
304
- async ensureRevision(meta, rev) {
305
- if (this.inRanges(rev, meta.ranges)) {
306
- return;
307
- }
308
- const lockId = `BlockStorage.ensureRevision:${this.blockId}`;
309
- const release = await Latches.acquire(lockId);
310
- try {
311
- const currentMeta = await this.storage.getMetadata(this.blockId);
312
- if (!currentMeta) {
313
- throw new Error(`Block ${this.blockId} metadata disappeared unexpectedly.`);
314
- }
315
- if (this.inRanges(rev, currentMeta.ranges)) {
316
- return;
317
- }
318
- const restored = await this.restoreBlock(rev);
319
- if (!restored) {
320
- throw new Error(`Block ${this.blockId} revision ${rev} not found during restore attempt.`);
321
- }
322
- await this.saveRestored(restored);
323
- currentMeta.ranges.unshift(restored.range);
324
- currentMeta.ranges = mergeRanges(currentMeta.ranges);
325
- await this.storage.saveMetadata(this.blockId, currentMeta);
326
- }
327
- finally {
328
- release();
313
+ // One-revision archive. A replica's body carries the materialized block; a deletion's body
314
+ // omits it (forward tombstone). saveRestored skips materialization when `block` is absent,
315
+ // so a tombstone reverse-applies to an absent block (read back as undefined).
316
+ const archive = {
317
+ blockId: this.blockId,
318
+ revisions: {
319
+ [rev]: body
320
+ },
321
+ range: [rev, rev + 1]
322
+ };
323
+ await this.saveRestored(archive, verifiedProof ? { rev, proof: verifiedProof } : undefined);
324
+ // INVARIANT P: a block never holds a pending record AND a committed record for the same
325
+ // action id. On the commit path `promotePendingTransaction` maintains it by MOVING the
326
+ // record atomically; this forward path writes the committed transform directly (via
327
+ // saveRestored above), so it owes the deletion itself. Without it, a node that pended the
328
+ // action but diverged before committing keeps a record nothing can ever promote — reported
329
+ // as a phantom conflicting action by every later `pend` on the block, which under
330
+ // `policy: 'f'` refuses that node's participation in the block's writes permanently.
331
+ //
332
+ // Deliberately on the WRITE path only: the monotonic guard above returns before here, and
333
+ // that early return must stay a true no-op (the earlier call that wrote the revision is the
334
+ // one that owed the deletion). Deliberately here rather than in `saveRestored`, which is
335
+ // also reached from restoreRevision's historical restore, where a held revision's pending
336
+ // record is not this writer's to delete. Both paths run under the block's write latch, so
337
+ // this deletion is already mutually exclusive with a live commit.
338
+ //
339
+ // NOTE: deletes only this revision's actionId, not every pending whose action is already
340
+ // committed. A broader sweep would repair records orphaned by routes that do not carry the
341
+ // committing actionId; if orphaned pendings ever show up in the field on blocks whose
342
+ // committing action id differs, widen to a sweep over listPendingTransactions filtered by
343
+ // getTransaction.
344
+ await this.storage.deletePendingTransaction(this.blockId, actionId);
345
+ // Seed metadata when absent, advance latest, and merge the covered range.
346
+ const prevRev = meta?.latest?.rev;
347
+ if (!meta) {
348
+ meta = { latest: undefined, ranges: [] };
329
349
  }
350
+ meta.latest = { rev, actionId };
351
+ // Open-ended coverage from the earliest held rev (see setLatest): the descending walk serves
352
+ // any rev >= the anchor. A prior latest at prevRev (< rev per the monotonic guard) is a
353
+ // materialized point, so anchor at prevRev; the first write (prevRev undefined) anchors at
354
+ // rev. Freshness of a stale replica is a separate (replication-lag) concern from what this
355
+ // node can locally reconstruct, which is exactly what ranges records.
356
+ meta.ranges.unshift([prevRev ?? rev]);
357
+ meta.ranges = mergeRanges(meta.ranges);
358
+ await this.storage.saveMetadata(this.blockId, meta);
359
+ log('%s:save blockId=%s rev=%d actionId=%s', logLabel, this.blockId, rev, actionId);
360
+ return meta.latest;
330
361
  }
331
362
  async materializeBlock(meta, targetRev) {
332
363
  let block;
@@ -373,11 +404,30 @@ export class BlockStorage {
373
404
  // NOTE: cold non-checkpoint historical reads re-replay every time (up to `checkpointInterval`
374
405
  // forward transforms). Acceptable — historical reads are rare and replay is depth-bounded. If
375
406
  // they ever show as hot, cache at the nearest checkpoint below the target instead of skipping.
376
- // Read metadata FRESH for the retention decision: the `meta` passed in was captured by getBlock
377
- // BEFORE ensureRevision, which may have restored the containing range during this same read
378
- // (ensureRevision mutates its own re-read, not this snapshot). A stale `meta.ranges` would send
379
- // rangeFloorOf into its fallback (treats the target as its own floor wrongly "retained"),
380
- // re-caching a rev the sweep means to prune regrowing storage via reads of restored ranges.
407
+ // NOTE: this `saveMaterializedBlock` is the ONE named exclusion from the storage invariant that
408
+ // every write to a block holds `blockWriteLatchKey(blockId)` (see block-latch.ts). It runs on the
409
+ // READ path, unlatched, and that is safe for CONTENT because it is not a read-modify-write of
410
+ // anything: the key is `(blockId, actionId)` and the value is a deterministic replay of
411
+ // transforms this node has already retained, so a concurrent SAVE of the same key writes the
412
+ // same bytes. It touches neither the metadata blob nor any revision record, so it cannot
413
+ // clobber `latest`. Taking the latch here would put a lock acquisition on every cold historical
414
+ // read and would deadlock the callers that already hold it. Dropping the re-cache entirely is
415
+ // the other way to close the exclusion, and is out of scope until someone measures the cold
416
+ // historical-read cost of doing without it.
417
+ //
418
+ // NOTE: the one racer that does NOT write the same bytes at this key is
419
+ // `pruneSupersededMaterialization`, which DELETES it (saveMaterializedBlock(..., undefined)).
420
+ // Losing that race resurrects a materialization the sweep just removed — a bounded storage
421
+ // leak, never wrong content, since the resurrected bytes are a correct materialization of that
422
+ // rev. The fresh `retentionMeta` read below narrows the window but cannot close it: a commit
423
+ // can land and prune between that read and this save. If materialization storage is ever seen
424
+ // to grow under read load, close it by having the prune win — e.g. re-check retention inside
425
+ // the raw driver's save, or have the sweep re-run after the read.
426
+ //
427
+ // Read metadata FRESH for the retention decision: because this runs outside the block's write
428
+ // latch, a concurrent restore or commit may have moved `ranges` since `meta` was captured. A stale
429
+ // `meta.ranges` would send rangeFloorOf into its fallback (treats the target as its own floor
430
+ // ⇒ wrongly "retained"), re-caching a rev the sweep means to prune — regrowing storage via reads.
381
431
  const retentionMeta = (await this.storage.getMetadata(this.blockId)) ?? meta;
382
432
  const cacheRev = actions[0].rev;
383
433
  const latestRev = retentionMeta.latest?.rev ?? cacheRev;
@@ -394,15 +444,228 @@ export class BlockStorage {
394
444
  return undefined;
395
445
  return await this.restoreCallback(this.blockId, rev);
396
446
  }
397
- async saveRestored(archive) {
447
+ /**
448
+ * Vet an archive fetched for a PINNED restore of `rev`, returning the revision coverage to record
449
+ * for it — or `undefined` when the archive must be refused, in which case nothing is written at
450
+ * all and the reason is logged.
451
+ *
452
+ * This is the whole trust boundary for the restore wire. `restoreBlock`'s
453
+ * `RestorationCoordinator` verifies nothing about a response (`queryPeer` returns
454
+ * `response.archive` straight through), so every field below is a remote peer's assertion, and
455
+ * `saveRestored` writes keyed by REVISION and by ACTION ID — meaning an archive naming a
456
+ * revision or action id this node already holds would otherwise overwrite content that was never
457
+ * in question. The checks, in order:
458
+ *
459
+ * - **The archive is about this block.** `saveRestored` writes under `this.blockId` and ignores
460
+ * `archive.blockId`, so an answer about a different block would land as this block's history.
461
+ * - **Every revision key is a real revision.** Keys arrive as JSON strings; a non-numeric key
462
+ * coerces to `NaN` and would be stored as a garbage revision number. Min/max are folded rather
463
+ * than spread through `Math.min`/`Math.max`, which throws `RangeError` past ~125k arguments —
464
+ * reachable inside the 8 MiB sync-response cap (see `maxArchiveRevision`, same hazard).
465
+ * - **Each entry's own `rev`, when it declares one, agrees with the key it is filed under.**
466
+ * That disagreement IS the mislabel this ticket's family of bugs is about, in miniature.
467
+ * - **The archive answers the pin.** NOT "carries revision `rev`" — `ActionContext.rev` is a
468
+ * COLLECTION-wide revision, so it routinely sits above the revision at which this particular
469
+ * block last changed. A peer answering a pin at 9 for a block whose last commit was rev 2
470
+ * correctly serves rev 2, labelled as rev 2 (pinned in `test/block-archive-proof.spec.ts`).
471
+ * So the rule is that the archive's LOWEST revision is at or below the pin: `materializeBlock`
472
+ * descends from `rev`, so an archive entirely above the pin answers a different question and
473
+ * is exactly the "old bytes under a newer label" shape that overwrites good local data.
474
+ * - **The declared `range` agrees with the revisions actually carried** — it starts at the
475
+ * lowest (the floor must be present, or the descending walk has nothing to land on) and ends
476
+ * past the highest. An OPEN-ENDED range is refused outright: it would claim infinite coverage
477
+ * and permanently disable restore for this block on one unverified peer's say-so.
478
+ * (`RestoreCallback` allows open-ended for the UNPINNED call; `restoreRevision` never makes one.)
479
+ * - **Nothing already held is overwritten with different content** — see
480
+ * {@link noDivergentRewrite}.
481
+ *
482
+ * ## What gets recorded, and the one thing taken on trust
483
+ *
484
+ * The coverage returned is `[lowest, rev + 1)` — the archive's floor, up to the PIN and no
485
+ * further. Both halves of that are deliberate.
486
+ *
487
+ * Extending UP to the pin is an INFERENCE, and the only one here: a peer
488
+ * answering a pinned fetch with revision M ≤ N means "M is my highest committed revision of this
489
+ * block at or below N", i.e. nothing changed in (M, N]. This node cannot verify that locally.
490
+ *
491
+ * It is recorded anyway because the alternative is worse. `meta.ranges` is what
492
+ * {@link restoreRevision} consults to decide whether to fetch at all, so recording only the
493
+ * archive's literal `[M, M+1)` leaves `inRanges(N)` false and re-runs the ENTIRE restore — network
494
+ * round trip plus a full `saveRestored` write — on every later read at that pin, forever, never
495
+ * converging. The inference is also unavoidable rather than merely convenient: having the peer
496
+ * state the claim on the wire instead would not make it verifiable, only explicit, while breaking
497
+ * repair against every peer running an older build.
498
+ *
499
+ * Stopping AT the pin, on the other hand, discards coverage for any revision the archive
500
+ * volunteered ABOVE it. Those entries are still WRITTEN — an honest peer serves a contiguous
501
+ * span, so an archive of `{2, 3, 4}` answering a pin at 3 is normal, not hostile — they are
502
+ * merely not CLAIMED. `rev` is the one number in the exchange this node chose, so it is as far as
503
+ * its trust in the answer should reach; recording `highest + 1` instead would let the peer set
504
+ * the width of its own credibility by padding the archive with fabricated high revisions, and
505
+ * reads across that padded span would then be served from local content without ever re-asking.
506
+ * The cost is one redundant fetch the first time a revision above the pin is read; that fetch is
507
+ * idempotent (identical content is not a conflict, see {@link noDivergentRewrite}) and the
508
+ * coverage converges.
509
+ *
510
+ * NOTE: accepted tradeoff — a lying peer's answer is therefore STICKY across the whole span it
511
+ * was asked about: reads between M and N are served locally from M's content and never re-ask, so
512
+ * a later honest peer is never consulted for them. Weighed against an unbounded re-fetch loop and
513
+ * kept; that is the same "ranges records what this node can locally reconstruct, freshness is a
514
+ * separate concern" position `setLatest` and `saveForwardRevision` already take. Revisit if a
515
+ * restore ever gains a way to verify an archive (a commit proof chain over the served revision
516
+ * would do it) — at that point record only what verifies.
517
+ */
518
+ async vetRestoredArchive(archive, rev) {
519
+ const refuse = (why, ...args) => {
520
+ log(`restore:refused blockId=%s rev=%d ${why}`, this.blockId, rev, ...args);
521
+ return undefined;
522
+ };
523
+ if (archive.blockId !== this.blockId) {
524
+ return refuse('archive is for blockId=%s', archive.blockId);
525
+ }
526
+ const entries = [];
527
+ let lowest;
528
+ let highest;
529
+ for (const [key, entry] of Object.entries(archive.revisions ?? {})) {
530
+ const entryRev = Number(key);
531
+ // `String(entryRev) === key`, not merely "parses as an integer". `saveRestored` re-derives
532
+ // the number with its own `Number(key)`, so any key with a second spelling (`"02"`, `" 2"`,
533
+ // `"2e1"`) lets ONE archive file two entries under one revision: the vet checks both, the
534
+ // write keeps whichever `Object.entries` yields last, and which one that is was never the
535
+ // question either check answered.
536
+ if (!Number.isInteger(entryRev) || entryRev < 1 || String(entryRev) !== key) {
537
+ return refuse('revision key %s is not a revision', key);
538
+ }
539
+ const action = entry?.action;
540
+ if (!action?.actionId) {
541
+ return refuse('revision %d carries no action', entryRev);
542
+ }
543
+ if (action.rev !== undefined && action.rev !== entryRev) {
544
+ return refuse('revision %d is filed under an action declaring rev=%d', entryRev, action.rev);
545
+ }
546
+ entries.push({ rev: entryRev, action, block: entry.block });
547
+ if (lowest === undefined || entryRev < lowest)
548
+ lowest = entryRev;
549
+ if (highest === undefined || entryRev > highest)
550
+ highest = entryRev;
551
+ }
552
+ if (lowest === undefined || highest === undefined) {
553
+ return refuse('carries no revisions');
554
+ }
555
+ if (lowest > rev) {
556
+ return refuse('lowest revision %d is above the pin', lowest);
557
+ }
558
+ const range = archive.range;
559
+ if (!Array.isArray(range)) {
560
+ return refuse('declares no range');
561
+ }
562
+ const [start, end] = range;
563
+ if (start !== lowest) {
564
+ return refuse('range starts at %o but revisions start at %d', start, lowest);
565
+ }
566
+ if (end === undefined || !Number.isInteger(end) || end <= highest) {
567
+ return refuse('range ends at %o but revisions end at %d', end, highest);
568
+ }
569
+ if (!await this.noDivergentRewrite(entries, refuse)) {
570
+ return undefined;
571
+ }
572
+ return [lowest, rev + 1];
573
+ }
574
+ /**
575
+ * True when none of `entries` would overwrite content this node ALREADY holds with different
576
+ * content. False (having logged which entry, via `refuse`) when any would.
577
+ *
578
+ * The refusal is all-or-nothing: one divergent entry rejects the WHOLE archive rather than
579
+ * landing the entries this node happens to lack. Two reasons. An archive that contradicts locally
580
+ * held content is evidence the peer is wrong or hostile about this block, which makes the rest of
581
+ * it no more trustworthy than the part that was caught; and a partial apply would leave
582
+ * {@link vetRestoredArchive}'s coverage claiming a span the applied subset may not support.
583
+ *
584
+ * Identical content is NOT a conflict — a re-restore of the same archive must stay idempotent,
585
+ * which it has to be for the pin-extended coverage above to converge.
586
+ *
587
+ * NOTE: accepted tradeoff — first writer wins, permanently. Once a revision record is held, no
588
+ * later archive can replace it, so a lying peer that answers a gap FIRST makes every honest
589
+ * archive restating that revision refuse forever: the revision becomes unreadable rather than
590
+ * wrong. That is the deliberate direction — this guard exists precisely so a peer cannot rewrite
591
+ * held history, and it cannot tell "the held copy is the lie" from "the incoming copy is". Repair
592
+ * from that state is an operator action (drop the block's local records and re-fetch). Revisit
593
+ * only alongside a way to verify an archive, which would let the guard prefer the provable copy
594
+ * instead of the earlier one.
595
+ *
596
+ * Comparison is by `canonicalJson`, db-core's one deterministic encoding, so key ORDER across a
597
+ * JSON round trip over the wire never reads as divergence.
598
+ *
599
+ * The three keys mirror {@link saveRestored}'s three writes exactly; an entry that carries no
600
+ * `block` writes no materialization, so it cannot clobber one and is not checked for it.
601
+ *
602
+ * NOTE: costs up to three raw-storage reads per revision entry, on the restore path only — which
603
+ * has already paid for a network round trip, so it is not the term that matters. If a restore
604
+ * ever carries thousands of revisions and this shows up, check `getRevision` first and skip the
605
+ * other two for a revision this node does not hold at all.
606
+ */
607
+ async noDivergentRewrite(entries, refuse) {
608
+ for (const { rev, action, block } of entries) {
609
+ const heldActionId = await this.storage.getRevision(this.blockId, rev);
610
+ if (heldActionId !== undefined && heldActionId !== action.actionId) {
611
+ refuse('revision %d is already held as action %s, archive names %s', rev, heldActionId, action.actionId);
612
+ return false;
613
+ }
614
+ const heldTransform = await this.storage.getTransaction(this.blockId, action.actionId);
615
+ if (heldTransform !== undefined && canonicalJson(heldTransform) !== canonicalJson(action.transform)) {
616
+ refuse('action %s (revision %d) is already held with a different transform', action.actionId, rev);
617
+ return false;
618
+ }
619
+ if (block) {
620
+ const heldBlock = await this.storage.getMaterializedBlock(this.blockId, action.actionId);
621
+ if (heldBlock !== undefined && canonicalJson(heldBlock) !== canonicalJson(block)) {
622
+ refuse('action %s (revision %d) is already materialized with different content', action.actionId, rev);
623
+ return false;
624
+ }
625
+ }
626
+ }
627
+ return true;
628
+ }
629
+ /**
630
+ * Persist a fetched archive's revisions locally.
631
+ *
632
+ * A revision entry's own `proof` is deliberately IGNORED. An archive is remote wire data —
633
+ * {@link restoreBlock}'s `RestorationCoordinator` fetch verifies nothing, and a peer chooses
634
+ * what to attach — so persisting a proof read out of the archive body would re-serve a hostile
635
+ * peer's artifact as evidence this node retained itself. A proof reaches storage only through
636
+ * `verified`, passed out-of-band alongside the archive, which exactly one caller chain supplies:
637
+ * `cluster/reconcile-block.ts` → `StorageRepo.saveReplicatedBlock` → {@link saveReplica} →
638
+ * {@link saveForwardRevision}, where `certifyContent` had already bound the proof to these exact
639
+ * bytes. A separate parameter rather than a caller obligation to strip is what makes "an
640
+ * unverified proof reached `saveBlockProof`" unrepresentable instead of merely documented.
641
+ *
642
+ * This is a WRITER, not a gate: it trusts what it is handed, and each of its two callers is
643
+ * responsible for having earned that on its own terms.
644
+ *
645
+ * - {@link restoreRevision} — the unverified restore wire — runs {@link vetRestoredArchive}
646
+ * first. Those checks are ABOUT the pinned request (does the archive answer the revision that
647
+ * was asked for?), and this function has no pin to check against, so they cannot live here.
648
+ * - {@link saveForwardRevision} — reached by `saveReplica`/`saveDeletion` through
649
+ * `StorageRepo.saveReplicatedBlock` — builds the archive it passes from local arguments, and
650
+ * on the replica path `cluster/reconcile-block.ts` has already bound those bytes to a verified
651
+ * proof. It writes strictly ABOVE its own `latest` (the monotonic guard returns first
652
+ * otherwise), so it cannot rewrite held history, and it deliberately pays nothing for the
653
+ * restore wire's checks.
654
+ *
655
+ * A THIRD caller would not inherit either argument. Any future one that takes an archive off a
656
+ * network must route through `vetRestoredArchive` (or an equivalent for its own trust model)
657
+ * before reaching here.
658
+ */
659
+ async saveRestored(archive, verified) {
398
660
  const revisions = Object.entries(archive.revisions)
399
661
  .map(([rev, data]) => ({ rev: Number(rev), data }));
400
- // Save all revisions, actions, and materializations
662
+ // Save all revisions, actions, materializations, and the caller-verified proof (if any).
401
663
  for (const { rev, data: { action, block } } of revisions) {
402
664
  await Promise.all([
403
665
  this.storage.saveRevision(this.blockId, rev, action.actionId),
404
666
  this.storage.saveTransaction(this.blockId, action.actionId, action.transform),
405
- block ? this.storage.saveMaterializedBlock(this.blockId, action.actionId, block) : Promise.resolve()
667
+ block ? this.storage.saveMaterializedBlock(this.blockId, action.actionId, block) : Promise.resolve(),
668
+ verified?.rev === rev ? this.storage.saveBlockProof(this.blockId, rev, verified.proof) : Promise.resolve()
406
669
  ]);
407
670
  }
408
671
  }