@optimystic/db-p2p 0.14.0 → 0.16.2

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 (275) hide show
  1. package/README.md +616 -609
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +4 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +52 -3
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +129 -6
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts.map +1 -1
  10. package/dist/src/cluster/client.js +3 -1
  11. package/dist/src/cluster/client.js.map +1 -1
  12. package/dist/src/cluster/cluster-repo.d.ts +182 -11
  13. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  14. package/dist/src/cluster/cluster-repo.js +429 -69
  15. package/dist/src/cluster/cluster-repo.js.map +1 -1
  16. package/dist/src/cluster/peer-key-binding.d.ts +25 -0
  17. package/dist/src/cluster/peer-key-binding.d.ts.map +1 -0
  18. package/dist/src/cluster/peer-key-binding.js +39 -0
  19. package/dist/src/cluster/peer-key-binding.js.map +1 -0
  20. package/dist/src/cluster/quorum-restore.d.ts +72 -0
  21. package/dist/src/cluster/quorum-restore.d.ts.map +1 -0
  22. package/dist/src/cluster/quorum-restore.js +0 -0
  23. package/dist/src/cluster/quorum-restore.js.map +1 -0
  24. package/dist/src/cluster/rebalance-monitor.d.ts +23 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  26. package/dist/src/cluster/rebalance-monitor.js +17 -2
  27. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  28. package/dist/src/cluster/service.d.ts.map +1 -1
  29. package/dist/src/cluster/service.js +7 -1
  30. package/dist/src/cluster/service.js.map +1 -1
  31. package/dist/src/cluster/spread-on-churn.d.ts +7 -0
  32. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  33. package/dist/src/cluster/spread-on-churn.js +4 -1
  34. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  35. package/dist/src/cluster/supermajority-coupling.d.ts +22 -0
  36. package/dist/src/cluster/supermajority-coupling.d.ts.map +1 -0
  37. package/dist/src/cluster/supermajority-coupling.js +27 -0
  38. package/dist/src/cluster/supermajority-coupling.js.map +1 -0
  39. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +46 -4
  40. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -1
  41. package/dist/src/cohort-topic/cohort-gossip-driver.js +62 -7
  42. package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -1
  43. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +7 -0
  44. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -1
  45. package/dist/src/cohort-topic/cohort-gossip-transport.js +9 -0
  46. package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -1
  47. package/dist/src/cohort-topic/host.d.ts +197 -27
  48. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  49. package/dist/src/cohort-topic/host.js +643 -87
  50. package/dist/src/cohort-topic/host.js.map +1 -1
  51. package/dist/src/dispute/arbitrator-selection.d.ts +68 -7
  52. package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -1
  53. package/dist/src/dispute/arbitrator-selection.js +105 -16
  54. package/dist/src/dispute/arbitrator-selection.js.map +1 -1
  55. package/dist/src/dispute/cascade.d.ts +7 -0
  56. package/dist/src/dispute/cascade.d.ts.map +1 -1
  57. package/dist/src/dispute/cascade.js +1 -1
  58. package/dist/src/dispute/cascade.js.map +1 -1
  59. package/dist/src/dispute/client.d.ts.map +1 -1
  60. package/dist/src/dispute/client.js +3 -2
  61. package/dist/src/dispute/client.js.map +1 -1
  62. package/dist/src/dispute/dispute-service.d.ts +32 -3
  63. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  64. package/dist/src/dispute/dispute-service.js +82 -9
  65. package/dist/src/dispute/dispute-service.js.map +1 -1
  66. package/dist/src/dispute/index.d.ts +1 -1
  67. package/dist/src/dispute/index.d.ts.map +1 -1
  68. package/dist/src/dispute/index.js +1 -1
  69. package/dist/src/dispute/index.js.map +1 -1
  70. package/dist/src/dispute/invalidation.d.ts +25 -0
  71. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  72. package/dist/src/dispute/invalidation.js +14 -2
  73. package/dist/src/dispute/invalidation.js.map +1 -1
  74. package/dist/src/dispute/service.d.ts.map +1 -1
  75. package/dist/src/dispute/service.js +7 -1
  76. package/dist/src/dispute/service.js.map +1 -1
  77. package/dist/src/index.d.ts +4 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/index.js +4 -1
  80. package/dist/src/index.js.map +1 -1
  81. package/dist/src/it-utility.d.ts +1 -1
  82. package/dist/src/it-utility.d.ts.map +1 -1
  83. package/dist/src/it-utility.js +4 -13
  84. package/dist/src/it-utility.js.map +1 -1
  85. package/dist/src/libp2p-key-network.d.ts +98 -1
  86. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  87. package/dist/src/libp2p-key-network.js +272 -17
  88. package/dist/src/libp2p-key-network.js.map +1 -1
  89. package/dist/src/libp2p-node-base.d.ts +12 -0
  90. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  91. package/dist/src/libp2p-node-base.js +415 -87
  92. package/dist/src/libp2p-node-base.js.map +1 -1
  93. package/dist/src/matchmaking/query-transport.d.ts +2 -0
  94. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  95. package/dist/src/matchmaking/query-transport.js +6 -0
  96. package/dist/src/matchmaking/query-transport.js.map +1 -1
  97. package/dist/src/matchmaking/traffic-validation.js +1 -0
  98. package/dist/src/matchmaking/traffic-validation.js.map +1 -1
  99. package/dist/src/network/network-manager-service.d.ts +2 -2
  100. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  101. package/dist/src/network/network-manager-service.js +8 -4
  102. package/dist/src/network/network-manager-service.js.map +1 -1
  103. package/dist/src/owned-block-seed.d.ts +27 -0
  104. package/dist/src/owned-block-seed.d.ts.map +1 -0
  105. package/dist/src/owned-block-seed.js +37 -0
  106. package/dist/src/owned-block-seed.js.map +1 -0
  107. package/dist/src/protocol-client.d.ts +1 -0
  108. package/dist/src/protocol-client.d.ts.map +1 -1
  109. package/dist/src/protocol-client.js +9 -2
  110. package/dist/src/protocol-client.js.map +1 -1
  111. package/dist/src/protocol-limits.d.ts +32 -0
  112. package/dist/src/protocol-limits.d.ts.map +1 -0
  113. package/dist/src/protocol-limits.js +32 -0
  114. package/dist/src/protocol-limits.js.map +1 -0
  115. package/dist/src/repo/client.d.ts.map +1 -1
  116. package/dist/src/repo/client.js +3 -0
  117. package/dist/src/repo/client.js.map +1 -1
  118. package/dist/src/repo/cluster-coordinator.d.ts +41 -4
  119. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  120. package/dist/src/repo/cluster-coordinator.js +128 -80
  121. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  122. package/dist/src/repo/coordinator-repo.d.ts +38 -1
  123. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  124. package/dist/src/repo/coordinator-repo.js +92 -13
  125. package/dist/src/repo/coordinator-repo.js.map +1 -1
  126. package/dist/src/repo/service.d.ts.map +1 -1
  127. package/dist/src/repo/service.js +7 -1
  128. package/dist/src/repo/service.js.map +1 -1
  129. package/dist/src/reputation/peer-reputation.js +1 -1
  130. package/dist/src/reputation/peer-reputation.js.map +1 -1
  131. package/dist/src/reputation/types.d.ts +3 -1
  132. package/dist/src/reputation/types.d.ts.map +1 -1
  133. package/dist/src/reputation/types.js +3 -0
  134. package/dist/src/reputation/types.js.map +1 -1
  135. package/dist/src/rn.d.ts +5 -1
  136. package/dist/src/rn.d.ts.map +1 -1
  137. package/dist/src/rn.js +7 -1
  138. package/dist/src/rn.js.map +1 -1
  139. package/dist/src/storage/arachnode-fret-adapter.d.ts +30 -1
  140. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
  141. package/dist/src/storage/arachnode-fret-adapter.js +15 -2
  142. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
  143. package/dist/src/storage/arachnode-partition.d.ts +49 -0
  144. package/dist/src/storage/arachnode-partition.d.ts.map +1 -0
  145. package/dist/src/storage/arachnode-partition.js +61 -0
  146. package/dist/src/storage/arachnode-partition.js.map +1 -0
  147. package/dist/src/storage/block-storage.d.ts +40 -1
  148. package/dist/src/storage/block-storage.d.ts.map +1 -1
  149. package/dist/src/storage/block-storage.js +150 -57
  150. package/dist/src/storage/block-storage.js.map +1 -1
  151. package/dist/src/storage/i-block-storage.d.ts +8 -0
  152. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/i-raw-storage.d.ts +13 -0
  154. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  155. package/dist/src/storage/kv-raw-storage.d.ts +45 -0
  156. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -0
  157. package/dist/src/storage/kv-raw-storage.js +113 -0
  158. package/dist/src/storage/kv-raw-storage.js.map +1 -0
  159. package/dist/src/storage/memory-storage.d.ts +20 -58
  160. package/dist/src/storage/memory-storage.d.ts.map +1 -1
  161. package/dist/src/storage/memory-storage.js +22 -148
  162. package/dist/src/storage/memory-storage.js.map +1 -1
  163. package/dist/src/storage/memory-store-driver.d.ts +39 -0
  164. package/dist/src/storage/memory-store-driver.d.ts.map +1 -0
  165. package/dist/src/storage/memory-store-driver.js +129 -0
  166. package/dist/src/storage/memory-store-driver.js.map +1 -0
  167. package/dist/src/storage/raw-store-codec.d.ts +10 -0
  168. package/dist/src/storage/raw-store-codec.d.ts.map +1 -0
  169. package/dist/src/storage/raw-store-codec.js +36 -0
  170. package/dist/src/storage/raw-store-codec.js.map +1 -0
  171. package/dist/src/storage/raw-store-driver.d.ts +69 -0
  172. package/dist/src/storage/raw-store-driver.d.ts.map +1 -0
  173. package/dist/src/storage/raw-store-driver.js +2 -0
  174. package/dist/src/storage/raw-store-driver.js.map +1 -0
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +1 -1
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +1 -1
  178. package/dist/src/storage/restoration-coordinator.d.ts +73 -0
  179. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -0
  180. package/dist/src/storage/restoration-coordinator.js +181 -0
  181. package/dist/src/storage/restoration-coordinator.js.map +1 -0
  182. package/dist/src/storage/ring-selector.d.ts +92 -5
  183. package/dist/src/storage/ring-selector.d.ts.map +1 -1
  184. package/dist/src/storage/ring-selector.js +153 -51
  185. package/dist/src/storage/ring-selector.js.map +1 -1
  186. package/dist/src/storage/ring-shift-coordinator.d.ts +128 -0
  187. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -0
  188. package/dist/src/storage/ring-shift-coordinator.js +196 -0
  189. package/dist/src/storage/ring-shift-coordinator.js.map +1 -0
  190. package/dist/src/storage/storage-monitor.d.ts +28 -0
  191. package/dist/src/storage/storage-monitor.d.ts.map +1 -1
  192. package/dist/src/storage/storage-monitor.js +42 -2
  193. package/dist/src/storage/storage-monitor.js.map +1 -1
  194. package/dist/src/storage/storage-repo.d.ts +19 -0
  195. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  196. package/dist/src/storage/storage-repo.js +172 -22
  197. package/dist/src/storage/storage-repo.js.map +1 -1
  198. package/dist/src/sync/client.d.ts.map +1 -1
  199. package/dist/src/sync/client.js +5 -1
  200. package/dist/src/sync/client.js.map +1 -1
  201. package/dist/src/sync/service.d.ts.map +1 -1
  202. package/dist/src/sync/service.js +2 -1
  203. package/dist/src/sync/service.js.map +1 -1
  204. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +28 -4
  205. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  206. package/dist/src/testing/cohort-topic-mesh-harness.js +51 -10
  207. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  208. package/dist/src/testing/index.d.ts +1 -0
  209. package/dist/src/testing/index.d.ts.map +1 -1
  210. package/dist/src/testing/index.js +1 -0
  211. package/dist/src/testing/index.js.map +1 -1
  212. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  213. package/dist/src/testing/mesh-harness.js +15 -7
  214. package/dist/src/testing/mesh-harness.js.map +1 -1
  215. package/dist/src/testing/raw-storage-conformance.d.ts +24 -0
  216. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -0
  217. package/dist/src/testing/raw-storage-conformance.js +313 -0
  218. package/dist/src/testing/raw-storage-conformance.js.map +1 -0
  219. package/package.json +4 -4
  220. package/src/cluster/block-transfer-service.ts +4 -3
  221. package/src/cluster/block-transfer.ts +424 -265
  222. package/src/cluster/client.ts +3 -1
  223. package/src/cluster/cluster-repo.ts +481 -71
  224. package/src/cluster/peer-key-binding.ts +37 -0
  225. package/src/cluster/quorum-restore.ts +0 -0
  226. package/src/cluster/rebalance-monitor.ts +34 -3
  227. package/src/cluster/service.ts +7 -1
  228. package/src/cluster/spread-on-churn.ts +11 -1
  229. package/src/cluster/supermajority-coupling.ts +37 -0
  230. package/src/cohort-topic/cohort-gossip-driver.ts +96 -9
  231. package/src/cohort-topic/cohort-gossip-transport.ts +10 -0
  232. package/src/cohort-topic/host.ts +831 -105
  233. package/src/dispute/arbitrator-selection.ts +138 -28
  234. package/src/dispute/cascade.ts +8 -1
  235. package/src/dispute/client.ts +3 -2
  236. package/src/dispute/dispute-service.ts +94 -9
  237. package/src/dispute/index.ts +7 -1
  238. package/src/dispute/invalidation.ts +40 -2
  239. package/src/dispute/service.ts +7 -1
  240. package/src/index.ts +46 -43
  241. package/src/it-utility.ts +5 -13
  242. package/src/libp2p-key-network.ts +281 -18
  243. package/src/libp2p-node-base.ts +485 -79
  244. package/src/matchmaking/query-transport.ts +9 -0
  245. package/src/network/network-manager-service.ts +8 -2
  246. package/src/owned-block-seed.ts +40 -0
  247. package/src/protocol-client.ts +10 -3
  248. package/src/protocol-limits.ts +33 -0
  249. package/src/repo/client.ts +3 -0
  250. package/src/repo/cluster-coordinator.ts +145 -82
  251. package/src/repo/coordinator-repo.ts +96 -16
  252. package/src/repo/service.ts +7 -1
  253. package/src/reputation/types.ts +3 -0
  254. package/src/rn.ts +7 -1
  255. package/src/storage/arachnode-fret-adapter.ts +33 -2
  256. package/src/storage/arachnode-partition.ts +74 -0
  257. package/src/storage/block-storage.ts +169 -61
  258. package/src/storage/i-block-storage.ts +9 -0
  259. package/src/storage/i-raw-storage.ts +14 -0
  260. package/src/storage/kv-raw-storage.ts +135 -0
  261. package/src/storage/memory-storage.ts +25 -173
  262. package/src/storage/memory-store-driver.ts +157 -0
  263. package/src/storage/raw-store-codec.ts +42 -0
  264. package/src/storage/raw-store-driver.ts +80 -0
  265. package/src/storage/{restoration-coordinator-v2.ts → restoration-coordinator.ts} +43 -37
  266. package/src/storage/ring-selector.ts +317 -176
  267. package/src/storage/ring-shift-coordinator.ts +271 -0
  268. package/src/storage/storage-monitor.ts +70 -3
  269. package/src/storage/storage-repo.ts +710 -558
  270. package/src/sync/client.ts +5 -1
  271. package/src/sync/service.ts +2 -1
  272. package/src/testing/cohort-topic-mesh-harness.ts +61 -11
  273. package/src/testing/index.ts +1 -0
  274. package/src/testing/mesh-harness.ts +14 -6
  275. package/src/testing/raw-storage-conformance.ts +397 -0
@@ -13,20 +13,24 @@ import { generateKeyPair } from '@libp2p/crypto/keys';
13
13
  import { clusterService } from './cluster/service.js';
14
14
  import { blockTransferService } from './cluster/block-transfer-service.js';
15
15
  import { repoService } from './repo/service.js';
16
- import { StorageRepo } from './storage/storage-repo.js';
16
+ import { StorageRepo, withBlockCommitLatch } from './storage/storage-repo.js';
17
17
  import { BlockStorage } from './storage/block-storage.js';
18
18
  import { MemoryRawStorage } from './storage/memory-storage.js';
19
+ import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
19
20
  import { clusterMember } from './cluster/cluster-repo.js';
21
+ import { selectQuorumRev, selectQuorumBlock, canonicalBlockHash } from './cluster/quorum-restore.js';
20
22
  import { createCommitCertStore, makeClusterCommitCertExtractor } from './cluster/commit-cert.js';
21
23
  import { coordinatorRepo } from './repo/coordinator-repo.js';
22
24
  import { Libp2pKeyPeerNetwork } from './libp2p-key-network.js';
23
25
  import { ClusterClient } from './cluster/client.js';
24
26
  import { networkManagerService } from './network/network-manager-service.js';
25
- import { fretService } from 'p2p-fret';
27
+ import { BlockTransferCoordinator } from './cluster/block-transfer.js';
28
+ import { fretService, Libp2pFretService } from 'p2p-fret';
26
29
  import { syncService } from './sync/service.js';
27
30
  import { SyncClient } from './sync/client.js';
28
- import { RestorationCoordinator } from './storage/restoration-coordinator-v2.js';
31
+ import { RestorationCoordinator } from './storage/restoration-coordinator.js';
29
32
  import { RingSelector } from './storage/ring-selector.js';
33
+ import { RingShiftCoordinator } from './storage/ring-shift-coordinator.js';
30
34
  import { StorageMonitor } from './storage/storage-monitor.js';
31
35
  import { ArachnodeFretAdapter } from './storage/arachnode-fret-adapter.js';
32
36
  import { createCohortTopicHost } from './cohort-topic/host.js';
@@ -43,14 +47,24 @@ import { DEFAULT_REACTIVITY_PROTOCOLS, reactivityProtocolList } from './reactivi
43
47
  import { registerMatchmakingQueryHandler } from './matchmaking/query-transport.js';
44
48
  import { DEFAULT_MATCHMAKING_PROTOCOLS, matchmakingProtocolList } from './matchmaking/protocols.js';
45
49
  import { signPeer } from './cohort-topic/peer-sig.js';
46
- import { createNotificationVerifier, createCorrelationReplayGuard, createStickyCohortHintCache, reactivityNodePolicy, createTierAddressing, createRingHash, Tier, b64urlToBytes, bytesToB64url, } from '@optimystic/db-core';
50
+ import { createNotificationVerifier, createCorrelationReplayGuard, createStickyCohortHintCache, reactivityNodePolicy, createTierAddressing, createRingHash, DEFAULT_SUPER_MAJORITY_THRESHOLD, Tier, b64urlToBytes, bytesToB64url, } from '@optimystic/db-core';
47
51
  import { PartitionDetector } from './cluster/partition-detector.js';
52
+ import { assertSuperMajorityCoupling } from './cluster/supermajority-coupling.js';
48
53
  import { createLogger } from './logger.js';
49
54
  import { PeerReputationService } from './reputation/peer-reputation.js';
55
+ import { PenaltyReason } from './reputation/types.js';
50
56
  import { DisputeService } from './dispute/dispute-service.js';
51
57
  import { DisputeClient } from './dispute/client.js';
58
+ import { sampleArbitrators } from './dispute/arbitrator-selection.js';
52
59
  /** Logger for the reactivity node-wiring (origination/forwarder/recover/rotation composition). */
53
60
  const reactivityWiringLog = createLogger('reactivity-node-wiring');
61
+ /**
62
+ * Logger for the best-effort in-factory service wiring. These injections run during `createLibp2p`
63
+ * internals against the unreliable `components.libp2p` proxy; the real node is re-injected
64
+ * post-construction (see the load-bearing block after `createLibp2p`), so a failure here is logged,
65
+ * not fatal.
66
+ */
67
+ const wiringLog = createLogger('node-wiring');
54
68
  function resolveStorage(provider) {
55
69
  if (!provider) {
56
70
  return new MemoryRawStorage();
@@ -96,6 +110,14 @@ export async function createLibp2pNodeBase(options, defaults) {
96
110
  };
97
111
  // Create shared storage layers with restoration callback
98
112
  const storageRepo = new StorageRepo((blockId) => new BlockStorage(blockId, rawStorage, restoreCallback));
113
+ // Per-block commit-latch runner, ready to thread into the invalidation-apply sink (`onInvalidate`)
114
+ // passed to `clusterMember(...)` and into each cascade `CollectionEnv`, the instant either is wired
115
+ // here. Sharing the `StorageRepo.commit:<blockId>` latch makes a compensating saveReplica/saveDeletion
116
+ // RMW of `meta.latest` mutually exclusive with a concurrent commit on the same block. It is unused
117
+ // today only because no `onInvalidate`/cascade driver is wired in the live node (see review handoff);
118
+ // it is bound here so that wiring is a one-liner and cannot reach for a divergent latch key.
119
+ const blockCommitLatch = withBlockCommitLatch;
120
+ void blockCommitLatch;
99
121
  let clusterImpl;
100
122
  let coordinatedRepo;
101
123
  const clusterProxy = {
@@ -150,17 +172,27 @@ export async function createLibp2pNodeBase(options, defaults) {
150
172
  listen: listenAddrs
151
173
  },
152
174
  connectionManager: {
153
- autoDial: true,
154
- minConnections: 1,
175
+ // `autoDial`, `minConnections`, and `dialQueue` were stale libp2p option keys silently
176
+ // ignored under the former `libp2pOptions as any` (removed with this change). This libp2p
177
+ // version has no such keys — auto-dial is now default connection-manager behavior with no
178
+ // direct replacement — so they are dropped rather than re-cast. See review handoff.
155
179
  maxConnections: 16,
156
- inboundConnectionUpgradeTimeout: 10_000,
157
- dialQueue: { concurrency: 2, attempts: 2 }
180
+ // Renamed from the stale `inboundConnectionUpgradeTimeout`. 10_000 equals this version's
181
+ // default, so surfacing (and correcting) the key is behavior-preserving; the old key was a no-op.
182
+ inboundUpgradeTimeout: 10_000
158
183
  },
159
184
  ...(options.connectionGater ? { connectionGater: options.connectionGater } : {}),
160
185
  transports,
161
186
  connectionEncrypters: [noise()],
162
187
  streamMuxers: [yamux()],
163
- services: {
188
+ // Narrow cast confined to the `services` field: the built-in factories (identify/dcutr/…) are
189
+ // typed against a SECOND copy of `@libp2p/interface` pulled in transitively (via `@libp2p/crypto`),
190
+ // whose `Uint8Array<ArrayBuffer>` vs `<ArrayBufferLike>` PeerId/key shapes are structurally
191
+ // incompatible with the top-level copy — a dependency-dedup artifact, not a real mismatch. The cast
192
+ // stays on this field alone so the rest of `libp2pOptions` remains fully typed as `Libp2pInit`.
193
+ // NOTE: this cast exists ONLY because of the duplicate @libp2p/interface install; if that dedups
194
+ // (or on a libp2p bump) drop `as unknown as NonNullable<Libp2pInit['services']>` and type the map directly.
195
+ services: ({
164
196
  identify: identify({
165
197
  protocolPrefix: `/optimystic/${options.networkName}`
166
198
  }),
@@ -255,10 +287,13 @@ export async function createLibp2pNodeBase(options, defaults) {
255
287
  clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5
256
288
  });
257
289
  const svc = svcFactory(components);
290
+ // Best-effort proxy-time injection; the real node is re-injected post-construction below.
258
291
  try {
259
- svc.setLibp2p?.(components.libp2p);
292
+ svc.setLibp2p(components.libp2p);
293
+ }
294
+ catch (err) {
295
+ wiringLog('networkManager in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err);
260
296
  }
261
- catch { }
262
297
  return svc;
263
298
  },
264
299
  fret: (components) => {
@@ -271,60 +306,79 @@ export async function createLibp2pNodeBase(options, defaults) {
271
306
  bootstraps: options.bootstrapNodes ?? []
272
307
  });
273
308
  const svc = svcFactory(components);
309
+ // Best-effort proxy-time injection; the real node is re-injected post-construction below.
274
310
  try {
275
311
  svc.setLibp2p(components.libp2p);
276
312
  }
277
- catch { }
313
+ catch (err) {
314
+ wiringLog('fret in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err);
315
+ }
278
316
  return svc;
279
317
  }
280
- },
318
+ // [dispute-subsystem-dormant] The /optimystic/<network>/dispute/1.0.0 handler
319
+ // (disputeProtocolService / DisputeProtocolService) is intentionally NOT registered here.
320
+ // The subsystem is staged dormant pending arbitrator-set anchoring — without it, a peer
321
+ // minting throwaway keypairs can forge a synthetic super-majority and pass resolution.
322
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
323
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
324
+ }),
281
325
  // Add bootstrap nodes as needed
282
326
  peerDiscovery: [
283
327
  ...(options.bootstrapNodes?.length ? [bootstrap({ list: options.bootstrapNodes })] : [])
284
328
  ],
285
329
  };
286
330
  const node = await createLibp2p(libp2pOptions);
287
- // Inject libp2p reference into services that need it before start
288
- try {
289
- node.services?.fret?.setLibp2p?.(node);
290
- }
291
- catch { }
292
- try {
293
- node.services?.networkManager?.setLibp2p?.(node);
294
- }
295
- catch { }
331
+ // Inject the REAL libp2p node into the services that need it, before start(). These are
332
+ // load-bearing and the node has NOT started yet, so any throw fails fast and rejects node
333
+ // creation (nothing started leaks) — far better than the service silently falling back to the
334
+ // unreliable `components.libp2p` proxy and surfacing later as routing/consensus failures.
335
+ const wired = node.services;
336
+ wired.fret.setLibp2p(node);
337
+ wired.networkManager.setLibp2p(node);
296
338
  // RepoService.checkRedirect resolves the network manager / self id / connection
297
339
  // addrs through this injected node (the components.libp2p proxy is unreliable
298
340
  // from inside a service at request time). Done before start() so the protocol
299
341
  // handler is live with a resolvable node from its first request.
300
- try {
301
- node.services?.repo?.setLibp2p?.(node);
302
- }
303
- catch { }
342
+ wired.repo.setLibp2p(node);
304
343
  await node.start();
305
344
  // Initialize peer reputation service
306
345
  const reputation = new PeerReputationService();
307
346
  // Initialize cluster coordination components
308
347
  const networkMode = (options.bootstrapNodes?.length ?? 0) > 0 ? 'joining' : 'forming';
309
- const keyNetwork = new Libp2pKeyPeerNetwork(node, options.clusterSize, undefined, networkMode, options.persistence, reputation);
310
- await keyNetwork.initFromPersistedState();
348
+ // Network-namespaced protocol prefix, threaded into the key network so coordinator/
349
+ // cohort selection is scoped to peers that serve THIS network's cluster/repo protocol.
350
+ // A peer that only belongs to another network sharing the same physical nodes/
351
+ // bootstraps registers a different (network-namespaced) identify protocol, so it is
352
+ // never selected and can't drag this network's super-majority below quorum.
311
353
  const protocolPrefix = `/optimystic/${options.networkName}`;
354
+ const keyNetwork = new Libp2pKeyPeerNetwork(node, options.clusterSize, undefined, networkMode, options.persistence, reputation, protocolPrefix);
355
+ await keyNetwork.initFromPersistedState();
312
356
  const createClusterClient = (peerId) => ClusterClient.create(peerId, keyNetwork, protocolPrefix);
313
- // Inject reputation into NetworkManagerService
357
+ // Inject reputation into NetworkManagerService. Load-bearing and non-optional: the service is
358
+ // unconditionally present, so a throw is a real wiring bug. Unlike the pre-start injections above
359
+ // the node has already started here, so stop it before rethrowing rather than leaking a started
360
+ // node + open transports (mirrors the cohortTopic hard-fail blocks below).
314
361
  try {
315
- node.services?.networkManager?.setReputation?.(reputation);
362
+ wired.networkManager.setReputation(reputation);
363
+ }
364
+ catch (err) {
365
+ await node.stop();
366
+ throw err;
316
367
  }
317
- catch { }
318
368
  // Create partition detector and get FRET service
319
369
  const partitionDetector = new PartitionDetector();
320
370
  const fretSvc = node.services?.fret;
321
371
  const consensusConfig = {
322
- superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? 0.67,
372
+ superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
323
373
  simpleMajorityThreshold: 0.51,
324
374
  minAbsoluteClusterSize: 2,
325
375
  allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
326
376
  clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
327
- partitionDetectionWindow: 60000
377
+ partitionDetectionWindow: 60000,
378
+ // Configured full cluster size — the member's own reference for "full size" in the membership
379
+ // admission gate (a below-full-size declared set under low FRET confidence is refused as a possible
380
+ // self-shrink). Matches the size threaded into the coordinator below.
381
+ clusterSize: options.clusterSize ?? 10
328
382
  };
329
383
  // Fetch a block archive from one cohort peer over the sync protocol, bounded by a
330
384
  // per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
@@ -361,9 +415,16 @@ export async function createLibp2pNodeBase(options, defaults) {
361
415
  const targets = cohortPeerIds.filter(id => id !== node.peerId.toString());
362
416
  if (targets.length === 0)
363
417
  return;
364
- const archives = await Promise.all(targets.map(peerIdStr => fetchArchiveFromPeer(peerIdStr, blockId)));
365
- let best;
366
- for (const archive of archives) {
418
+ const fetched = await Promise.all(targets.map(async (peerIdStr) => ({ peerIdStr, archive: await fetchArchiveFromPeer(peerIdStr, blockId) })));
419
+ // Each cohort archive contributes one (rev, actionId) claim from its max
420
+ // revision (>= the rev we committed). Pick the target rev by quorum
421
+ // corroboration rather than raw Math.max — a lone peer inflating its rev
422
+ // cannot steer reconciliation. Keep the serving peer + block per candidate
423
+ // so we can then verify content agreement.
424
+ // NOTE: this quorum is corroboration-of-a-claim, NOT Sybil-resistant cohort
425
+ // membership — deferred to backlog `debt-read-repair-commit-cert-verification`.
426
+ const candidates = [];
427
+ for (const { peerIdStr, archive } of fetched) {
367
428
  if (!archive)
368
429
  continue;
369
430
  const revs = Object.keys(archive.revisions).map(Number);
@@ -373,15 +434,57 @@ export async function createLibp2pNodeBase(options, defaults) {
373
434
  if (maxRev < committed.rev)
374
435
  continue;
375
436
  const data = archive.revisions[maxRev];
376
- if (!data?.block)
437
+ if (!data?.action)
377
438
  continue;
378
- if (!best || maxRev > best.source.rev) {
379
- best = { block: data.block, source: { actionId: data.action.actionId, rev: maxRev } };
439
+ candidates.push({ peerIdStr, rev: maxRev, actionId: data.action.actionId, block: data.block });
440
+ }
441
+ const revClaims = candidates.map(c => ({ peerId: c.peerIdStr, rev: c.rev, actionId: c.actionId }));
442
+ const selected = selectQuorumRev(revClaims, consensusConfig.simpleMajorityThreshold);
443
+ if (!selected)
444
+ return; // no rev corroborated by a quorum → leave block, churn/rebalance retries later
445
+ // Content agreement: among archives corroborating the chosen (rev, actionId)
446
+ // and actually carrying the block, the content must be byte-identical across
447
+ // a quorum. A cohort member serving content that hashes differently is rejected.
448
+ // NOTE: selectQuorumBlock recomputes its quorum over only the block-CARRYING
449
+ // corroborators, not the full rev-responder set. If most peers corroborate the
450
+ // rev but few carry block bytes (e.g. mid-prune), the content quorum can shrink
451
+ // to 2. Harmless with honest peers; if a colluding pair ever becomes the only
452
+ // block-servers for an agreed rev, that is the Sybil regime already deferred to
453
+ // backlog `debt-read-repair-commit-cert-verification`.
454
+ const corroborating = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
455
+ const hashCandidates = await Promise.all(corroborating.map(async (c) => ({ peerId: c.peerIdStr, hash: await canonicalBlockHash(c.block), block: c.block })));
456
+ const agreed = selectQuorumBlock(hashCandidates, consensusConfig.simpleMajorityThreshold);
457
+ if (!agreed)
458
+ return; // no content quorum → skip persist
459
+ // Best-effort: penalize cohort members that served content contradicting the
460
+ // agreed hash for the same committed (rev, actionId). Never let this throw.
461
+ try {
462
+ for (const c of hashCandidates) {
463
+ if (c.hash !== agreed.hash) {
464
+ reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
465
+ }
380
466
  }
381
467
  }
382
- if (best) {
383
- await storageRepo.saveReplicatedBlock(blockId, best.block, best.source);
468
+ catch { /* reputation write must never block restoration */ }
469
+ await storageRepo.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
470
+ };
471
+ // Member-side membership derivation for the admission gate: independently re-derive this block's
472
+ // responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
473
+ // network-size confidence. A member gates a coordinator-declared peer set against this view before
474
+ // voting, so a self-shrunk minority-partition set cannot be voted into super-majority (see cluster-repo
475
+ // admitMembership). No FRET ⇒ confidence 0 ⇒ the gate fails closed for any downsize.
476
+ const deriveExpectedCluster = async (blockId) => {
477
+ const peers = await keyNetwork.findCluster(new TextEncoder().encode(blockId));
478
+ let confidence = 0;
479
+ if (fretSvc) {
480
+ try {
481
+ confidence = fretSvc.getNetworkSizeEstimate().confidence;
482
+ }
483
+ catch {
484
+ // Leave confidence 0 → fail closed for downsizing.
485
+ }
384
486
  }
487
+ return { peers: peers ?? {}, confidence };
385
488
  };
386
489
  clusterImpl = clusterMember({
387
490
  storageRepo,
@@ -396,7 +499,8 @@ export async function createLibp2pNodeBase(options, defaults) {
396
499
  consensusConfig,
397
500
  stateStore: options.transactionStateStore,
398
501
  reconcileBlock,
399
- onCommitCertificate
502
+ onCommitCertificate,
503
+ deriveExpectedCluster
400
504
  // `recomputeArbitratorSet` (invalidation layer-2) is intentionally NOT wired here yet: a live FRET
401
505
  // recompute needs a churn-tolerance window so it does not false-reject legitimate certificates from
402
506
  // late-joiners (a liveness regression). Until that is tuned against live topology — and the
@@ -405,7 +509,7 @@ export async function createLibp2pNodeBase(options, defaults) {
405
509
  // `verifyInvalidationCertificate` and `tickets/plan/cohort-topic-membership-cert-trust-anchoring.md`.
406
510
  });
407
511
  const coordinatorRepoFactory = coordinatorRepo(keyNetwork, createClusterClient, {
408
- clusterSize: options.clusterSize ?? 10,
512
+ // clusterSize is now part of consensusConfig (member + coordinator share one reference).
409
513
  ...consensusConfig
410
514
  }, fretSvc, reputation, options.transactionStateStore);
411
515
  // Create callback for querying cluster peers for their latest block revision
@@ -448,36 +552,68 @@ export async function createLibp2pNodeBase(options, defaults) {
448
552
  localPeerId: node.peerId,
449
553
  clusterLatestCallback
450
554
  });
555
+ // Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
556
+ // coordinator (what declares a transaction committed on that super-majority) MUST run the same
557
+ // threshold, or the node would come up able to disagree with itself mid-consensus. Both are fed from
558
+ // the single `consensusConfig` above; this asserts on their RESOLVED values so any future drift throws
559
+ // HERE at construction. See `assertSuperMajorityCoupling`.
560
+ assertSuperMajorityCoupling(clusterImpl, coordinatedRepo);
451
561
  // Recover persisted transaction state before accepting new requests
452
562
  if (options.transactionStateStore) {
453
563
  await clusterImpl.recoverTransactions();
454
564
  await coordinatedRepo.recoverTransactions();
455
565
  }
566
+ // --- Shared owned-block set for the resilience monitors ---
567
+ // SpreadOnChurnMonitor (sender) and RebalanceMonitor (responsibility tracker) both act on "the
568
+ // blocks this node physically holds". They share ONE Set so the two can never drift: a single
569
+ // owned-block feed populates it, and the rebalance responsibility-loss signal evicts from it
570
+ // (in the rebalance block below). Both monitors take this exact instance via deps.trackedBlocks.
571
+ const networkManager = node.services?.networkManager;
572
+ const ownedBlocks = new Set();
573
+ // Single owned-block feed: every block this node commits OR receives as a replica fires
574
+ // storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
575
+ // node.blockChangeNotifier): the cohort-topic activation block below may replace
576
+ // blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its own
577
+ // surface regardless of that opt-in. NOTE: this feed does NOT re-emit blocks already durable
578
+ // from a previous run; those are seeded once at startup by the storage-enumeration scan wired
579
+ // below (seedOwnedBlocksFromStorage), so a restarted node protects on-disk data without waiting
580
+ // for each block to be touched again. Registered lazily the first time a
581
+ // monitor that reads ownedBlocks is wired, so when BOTH monitors are disabled no subscription
582
+ // leaks; torn down exactly once in the stop wrapper below.
583
+ let offOwnedBlockFeed;
584
+ const ensureOwnedBlockFeed = () => {
585
+ if (offOwnedBlockFeed)
586
+ return;
587
+ offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
588
+ for (const blockId of e.blockIds)
589
+ ownedBlocks.add(blockId);
590
+ });
591
+ };
592
+ // Single owned-block-feed teardown. Registered up front (before either monitor's own stop
593
+ // wrapper) so it runs regardless of WHICH monitor subscribed the feed - including the
594
+ // spread-disabled / rebalance-only case. Idempotent: offOwnedBlockFeed is undefined-guarded.
595
+ {
596
+ const previousStop = node.stop.bind(node);
597
+ node.stop = async () => {
598
+ try {
599
+ offOwnedBlockFeed?.();
600
+ }
601
+ finally {
602
+ await previousStop();
603
+ }
604
+ };
605
+ }
456
606
  // --- Churn-resilient spread: drive SpreadOnChurnMonitor on a live node ---
457
607
  // Nothing previously activated the SENDING side of the churn-resilient spread protocol on a
458
- // real node. Here we init + start the monitor and feed it the blocks this node physically
459
- // holds, so a debounced connection:close re-pushes them to expansion-cohort peers (the
460
- // receiver durably persists each push via saveReplicatedBlock).
461
- const networkManager = node.services?.networkManager;
608
+ // real node. Here we init + start the monitor (sharing ownedBlocks) and ensure the single
609
+ // owned-block feed is live, so a debounced connection:close re-pushes the node's blocks to
610
+ // expansion-cohort peers (the receiver durably persists each push via saveReplicatedBlock).
462
611
  let spreadMonitor;
463
- let offOwnedBlockFeed;
464
612
  if (networkManager && (options.spreadOnChurn?.enabled ?? true) !== false) {
465
613
  try {
466
- spreadMonitor = networkManager.initSpreadOnChurnMonitor(partitionDetector, storageRepo, keyNetwork, options.clusterSize ?? 10, protocolPrefix, options.spreadOnChurn);
614
+ spreadMonitor = networkManager.initSpreadOnChurnMonitor(partitionDetector, storageRepo, keyNetwork, options.clusterSize ?? 10, protocolPrefix, ownedBlocks, options.spreadOnChurn);
467
615
  await spreadMonitor.start();
468
- // Feed owned blocks: every block this node commits OR receives as a replica fires
469
- // storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
470
- // node.blockChangeNotifier): the cohort-topic activation block below may replace
471
- // blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its
472
- // own surface regardless of that opt-in. NOTE: blocks already durable from a previous
473
- // run are NOT re-emitted on startup, so they are not tracked until next touched -
474
- // acceptable here (churn re-replication re-derives over time); an initial-scan is a
475
- // follow-on enhancement.
476
- const monitor = spreadMonitor;
477
- offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
478
- for (const blockId of e.blockIds)
479
- monitor.trackBlock(blockId);
480
- });
616
+ ensureOwnedBlockFeed();
481
617
  }
482
618
  catch (err) {
483
619
  // Spread is a resilience optimization, not a correctness requirement - a wiring
@@ -488,16 +624,15 @@ export async function createLibp2pNodeBase(options, defaults) {
488
624
  }
489
625
  // Expose for tests/diagnostics (mirrors node.keyNetwork / node.reputation).
490
626
  node.spreadOnChurnMonitor = spreadMonitor;
491
- // Disposal: release the owned-block subscription + stop the monitor deterministically
492
- // before the transports close. Composes with the arachnode / clusterMember / cohort-topic
493
- // stop wrappers (each calls its captured previousStop last). Both steps are idempotent (the
494
- // unsubscribe is flag-guarded; SpreadOnChurnMonitor.stop early-returns when not running), so
495
- // a double node.stop() does not throw.
627
+ // Disposal: stop the spread monitor deterministically before the transports close. Composes
628
+ // with the arachnode / clusterMember / cohort-topic stop wrappers (each calls its captured
629
+ // previousStop last). Idempotent (SpreadOnChurnMonitor.stop early-returns when not running), so
630
+ // a double node.stop() does not throw. The owned-block feed teardown is the separate up-front
631
+ // wrapper above (shared across both monitors).
496
632
  {
497
633
  const previousStop = node.stop.bind(node);
498
634
  node.stop = async () => {
499
635
  try {
500
- offOwnedBlockFeed?.();
501
636
  if (spreadMonitor)
502
637
  await spreadMonitor.stop();
503
638
  }
@@ -512,14 +647,34 @@ export async function createLibp2pNodeBase(options, defaults) {
512
647
  const log = node.logger?.forComponent?.('db-p2p:arachnode');
513
648
  const fret = node.services?.fret;
514
649
  if (fret) {
515
- const fretAdapter = new ArachnodeFretAdapter(fret);
650
+ const fretAdapter = new ArachnodeFretAdapter(fret, node.peerId.toString());
651
+ // Blocks whose shed range has been RELEASED (Phase C of a ring shift, or a confirmed
652
+ // rebalance release). This is the GC-eligibility signal the future storage sweep
653
+ // (`st-storage-sweep-archival-and-capacity-estimate`) must consult: a block's local bytes may
654
+ // be reclaimed ONLY once it appears here, so an unconfirmed / still-served range is never
655
+ // swept. Populated strictly after replication is confirmed. See
656
+ // docs/arachnode-ring-handoff.md § Part 2 (Local bytes vs. tracking).
657
+ // NOTE: no sweep consumes this set yet; it is the coordinated eligibility handoff the sweep
658
+ // ticket will read. Until then it grows unbounded — bound it when the sweep lands.
659
+ const gcEligible = new Set();
660
+ node.gcEligibleBlocks = gcEligible;
661
+ // The ring-shift state machine (advertise→confirm→release). Wired inside the rebalance block
662
+ // below (it needs the BlockTransferCoordinator confirmer + the cohort-size floor); left
663
+ // undefined when the rebalance reaction is not wired, in which case ring shifts stay inert —
664
+ // a move-out is unsafe without the confirm/release path.
665
+ let ringShift;
516
666
  const storageMonitor = new StorageMonitor(rawStorage, options.arachnode?.storage ?? {});
517
667
  const ringSelector = new RingSelector(fretAdapter, storageMonitor, {
518
668
  minCapacity: 100 * 1024 * 1024,
519
669
  thresholds: {
520
670
  moveOut: 0.85,
521
671
  moveIn: 0.40
522
- }
672
+ },
673
+ // Damping so the ring decision cannot thrash near a boundary
674
+ // (docs/arachnode-ring-handoff.md § Part 1).
675
+ smoothingAlpha: 0.2,
676
+ deadband: 0.5,
677
+ minDwellMs: 10 * 60 * 1000
523
678
  });
524
679
  // Determine and announce ring membership
525
680
  const peerId = node.peerId.toString();
@@ -534,14 +689,142 @@ export async function createLibp2pNodeBase(options, defaults) {
534
689
  };
535
690
  // Replace the restore callback (this is a bit hacky, but works for now)
536
691
  storageRepo.createBlockStorage = (blockId) => new BlockStorage(blockId, rawStorage, newRestoreCallback);
537
- // Monitor capacity and adjust ring periodically
692
+ // --- Rebalance reaction: drive RebalanceMonitor + react via BlockTransferCoordinator ---
693
+ // Nothing previously activated the rebalance path on a real node: initRebalanceMonitor was
694
+ // never called, the monitor was never start()ed, and BlockTransferCoordinator (the
695
+ // pull-gained / push-lost reaction primitive) was never constructed in src. This block lives
696
+ // inside the arachnode `if (fret)` gate because both dependencies only exist here — the
697
+ // fretAdapter and the RestorationCoordinator. When arachnode is disabled or FRET is absent the
698
+ // rebalance path stays inert (acceptable: rebalance is a resilience optimization). A wiring
699
+ // failure here is non-fatal (log + continue), unlike the operator-opted-in cohortTopic block.
700
+ if (networkManager && (options.rebalance?.enabled ?? true) !== false) {
701
+ try {
702
+ // repo → the LOCAL storageRepo (not repoProxy/coordinatedRepo): a pulled/pushed replica
703
+ // must land in / be read from this node's own storage, same reasoning as the
704
+ // blockTransfer service handler registration. protocolPrefix (/optimystic/<networkName>)
705
+ // MUST match the prefix the node registers its block-transfer handler under, or every
706
+ // lost-block push dials the wrong protocol and fails to connect.
707
+ const coordinator = new BlockTransferCoordinator(storageRepo, keyNetwork, restorationCoordinatorV2, partitionDetector, protocolPrefix);
708
+ const rebalanceMonitor = networkManager.initRebalanceMonitor(partitionDetector, fretAdapter, ownedBlocks, options.rebalance);
709
+ await rebalanceMonitor.start();
710
+ // onRebalance fires synchronously from the monitor's debounced check; the coordinator's
711
+ // reaction (pull gained / push lost, each partition-guarded) is async, so hop it off the
712
+ // handler rather than blocking the monitor's emit loop. handleRebalanceEvent can REJECT
713
+ // (e.g. RestorationCoordinator.restore() throws while pulling a gained block) and a bare
714
+ // `void` would surface that as an unhandled rejection (process-fatal on Node >=15); the
715
+ // reaction is a resilience optimization, so swallow + log instead.
716
+ //
717
+ // ALONGSIDE dispatching to the coordinator, drive the shared owned-block set off this
718
+ // authoritative responsibility signal. A GAINED block is added immediately so it is
719
+ // tracked even before its next commit/replica touches the feed.
720
+ //
721
+ // A LOST block is NO LONGER released synchronously: doing so stopped spreading a block
722
+ // whose push to the new owners might fail, drop it below the replication floor, and let a
723
+ // later sweep reclaim it (docs/arachnode-ring-handoff.md § Why the current code violates
724
+ // it #2). Instead the release is GATED on confirmation — the coordinator returns the lost
725
+ // blocks it confirmed replicated to ≥ floor new owners, and ONLY those are untracked
726
+ // (authoritative eviction from the shared set — complements spread's lazy self-prune) and
727
+ // marked GC-eligible. A lost block whose push failed / was partition-skipped stays
728
+ // tracked and served, and is retried on the next rebalance.
729
+ //
730
+ // Best-effort iteration safety: this eviction can mutate ownedBlocks while
731
+ // SpreadOnChurnMonitor (or this monitor) is mid for...of over the same Set inside an
732
+ // async loop. Adding/deleting a Set entry during iteration does not throw in JS — entries
733
+ // are visited best-effort — which is acceptable for a resilience mechanism, so we
734
+ // document it here rather than add locking.
735
+ rebalanceMonitor.onRebalance((event) => {
736
+ for (const blockId of event.gained)
737
+ ownedBlocks.add(blockId);
738
+ coordinator.handleRebalanceEvent(event).then((result) => {
739
+ for (const blockId of result.released) {
740
+ rebalanceMonitor.untrackBlock(blockId); // also evicts from the shared ownedBlocks set
741
+ gcEligible.add(blockId); // confirmed replicated → safe to sweep
742
+ }
743
+ }).catch((err) => {
744
+ log?.('rebalance reaction failed: %o', err);
745
+ });
746
+ });
747
+ // Ring-shift handoff (advertise→confirm→release). It needs the confirmer (this
748
+ // coordinator) and the cohort-size floor (this monitor), so it is wired here. The
749
+ // `onRelease` callback runs Phase C's local effect: stop serving/spreading the shed
750
+ // range and mark it GC-eligible — the same authoritative eviction the confirmed-rebalance
751
+ // release performs.
752
+ ringShift = new RingShiftCoordinator({
753
+ fretAdapter,
754
+ ringSelector,
755
+ fret,
756
+ partitionDetector,
757
+ confirmer: coordinator,
758
+ ownedBlocks,
759
+ selfPeerId: peerId,
760
+ getFloor: () => rebalanceMonitor.getCohortSize(),
761
+ onRelease: (blockIds) => {
762
+ for (const blockId of blockIds) {
763
+ rebalanceMonitor.untrackBlock(blockId);
764
+ gcEligible.add(blockId);
765
+ }
766
+ }
767
+ });
768
+ // Reconcile any stale `moving` advertisement left by a crash mid-handoff (no-op unless
769
+ // arachnode metadata survived a restart still marked `moving`).
770
+ ringShift.reconcileOnStart();
771
+ // Feed owned blocks via the SINGLE shared feed (idempotent — already live if the spread
772
+ // block above wired it). Both monitors read the same ownedBlocks set this populates.
773
+ ensureOwnedBlockFeed();
774
+ // Expose for tests/diagnostics (mirrors node.spreadOnChurnMonitor).
775
+ node.rebalanceMonitor = rebalanceMonitor;
776
+ node.blockTransferCoordinator = coordinator;
777
+ node.ringShiftCoordinator = ringShift;
778
+ // Disposal: stop the monitor before transports close. Composes with the other stop
779
+ // wrappers (each calls its captured previousStop last). Idempotent — RebalanceMonitor.stop()
780
+ // early-returns when not running (NetworkManagerService.stop() also stops it). The shared
781
+ // owned-block feed teardown is the separate up-front wrapper (not duplicated here).
782
+ const previousStop = node.stop.bind(node);
783
+ node.stop = async () => {
784
+ try {
785
+ await rebalanceMonitor.stop();
786
+ }
787
+ finally {
788
+ await previousStop();
789
+ }
790
+ };
791
+ }
792
+ catch (err) {
793
+ // Rebalance is a resilience optimization, not a correctness requirement - a wiring
794
+ // failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup.
795
+ log?.('rebalance wiring init failed: %o', err);
796
+ }
797
+ }
798
+ // Monitor capacity and adjust ring periodically. The damped `shouldTransition()` decides
799
+ // WHETHER/where to move (docs/arachnode-ring-handoff.md § Part 1); the RingShiftCoordinator
800
+ // carries the move out through the advertise→confirm→release handoff (§ Part 2) so a shift
801
+ // never drops a key below its replication floor. The old unilateral `setArachnodeInfo` flip —
802
+ // which changed advertised responsibility instantly with no data handoff — is gone.
803
+ //
804
+ // Ring shifts run ONLY when `ringShift` is wired (i.e. the rebalance reaction is enabled): a
805
+ // move-out is unsafe without the confirm/release path, so a node with the rebalance reaction
806
+ // disabled stays at its bootstrap ring rather than flipping unsafely.
538
807
  const monitorInterval = setInterval(async () => {
808
+ if (!ringShift)
809
+ return;
539
810
  const transition = await ringSelector.shouldTransition();
540
- if (transition.shouldMove) {
811
+ if (transition.shouldMove && transition.direction && transition.newRingDepth !== undefined) {
541
812
  log?.('Ring transition needed: moving %s to Ring %d', transition.direction, transition.newRingDepth);
542
- // Update Arachnode info with new ring
543
- const updatedInfo = await ringSelector.createArachnodeInfo(peerId);
544
- fretAdapter.setArachnodeInfo(updatedInfo);
813
+ try {
814
+ const outcome = await ringShift.executeShift({
815
+ direction: transition.direction,
816
+ newRingDepth: transition.newRingDepth
817
+ });
818
+ log?.('Ring shift outcome: %o', outcome);
819
+ }
820
+ catch (err) {
821
+ log?.('Ring shift failed: %o', err);
822
+ }
823
+ finally {
824
+ // Measure the minimum dwell from the SETTLED shift (completed or rolled back), not
825
+ // just the trigger stamped inside shouldTransition (docs/arachnode-ring-handoff.md §1.3).
826
+ ringSelector.recordShiftSettled();
827
+ }
545
828
  }
546
829
  }, 60_000);
547
830
  // Cleanup on node stop
@@ -555,6 +838,44 @@ export async function createLibp2pNodeBase(options, defaults) {
555
838
  log?.('FRET service not available, Arachnode disabled');
556
839
  }
557
840
  }
841
+ // --- Seed the shared owned-block set from already-durable storage ---
842
+ // Blocks durable from a previous run are otherwise untracked until next touched (see the
843
+ // onAnyCollectionChange comment above where ownedBlocks is declared). Placed here, AFTER both
844
+ // monitor-wiring blocks (spread ~line 862, rebalance ~line 974) have had their chance to call
845
+ // ensureOwnedBlockFeed():
846
+ // - Gate on offOwnedBlockFeed: only seed when a monitor actually consumes ownedBlocks; if both
847
+ // are disabled the set is unused and the scan (plus the background task) is wasted work.
848
+ // - Feed-before-scan ordering is load-bearing: because the feed is already live, a block
849
+ // committed/replicated DURING the scan is caught by the feed; Set.add is idempotent so the
850
+ // overlap is harmless. Scanning before subscribing would drop a block committed in the gap.
851
+ // - Fire-and-forget so a large store never blocks startup; the .catch keeps a scan rejection
852
+ // from becoming an unhandled rejection.
853
+ // - Cancellable: a stop wrapper flips seedStopping so the scan loop breaks against a
854
+ // stopping/closing backend rather than running the enumeration to completion.
855
+ // NOTE: a concurrent rebalance release can untrackBlock (delete from ownedBlocks) a confirmed-
856
+ // released block while this scan is still running, and the scan could then re-add that id. Benign
857
+ // transient: the block is still in the metadata store (no sweep reclaims metadata yet), so a
858
+ // re-added released block is simply re-evaluated and re-released on the next rebalance tick. Right
859
+ // after a restart, responsibility-loss detection lags this fast metadata scan, so the window is
860
+ // small. Accepted rather than synchronized.
861
+ if (offOwnedBlockFeed && typeof rawStorage.listBlockIds === 'function') {
862
+ let seedStopping = false;
863
+ const previousStop = node.stop.bind(node);
864
+ node.stop = async () => {
865
+ seedStopping = true;
866
+ await previousStop();
867
+ };
868
+ void seedOwnedBlocksFromStorage(rawStorage, ownedBlocks, () => seedStopping)
869
+ .catch((err) => (node.logger?.forComponent?.('db-p2p:owned-block-seed'))?.('seed failed: %o', err));
870
+ }
871
+ // [dispute-subsystem-dormant] The DisputeService object is constructed below so tests and
872
+ // getDisputeStatus() work, but it is unreachable from the live network path:
873
+ // - No inbound handler: disputeProtocolService is NOT in the services map above.
874
+ // - onInvalidation is deliberately unset: maybeInvalidate() is a no-op on live nodes.
875
+ // - revalidate is deliberately unset: handleChallenge always votes inconclusive on live nodes.
876
+ // Full activation requires arbitrator-set anchoring before a forged synthetic cohort can pass resolution.
877
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
878
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
558
879
  // Initialize dispute service if enabled
559
880
  let disputeServiceInstance;
560
881
  if (options.dispute?.disputeEnabled) {
@@ -567,24 +888,25 @@ export async function createLibp2pNodeBase(options, defaults) {
567
888
  reputation,
568
889
  validator: options.validator,
569
890
  config: options.dispute,
570
- selectArbitrators: async (blockId, excludePeers, count) => {
891
+ selectArbitrators: async (blockId, excludePeers, count, round, epoch) => {
571
892
  const { hashKey: fretHashKey } = await import('p2p-fret');
572
- const blockIdBytes = new TextEncoder().encode(blockId);
573
893
  const fret = node.services?.fret;
574
894
  if (!fret)
575
895
  return [];
576
- // Get a larger cohort and exclude the original cluster peers
577
- const cohortSize = count + excludePeers.length + 1;
578
- const hashedCoord = await fretHashKey(blockIdBytes);
579
- const allPeerIdStrs = fret.assembleCohort(hashedCoord, cohortSize);
580
- // Filter out original cluster peers and self, convert to PeerId
896
+ // Dispersed sampling: draw `count` peers from coordinates spread across the whole keyspace
897
+ // (hash(blockId round epoch i)) rather than the block's XOR neighborhood, so an attacker
898
+ // who owns the block's locale does not thereby own the arbitrators. `assembleCohort` already
899
+ // filters to known members; excluding the original cluster + self keeps arbitrators independent.
581
900
  const excludeSet = new Set(excludePeers);
901
+ // NOTE: adding the local node's own id to `exclude` makes the draw node-relative. Cross-node
902
+ // determinism (the verifiable-recompute property) holds today only because the dissent
903
+ // coordinator running this is itself a member of the original cluster, so `self` is already in
904
+ // `excludePeers` — the add is a no-op and every honest node excludes the identical set. When a
905
+ // verify-path recompute lands, it MUST reconstruct `exclude` from the challenger's identity
906
+ // (`proof.challengerPeerId`) + original cluster, never the verifier's own id, or re-derivation diverges.
582
907
  excludeSet.add(node.peerId.toString());
583
- const arbitratorPeerIds = allPeerIdStrs
584
- .filter(pid => !excludeSet.has(pid))
585
- .slice(0, count)
586
- .map(pid => peerIdFromString(pid));
587
- return arbitratorPeerIds;
908
+ const picks = await sampleArbitrators({ blockId: new TextEncoder().encode(blockId), round, epoch, count, exclude: excludeSet }, (coord, wants) => fret.assembleCohort(coord, wants), fretHashKey);
909
+ return picks.map(pid => peerIdFromString(pid));
588
910
  },
589
911
  });
590
912
  }
@@ -607,6 +929,12 @@ export async function createLibp2pNodeBase(options, defaults) {
607
929
  node.keyNetwork = keyNetwork;
608
930
  node.reputation = reputation;
609
931
  node.disputeService = disputeServiceInstance;
932
+ // The node's libp2p Ed25519 identity key. Exposed on the same `(node as any).*` surface as
933
+ // coordinatedRepo/keyNetwork so a host can bind a client-transaction signer to it (the Quereus
934
+ // collection-factory's getSigner reuses this via signPeer). libp2p does not surface the private
935
+ // key on its public `Libp2p` interface, so this attachment is the sanctioned in-process handle.
936
+ // Ed25519 by construction (options.privateKey defaults to generateKeyPair('Ed25519')).
937
+ node.peerPrivateKey = nodePrivateKey;
610
938
  // --- Cohort-topic origination activation (post-node: consumes the fully-assembled node + FRET) ---
611
939
  // This is the only place that is after the node + FRET are assembled (node.start() done, fretSvc
612
940
  // available) yet before any caller can capture `blockChangeNotifier` — the Quereus collection-factory