@optimystic/db-p2p 0.14.1 → 0.16.3

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 +441 -89
  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 +511 -81
  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
@@ -1,7 +1,7 @@
1
1
  import { createLibp2p, type Libp2p } from 'libp2p';
2
2
  import { noise } from '@chainsafe/libp2p-noise';
3
3
  import { yamux } from '@chainsafe/libp2p-yamux';
4
- import { identify } from '@libp2p/identify';
4
+ import { identify, identifyPush } from '@libp2p/identify';
5
5
  import { ping } from '@libp2p/ping';
6
6
  import { dcutr } from '@libp2p/dcutr';
7
7
  import { autoNAT } from '@libp2p/autonat';
@@ -14,26 +14,31 @@ import type { ConnectionGater, PrivateKey } from '@libp2p/interface';
14
14
  import { clusterService } from './cluster/service.js';
15
15
  import { blockTransferService } from './cluster/block-transfer-service.js';
16
16
  import { repoService } from './repo/service.js';
17
- import { StorageRepo } from './storage/storage-repo.js';
17
+ import { StorageRepo, withBlockCommitLatch } from './storage/storage-repo.js';
18
18
  import { BlockStorage } from './storage/block-storage.js';
19
19
  import { MemoryRawStorage } from './storage/memory-storage.js';
20
20
  import type { IRawStorage } from './storage/i-raw-storage.js';
21
- import { clusterMember, type ReconcileBlockCallback, type CommitCertificateSink } from './cluster/cluster-repo.js';
21
+ import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
22
+ import { clusterMember, type ReconcileBlockCallback, type CommitCertificateSink, type DeriveExpectedClusterCallback } from './cluster/cluster-repo.js';
23
+ import { selectQuorumRev, selectQuorumBlock, canonicalBlockHash, type RevClaim, type BlockHashCandidate } from './cluster/quorum-restore.js';
22
24
  import { createCommitCertStore, makeClusterCommitCertExtractor, type CommitCertStore } from './cluster/commit-cert.js';
23
25
  import { coordinatorRepo } from './repo/coordinator-repo.js';
24
26
  import { Libp2pKeyPeerNetwork, type NetworkMode, type NetworkStatePersistence } from './libp2p-key-network.js';
25
27
  import { ClusterClient } from './cluster/client.js';
26
- import type { IRepo, ICluster, ITransactionValidator, BlockId, ActionRev, IBlock, IBlockChangeNotifier } from '@optimystic/db-core';
28
+ import type { IRepo, ICluster, ITransactionValidator, BlockId, IBlock, IBlockChangeNotifier } from '@optimystic/db-core';
27
29
  import type { ITransactionStateStore } from './cluster/i-transaction-state-store.js';
28
30
  import { networkManagerService, type NetworkManagerService } from './network/network-manager-service.js';
29
31
  import type { SpreadOnChurnConfig, SpreadOnChurnMonitor } from './cluster/spread-on-churn.js';
32
+ import { BlockTransferCoordinator } from './cluster/block-transfer.js';
33
+ import type { RebalanceMonitorConfig } from './cluster/rebalance-monitor.js';
30
34
  import { fretService, Libp2pFretService } from 'p2p-fret';
31
35
  import { syncService } from './sync/service.js';
32
36
  import { SyncClient } from './sync/client.js';
33
37
  import type { SyncResponse } from './sync/protocol.js';
34
38
  import type { ClusterLatestCallback } from './repo/coordinator-repo.js';
35
- import { RestorationCoordinator } from './storage/restoration-coordinator-v2.js';
39
+ import { RestorationCoordinator } from './storage/restoration-coordinator.js';
36
40
  import { RingSelector } from './storage/ring-selector.js';
41
+ import { RingShiftCoordinator } from './storage/ring-shift-coordinator.js';
37
42
  import { StorageMonitor } from './storage/storage-monitor.js';
38
43
  import type { StorageMonitorConfig } from './storage/storage-monitor.js';
39
44
  import { ArachnodeFretAdapter } from './storage/arachnode-fret-adapter.js';
@@ -65,6 +70,7 @@ import {
65
70
  reactivityNodePolicy,
66
71
  createTierAddressing,
67
72
  createRingHash,
73
+ DEFAULT_SUPER_MAJORITY_THRESHOLD,
68
74
  Tier,
69
75
  b64urlToBytes,
70
76
  bytesToB64url,
@@ -75,18 +81,57 @@ import {
75
81
  type NotificationVerifier,
76
82
  } from '@optimystic/db-core';
77
83
  import { PartitionDetector } from './cluster/partition-detector.js';
84
+ import { assertSuperMajorityCoupling } from './cluster/supermajority-coupling.js';
78
85
  import { createLogger } from './logger.js';
79
86
  import { PeerReputationService } from './reputation/peer-reputation.js';
87
+ import type { IPeerReputation } from './reputation/types.js';
88
+ import { PenaltyReason } from './reputation/types.js';
80
89
  import { DisputeService } from './dispute/dispute-service.js';
81
90
  import { DisputeClient } from './dispute/client.js';
91
+ import { sampleArbitrators } from './dispute/arbitrator-selection.js';
82
92
  import type { DisputeConfig } from './dispute/types.js';
83
93
 
84
94
  type Libp2pInit = NonNullable<Parameters<typeof createLibp2p>[0]>;
85
95
  export type Libp2pTransports = NonNullable<Libp2pInit['transports']>;
86
96
 
97
+ /** A service that accepts post-construction injection of the running libp2p node. */
98
+ interface SetLibp2pCapable {
99
+ setLibp2p(libp2p: Libp2p): void;
100
+ }
101
+
102
+ /** A service that accepts post-start injection of the peer-reputation view. */
103
+ interface SetReputationCapable {
104
+ setReputation(reputation: IPeerReputation): void;
105
+ }
106
+
107
+ /**
108
+ * The custom services that receive post-assembly dependency injection. Reaching them through this
109
+ * typed record (rather than `(node as any).services?.fret?.setLibp2p?.(...)`) removes the silent
110
+ * optional-chaining skips: a call like `wired.fret.setLibp2p(node)` is checked against these
111
+ * interfaces at build time (a caller-side typo or wrong-arity call fails tsc), and if the service is
112
+ * absent at runtime the property access throws (fail-fast) instead of being quietly no-op'd. Note the
113
+ * config-side `services` map is itself cast (see the comment at its declaration), so a service RENAME
114
+ * is caught here at runtime, not by tsc, and a signature change on the real service is caught only at
115
+ * that service's own definition. All three services are unconditionally present in that config, so a
116
+ * throw here is a genuine wiring bug, not a missing service.
117
+ */
118
+ type WiredServices = {
119
+ fret: SetLibp2pCapable;
120
+ networkManager: SetLibp2pCapable & SetReputationCapable;
121
+ repo: SetLibp2pCapable;
122
+ };
123
+
87
124
  /** Logger for the reactivity node-wiring (origination/forwarder/recover/rotation composition). */
88
125
  const reactivityWiringLog = createLogger('reactivity-node-wiring');
89
126
 
127
+ /**
128
+ * Logger for the best-effort in-factory service wiring. These injections run during `createLibp2p`
129
+ * internals against the unreliable `components.libp2p` proxy; the real node is re-injected
130
+ * post-construction (see the load-bearing block after `createLibp2p`), so a failure here is logged,
131
+ * not fatal.
132
+ */
133
+ const wiringLog = createLogger('node-wiring');
134
+
90
135
  /** Factory function or instance for creating raw storage */
91
136
  export type RawStorageProvider = IRawStorage | (() => IRawStorage);
92
137
 
@@ -133,7 +178,7 @@ export type NodeOptions = {
133
178
  clusterPolicy?: {
134
179
  allowDownsize?: boolean;
135
180
  sizeTolerance?: number; // acceptable relative difference (e.g. 0.5 = +/-50%)
136
- superMajorityThreshold?: number; // fraction of peers needed for super-majority (default: 0.67)
181
+ superMajorityThreshold?: number; // fraction of peers needed for super-majority (default: DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75)
137
182
  };
138
183
 
139
184
  /** Override libp2p listen multiaddrs. */
@@ -165,6 +210,16 @@ export type NodeOptions = {
165
210
  */
166
211
  spreadOnChurn?: Partial<SpreadOnChurnConfig>;
167
212
 
213
+ /**
214
+ * Rebalance reaction tuning. Drives the RebalanceMonitor + BlockTransferCoordinator pull-gained/
215
+ * push-lost path when arachnode/FRET are available (the only place fretAdapter + restoration
216
+ * coordinator exist). Absent -> enabled with defaults (see RebalanceMonitorConfig). Set
217
+ * { enabled: false } to disable the rebalance reaction on this node. When arachnode is disabled
218
+ * or FRET is absent the rebalance path stays inert regardless of this flag (rebalance is a
219
+ * resilience optimization, not a correctness requirement).
220
+ */
221
+ rebalance?: Partial<RebalanceMonitorConfig> & { enabled?: boolean };
222
+
168
223
  /** Transaction validator for cluster consensus */
169
224
  validator?: ITransactionValidator;
170
225
 
@@ -288,6 +343,15 @@ export async function createLibp2pNodeBase(
288
343
  new BlockStorage(blockId, rawStorage, restoreCallback)
289
344
  );
290
345
 
346
+ // Per-block commit-latch runner, ready to thread into the invalidation-apply sink (`onInvalidate`)
347
+ // passed to `clusterMember(...)` and into each cascade `CollectionEnv`, the instant either is wired
348
+ // here. Sharing the `StorageRepo.commit:<blockId>` latch makes a compensating saveReplica/saveDeletion
349
+ // RMW of `meta.latest` mutually exclusive with a concurrent commit on the same block. It is unused
350
+ // today only because no `onInvalidate`/cascade driver is wired in the live node (see review handoff);
351
+ // it is bound here so that wiring is a one-liner and cannot reach for a divergent latch key.
352
+ const blockCommitLatch = withBlockCommitLatch;
353
+ void blockCommitLatch;
354
+
291
355
  let clusterImpl: ICluster | undefined;
292
356
  let coordinatedRepo: IRepo | undefined;
293
357
 
@@ -341,26 +405,60 @@ export async function createLibp2pNodeBase(
341
405
  ? (actionId, cert): void => { certStore.put(actionId, cert); options.onCommitCertificate?.(actionId, cert); }
342
406
  : options.onCommitCertificate;
343
407
 
344
- const libp2pOptions: unknown = {
408
+ const libp2pOptions: Libp2pInit = {
345
409
  start: false,
346
410
  privateKey: nodePrivateKey,
347
411
  addresses: {
348
412
  listen: listenAddrs
349
413
  },
350
414
  connectionManager: {
351
- autoDial: true,
352
- minConnections: 1,
415
+ // `autoDial`, `minConnections`, and `dialQueue` were stale libp2p option keys silently
416
+ // ignored under the former `libp2pOptions as any` (removed with this change). This libp2p
417
+ // version has no such keys — auto-dial is now default connection-manager behavior with no
418
+ // direct replacement — so they are dropped rather than re-cast. See review handoff.
353
419
  maxConnections: 16,
354
- inboundConnectionUpgradeTimeout: 10_000,
355
- dialQueue: { concurrency: 2, attempts: 2 }
420
+ // Renamed from the stale `inboundConnectionUpgradeTimeout`. 10_000 equals this version's
421
+ // default, so surfacing (and correcting) the key is behavior-preserving; the old key was a no-op.
422
+ inboundUpgradeTimeout: 10_000
356
423
  },
357
424
  ...(options.connectionGater ? { connectionGater: options.connectionGater } : {}),
358
425
  transports,
359
426
  connectionEncrypters: [noise()],
360
427
  streamMuxers: [yamux()],
361
- services: {
428
+ // Narrow cast confined to the `services` field: the built-in factories (identify/dcutr/…) are
429
+ // typed against a SECOND copy of `@libp2p/interface` pulled in transitively (via `@libp2p/crypto`),
430
+ // whose `Uint8Array<ArrayBuffer>` vs `<ArrayBufferLike>` PeerId/key shapes are structurally
431
+ // incompatible with the top-level copy — a dependency-dedup artifact, not a real mismatch. The cast
432
+ // stays on this field alone so the rest of `libp2pOptions` remains fully typed as `Libp2pInit`.
433
+ // NOTE: this cast exists ONLY because of the duplicate @libp2p/interface install; if that dedups
434
+ // (or on a libp2p bump) drop `as unknown as NonNullable<Libp2pInit['services']>` and type the map directly.
435
+ services: ({
436
+ // `@libp2p/identify` is the ONE service here whose protocol id it builds itself:
437
+ // `Identify`/`IdentifyPush` both emit `/${protocolPrefix}/id[/push]/1.0.0`, always
438
+ // prepending the leading slash (its own default is the BARE `'ipfs'`). So this
439
+ // prefix must stay slash-LESS — passing `/optimystic/...` yields the malformed
440
+ // double-slash `//optimystic/<net>/id/1.0.0`. Every other service below
441
+ // (cluster/repo/sync/blockTransfer) concatenates its own template literal and so
442
+ // takes the slash-PREFIXED `protocolPrefix` form; do not unify the two.
443
+ // Locked by `identify-protocol-id.spec.ts`.
362
444
  identify: identify({
363
- protocolPrefix: `/optimystic/${options.networkName}`
445
+ protocolPrefix: `optimystic/${options.networkName}`
446
+ }),
447
+ // identify/push propagates *later* address/protocol changes (relay reservation,
448
+ // AutoNAT-learned observed addr, a service registered post-start) to already-connected
449
+ // peers. Without it those peers keep the stale snapshot from the initial identify.
450
+ // Two consequences, both now covered by tests rather than asserted here:
451
+ // - Addresses: a relay-only peer's reservation completes AFTER its first connection to
452
+ // the relay, so the circuit address is exactly the one identify cannot have carried.
453
+ // The relay's peerStore entry stays empty and a later dial by peer id alone fails
454
+ // with NoValidAddressesError against a reachable peer — `relay-address-propagation.spec.ts`
455
+ // (its gated control reproduces that failure with push removed).
456
+ // - Protocols: `membershipOf` in `libp2p-key-network.ts` classifies a peer serves/
457
+ // foreign/unknown purely from the peerStore protocol list, so a cluster/repo handler
458
+ // registered post-start never flips an already-connected peer to `serves` —
459
+ // `identify-push-propagation.spec.ts`.
460
+ identifyPush: identifyPush({
461
+ protocolPrefix: `optimystic/${options.networkName}`
364
462
  }),
365
463
  ping: ping(),
366
464
  // DCUtR (hole-punch) upgrades relayed node↔node connections to direct
@@ -457,7 +555,9 @@ export async function createLibp2pNodeBase(
457
555
  clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5
458
556
  });
459
557
  const svc = svcFactory(components);
460
- try { (svc as any).setLibp2p?.(components.libp2p); } catch { }
558
+ // Best-effort proxy-time injection; the real node is re-injected post-construction below.
559
+ try { (svc as SetLibp2pCapable).setLibp2p(components.libp2p); }
560
+ catch (err) { wiringLog('networkManager in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err); }
461
561
  return svc;
462
562
  },
463
563
  fret: (components: any) => {
@@ -470,26 +570,39 @@ export async function createLibp2pNodeBase(
470
570
  bootstraps: options.bootstrapNodes ?? []
471
571
  });
472
572
  const svc = svcFactory(components) as Libp2pFretService;
473
- try { svc.setLibp2p(components.libp2p); } catch { }
573
+ // Best-effort proxy-time injection; the real node is re-injected post-construction below.
574
+ try { (svc as SetLibp2pCapable).setLibp2p(components.libp2p); }
575
+ catch (err) { wiringLog('fret in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err); }
474
576
  return svc;
475
577
  }
476
- },
578
+
579
+ // [dispute-subsystem-dormant] The /optimystic/<network>/dispute/1.0.0 handler
580
+ // (disputeProtocolService / DisputeProtocolService) is intentionally NOT registered here.
581
+ // The subsystem is staged dormant pending arbitrator-set anchoring — without it, a peer
582
+ // minting throwaway keypairs can forge a synthetic super-majority and pass resolution.
583
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
584
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
585
+ }) as unknown as NonNullable<Libp2pInit['services']>,
477
586
  // Add bootstrap nodes as needed
478
587
  peerDiscovery: [
479
588
  ...(options.bootstrapNodes?.length ? [bootstrap({ list: options.bootstrapNodes })] : [])
480
589
  ],
481
590
  };
482
591
 
483
- const node = await createLibp2p(libp2pOptions as any);
592
+ const node = await createLibp2p(libp2pOptions);
484
593
 
485
- // Inject libp2p reference into services that need it before start
486
- try { ((node as any).services?.fret as any)?.setLibp2p?.(node); } catch { }
487
- try { ((node as any).services?.networkManager as any)?.setLibp2p?.(node); } catch { }
594
+ // Inject the REAL libp2p node into the services that need it, before start(). These are
595
+ // load-bearing and the node has NOT started yet, so any throw fails fast and rejects node
596
+ // creation (nothing started leaks) far better than the service silently falling back to the
597
+ // unreliable `components.libp2p` proxy and surfacing later as routing/consensus failures.
598
+ const wired = node.services as unknown as WiredServices;
599
+ wired.fret.setLibp2p(node);
600
+ wired.networkManager.setLibp2p(node);
488
601
  // RepoService.checkRedirect resolves the network manager / self id / connection
489
602
  // addrs through this injected node (the components.libp2p proxy is unreliable
490
603
  // from inside a service at request time). Done before start() so the protocol
491
604
  // handler is live with a resolvable node from its first request.
492
- try { ((node as any).services?.repo as any)?.setLibp2p?.(node); } catch { }
605
+ wired.repo.setLibp2p(node);
493
606
 
494
607
  await node.start();
495
608
 
@@ -498,25 +611,42 @@ export async function createLibp2pNodeBase(
498
611
 
499
612
  // Initialize cluster coordination components
500
613
  const networkMode: NetworkMode = (options.bootstrapNodes?.length ?? 0) > 0 ? 'joining' : 'forming';
501
- const keyNetwork = new Libp2pKeyPeerNetwork(node, options.clusterSize, undefined, networkMode, options.persistence, reputation);
502
- await keyNetwork.initFromPersistedState();
614
+ // Network-namespaced protocol prefix, threaded into the key network so coordinator/
615
+ // cohort selection is scoped to peers that serve THIS network's cluster/repo protocol.
616
+ // A peer that only belongs to another network sharing the same physical nodes/
617
+ // bootstraps registers a different (network-namespaced) identify protocol, so it is
618
+ // never selected and can't drag this network's super-majority below quorum.
503
619
  const protocolPrefix = `/optimystic/${options.networkName}`;
620
+ const keyNetwork = new Libp2pKeyPeerNetwork(node, options.clusterSize, undefined, networkMode, options.persistence, reputation, protocolPrefix);
621
+ await keyNetwork.initFromPersistedState();
504
622
  const createClusterClient = (peerId: any) => ClusterClient.create(peerId, keyNetwork, protocolPrefix);
505
623
 
506
- // Inject reputation into NetworkManagerService
507
- try { ((node as any).services?.networkManager as any)?.setReputation?.(reputation); } catch { }
624
+ // Inject reputation into NetworkManagerService. Load-bearing and non-optional: the service is
625
+ // unconditionally present, so a throw is a real wiring bug. Unlike the pre-start injections above
626
+ // the node has already started here, so stop it before rethrowing rather than leaking a started
627
+ // node + open transports (mirrors the cohortTopic hard-fail blocks below).
628
+ try {
629
+ wired.networkManager.setReputation(reputation);
630
+ } catch (err) {
631
+ await node.stop();
632
+ throw err;
633
+ }
508
634
 
509
635
  // Create partition detector and get FRET service
510
636
  const partitionDetector = new PartitionDetector();
511
637
  const fretSvc = (node as any).services?.fret as FretService | undefined;
512
638
 
513
639
  const consensusConfig = {
514
- superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? 0.67,
640
+ superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
515
641
  simpleMajorityThreshold: 0.51,
516
642
  minAbsoluteClusterSize: 2,
517
643
  allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
518
644
  clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
519
- partitionDetectionWindow: 60000
645
+ partitionDetectionWindow: 60000,
646
+ // Configured full cluster size — the member's own reference for "full size" in the membership
647
+ // admission gate (a below-full-size declared set under low FRET confidence is refused as a possible
648
+ // self-shrink). Matches the size threaded into the coordinator below.
649
+ clusterSize: options.clusterSize ?? 10
520
650
  };
521
651
 
522
652
  // Fetch a block archive from one cohort peer over the sync protocol, bounded by a
@@ -552,25 +682,78 @@ export async function createLibp2pNodeBase(
552
682
  const targets = cohortPeerIds.filter(id => id !== node.peerId.toString());
553
683
  if (targets.length === 0) return;
554
684
 
555
- const archives = await Promise.all(targets.map(peerIdStr => fetchArchiveFromPeer(peerIdStr, blockId)));
685
+ const fetched = await Promise.all(
686
+ targets.map(async peerIdStr => ({ peerIdStr, archive: await fetchArchiveFromPeer(peerIdStr, blockId) }))
687
+ );
556
688
 
557
- let best: { block: IBlock; source: ActionRev } | undefined;
558
- for (const archive of archives) {
689
+ // Each cohort archive contributes one (rev, actionId) claim from its max
690
+ // revision (>= the rev we committed). Pick the target rev by quorum
691
+ // corroboration rather than raw Math.max — a lone peer inflating its rev
692
+ // cannot steer reconciliation. Keep the serving peer + block per candidate
693
+ // so we can then verify content agreement.
694
+ // NOTE: this quorum is corroboration-of-a-claim, NOT Sybil-resistant cohort
695
+ // membership — deferred to backlog `debt-read-repair-commit-cert-verification`.
696
+ const candidates: { peerIdStr: string; rev: number; actionId: string; block?: IBlock }[] = [];
697
+ for (const { peerIdStr, archive } of fetched) {
559
698
  if (!archive) continue;
560
699
  const revs = Object.keys(archive.revisions).map(Number);
561
700
  if (revs.length === 0) continue;
562
701
  const maxRev = Math.max(...revs);
563
702
  if (maxRev < committed.rev) continue;
564
703
  const data = archive.revisions[maxRev];
565
- if (!data?.block) continue;
566
- if (!best || maxRev > best.source.rev) {
567
- best = { block: data.block, source: { actionId: data.action.actionId, rev: maxRev } };
568
- }
704
+ if (!data?.action) continue;
705
+ candidates.push({ peerIdStr, rev: maxRev, actionId: data.action.actionId, block: data.block });
569
706
  }
570
707
 
571
- if (best) {
572
- await storageRepo.saveReplicatedBlock(blockId, best.block, best.source);
708
+ const revClaims: RevClaim[] = candidates.map(c => ({ peerId: c.peerIdStr, rev: c.rev, actionId: c.actionId }));
709
+ const selected = selectQuorumRev(revClaims, consensusConfig.simpleMajorityThreshold);
710
+ if (!selected) return; // no rev corroborated by a quorum → leave block, churn/rebalance retries later
711
+
712
+ // Content agreement: among archives corroborating the chosen (rev, actionId)
713
+ // and actually carrying the block, the content must be byte-identical across
714
+ // a quorum. A cohort member serving content that hashes differently is rejected.
715
+ // NOTE: selectQuorumBlock recomputes its quorum over only the block-CARRYING
716
+ // corroborators, not the full rev-responder set. If most peers corroborate the
717
+ // rev but few carry block bytes (e.g. mid-prune), the content quorum can shrink
718
+ // to 2. Harmless with honest peers; if a colluding pair ever becomes the only
719
+ // block-servers for an agreed rev, that is the Sybil regime already deferred to
720
+ // backlog `debt-read-repair-commit-cert-verification`.
721
+ const corroborating = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
722
+ const hashCandidates: BlockHashCandidate[] = await Promise.all(
723
+ corroborating.map(async c => ({ peerId: c.peerIdStr, hash: await canonicalBlockHash(c.block!), block: c.block! }))
724
+ );
725
+ const agreed = selectQuorumBlock(hashCandidates, consensusConfig.simpleMajorityThreshold);
726
+ if (!agreed) return; // no content quorum → skip persist
727
+
728
+ // Best-effort: penalize cohort members that served content contradicting the
729
+ // agreed hash for the same committed (rev, actionId). Never let this throw.
730
+ try {
731
+ for (const c of hashCandidates) {
732
+ if (c.hash !== agreed.hash) {
733
+ reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
734
+ }
735
+ }
736
+ } catch { /* reputation write must never block restoration */ }
737
+
738
+ await storageRepo.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
739
+ };
740
+
741
+ // Member-side membership derivation for the admission gate: independently re-derive this block's
742
+ // responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
743
+ // network-size confidence. A member gates a coordinator-declared peer set against this view before
744
+ // voting, so a self-shrunk minority-partition set cannot be voted into super-majority (see cluster-repo
745
+ // admitMembership). No FRET ⇒ confidence 0 ⇒ the gate fails closed for any downsize.
746
+ const deriveExpectedCluster: DeriveExpectedClusterCallback = async (blockId) => {
747
+ const peers = await keyNetwork.findCluster(new TextEncoder().encode(blockId));
748
+ let confidence = 0;
749
+ if (fretSvc) {
750
+ try {
751
+ confidence = fretSvc.getNetworkSizeEstimate().confidence;
752
+ } catch {
753
+ // Leave confidence 0 → fail closed for downsizing.
754
+ }
573
755
  }
756
+ return { peers: peers ?? {}, confidence };
574
757
  };
575
758
 
576
759
  clusterImpl = clusterMember({
@@ -586,7 +769,8 @@ export async function createLibp2pNodeBase(
586
769
  consensusConfig,
587
770
  stateStore: options.transactionStateStore,
588
771
  reconcileBlock,
589
- onCommitCertificate
772
+ onCommitCertificate,
773
+ deriveExpectedCluster
590
774
  // `recomputeArbitratorSet` (invalidation layer-2) is intentionally NOT wired here yet: a live FRET
591
775
  // recompute needs a churn-tolerance window so it does not false-reject legitimate certificates from
592
776
  // late-joiners (a liveness regression). Until that is tuned against live topology — and the
@@ -599,7 +783,7 @@ export async function createLibp2pNodeBase(
599
783
  keyNetwork,
600
784
  createClusterClient,
601
785
  {
602
- clusterSize: options.clusterSize ?? 10,
786
+ // clusterSize is now part of consensusConfig (member + coordinator share one reference).
603
787
  ...consensusConfig
604
788
  },
605
789
  fretSvc,
@@ -647,20 +831,66 @@ export async function createLibp2pNodeBase(
647
831
  clusterLatestCallback
648
832
  });
649
833
 
834
+ // Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
835
+ // coordinator (what declares a transaction committed on that super-majority) MUST run the same
836
+ // threshold, or the node would come up able to disagree with itself mid-consensus. Both are fed from
837
+ // the single `consensusConfig` above; this asserts on their RESOLVED values so any future drift throws
838
+ // HERE at construction. See `assertSuperMajorityCoupling`.
839
+ assertSuperMajorityCoupling(
840
+ clusterImpl as import('./cluster/cluster-repo.js').ClusterMember,
841
+ coordinatedRepo as import('./repo/coordinator-repo.js').CoordinatorRepo
842
+ );
843
+
650
844
  // Recover persisted transaction state before accepting new requests
651
845
  if (options.transactionStateStore) {
652
846
  await (clusterImpl as import('./cluster/cluster-repo.js').ClusterMember).recoverTransactions();
653
847
  await (coordinatedRepo as import('./repo/coordinator-repo.js').CoordinatorRepo).recoverTransactions();
654
848
  }
655
849
 
850
+ // --- Shared owned-block set for the resilience monitors ---
851
+ // SpreadOnChurnMonitor (sender) and RebalanceMonitor (responsibility tracker) both act on "the
852
+ // blocks this node physically holds". They share ONE Set so the two can never drift: a single
853
+ // owned-block feed populates it, and the rebalance responsibility-loss signal evicts from it
854
+ // (in the rebalance block below). Both monitors take this exact instance via deps.trackedBlocks.
855
+ const networkManager = (node as any).services?.networkManager as NetworkManagerService | undefined;
856
+ const ownedBlocks = new Set<string>();
857
+ // Single owned-block feed: every block this node commits OR receives as a replica fires
858
+ // storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
859
+ // node.blockChangeNotifier): the cohort-topic activation block below may replace
860
+ // blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its own
861
+ // surface regardless of that opt-in. NOTE: this feed does NOT re-emit blocks already durable
862
+ // from a previous run; those are seeded once at startup by the storage-enumeration scan wired
863
+ // below (seedOwnedBlocksFromStorage), so a restarted node protects on-disk data without waiting
864
+ // for each block to be touched again. Registered lazily the first time a
865
+ // monitor that reads ownedBlocks is wired, so when BOTH monitors are disabled no subscription
866
+ // leaks; torn down exactly once in the stop wrapper below.
867
+ let offOwnedBlockFeed: (() => void) | undefined;
868
+ const ensureOwnedBlockFeed = (): void => {
869
+ if (offOwnedBlockFeed) return;
870
+ offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
871
+ for (const blockId of e.blockIds) ownedBlocks.add(blockId);
872
+ });
873
+ };
874
+ // Single owned-block-feed teardown. Registered up front (before either monitor's own stop
875
+ // wrapper) so it runs regardless of WHICH monitor subscribed the feed - including the
876
+ // spread-disabled / rebalance-only case. Idempotent: offOwnedBlockFeed is undefined-guarded.
877
+ {
878
+ const previousStop = node.stop.bind(node);
879
+ node.stop = async () => {
880
+ try {
881
+ offOwnedBlockFeed?.();
882
+ } finally {
883
+ await previousStop();
884
+ }
885
+ };
886
+ }
887
+
656
888
  // --- Churn-resilient spread: drive SpreadOnChurnMonitor on a live node ---
657
889
  // Nothing previously activated the SENDING side of the churn-resilient spread protocol on a
658
- // real node. Here we init + start the monitor and feed it the blocks this node physically
659
- // holds, so a debounced connection:close re-pushes them to expansion-cohort peers (the
660
- // receiver durably persists each push via saveReplicatedBlock).
661
- const networkManager = (node as any).services?.networkManager as NetworkManagerService | undefined;
890
+ // real node. Here we init + start the monitor (sharing ownedBlocks) and ensure the single
891
+ // owned-block feed is live, so a debounced connection:close re-pushes the node's blocks to
892
+ // expansion-cohort peers (the receiver durably persists each push via saveReplicatedBlock).
662
893
  let spreadMonitor: SpreadOnChurnMonitor | undefined;
663
- let offOwnedBlockFeed: (() => void) | undefined;
664
894
  if (networkManager && (options.spreadOnChurn?.enabled ?? true) !== false) {
665
895
  try {
666
896
  spreadMonitor = networkManager.initSpreadOnChurnMonitor(
@@ -669,21 +899,11 @@ export async function createLibp2pNodeBase(
669
899
  keyNetwork,
670
900
  options.clusterSize ?? 10,
671
901
  protocolPrefix,
902
+ ownedBlocks,
672
903
  options.spreadOnChurn,
673
904
  );
674
905
  await spreadMonitor.start();
675
- // Feed owned blocks: every block this node commits OR receives as a replica fires
676
- // storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
677
- // node.blockChangeNotifier): the cohort-topic activation block below may replace
678
- // blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its
679
- // own surface regardless of that opt-in. NOTE: blocks already durable from a previous
680
- // run are NOT re-emitted on startup, so they are not tracked until next touched -
681
- // acceptable here (churn re-replication re-derives over time); an initial-scan is a
682
- // follow-on enhancement.
683
- const monitor = spreadMonitor;
684
- offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
685
- for (const blockId of e.blockIds) monitor.trackBlock(blockId);
686
- });
906
+ ensureOwnedBlockFeed();
687
907
  } catch (err) {
688
908
  // Spread is a resilience optimization, not a correctness requirement - a wiring
689
909
  // failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup, unlike the
@@ -695,16 +915,15 @@ export async function createLibp2pNodeBase(
695
915
  // Expose for tests/diagnostics (mirrors node.keyNetwork / node.reputation).
696
916
  (node as any).spreadOnChurnMonitor = spreadMonitor;
697
917
 
698
- // Disposal: release the owned-block subscription + stop the monitor deterministically
699
- // before the transports close. Composes with the arachnode / clusterMember / cohort-topic
700
- // stop wrappers (each calls its captured previousStop last). Both steps are idempotent (the
701
- // unsubscribe is flag-guarded; SpreadOnChurnMonitor.stop early-returns when not running), so
702
- // a double node.stop() does not throw.
918
+ // Disposal: stop the spread monitor deterministically before the transports close. Composes
919
+ // with the arachnode / clusterMember / cohort-topic stop wrappers (each calls its captured
920
+ // previousStop last). Idempotent (SpreadOnChurnMonitor.stop early-returns when not running), so
921
+ // a double node.stop() does not throw. The owned-block feed teardown is the separate up-front
922
+ // wrapper above (shared across both monitors).
703
923
  {
704
924
  const previousStop = node.stop.bind(node);
705
925
  node.stop = async () => {
706
926
  try {
707
- offOwnedBlockFeed?.();
708
927
  if (spreadMonitor) await spreadMonitor.stop();
709
928
  } finally {
710
929
  await previousStop();
@@ -719,7 +938,24 @@ export async function createLibp2pNodeBase(
719
938
  const fret = (node as any).services?.fret as any;
720
939
 
721
940
  if (fret) {
722
- const fretAdapter = new ArachnodeFretAdapter(fret);
941
+ const fretAdapter = new ArachnodeFretAdapter(fret, node.peerId.toString());
942
+
943
+ // Blocks whose shed range has been RELEASED (Phase C of a ring shift, or a confirmed
944
+ // rebalance release). This is the GC-eligibility signal the future storage sweep
945
+ // (`st-storage-sweep-archival-and-capacity-estimate`) must consult: a block's local bytes may
946
+ // be reclaimed ONLY once it appears here, so an unconfirmed / still-served range is never
947
+ // swept. Populated strictly after replication is confirmed. See
948
+ // docs/arachnode-ring-handoff.md § Part 2 (Local bytes vs. tracking).
949
+ // NOTE: no sweep consumes this set yet; it is the coordinated eligibility handoff the sweep
950
+ // ticket will read. Until then it grows unbounded — bound it when the sweep lands.
951
+ const gcEligible = new Set<string>();
952
+ (node as any).gcEligibleBlocks = gcEligible;
953
+
954
+ // The ring-shift state machine (advertise→confirm→release). Wired inside the rebalance block
955
+ // below (it needs the BlockTransferCoordinator confirmer + the cohort-size floor); left
956
+ // undefined when the rebalance reaction is not wired, in which case ring shifts stay inert —
957
+ // a move-out is unsafe without the confirm/release path.
958
+ let ringShift: RingShiftCoordinator | undefined;
723
959
 
724
960
  const storageMonitor = new StorageMonitor(rawStorage, options.arachnode?.storage ?? {});
725
961
  const ringSelector = new RingSelector(fretAdapter, storageMonitor, {
@@ -727,7 +963,12 @@ export async function createLibp2pNodeBase(
727
963
  thresholds: {
728
964
  moveOut: 0.85,
729
965
  moveIn: 0.40
730
- }
966
+ },
967
+ // Damping so the ring decision cannot thrash near a boundary
968
+ // (docs/arachnode-ring-handoff.md § Part 1).
969
+ smoothingAlpha: 0.2,
970
+ deadband: 0.5,
971
+ minDwellMs: 10 * 60 * 1000
731
972
  });
732
973
 
733
974
  // Determine and announce ring membership
@@ -754,15 +995,154 @@ export async function createLibp2pNodeBase(
754
995
  (storageRepo as any).createBlockStorage = (blockId: string) =>
755
996
  new BlockStorage(blockId, rawStorage, newRestoreCallback);
756
997
 
757
- // Monitor capacity and adjust ring periodically
998
+ // --- Rebalance reaction: drive RebalanceMonitor + react via BlockTransferCoordinator ---
999
+ // Nothing previously activated the rebalance path on a real node: initRebalanceMonitor was
1000
+ // never called, the monitor was never start()ed, and BlockTransferCoordinator (the
1001
+ // pull-gained / push-lost reaction primitive) was never constructed in src. This block lives
1002
+ // inside the arachnode `if (fret)` gate because both dependencies only exist here — the
1003
+ // fretAdapter and the RestorationCoordinator. When arachnode is disabled or FRET is absent the
1004
+ // rebalance path stays inert (acceptable: rebalance is a resilience optimization). A wiring
1005
+ // failure here is non-fatal (log + continue), unlike the operator-opted-in cohortTopic block.
1006
+ if (networkManager && (options.rebalance?.enabled ?? true) !== false) {
1007
+ try {
1008
+ // repo → the LOCAL storageRepo (not repoProxy/coordinatedRepo): a pulled/pushed replica
1009
+ // must land in / be read from this node's own storage, same reasoning as the
1010
+ // blockTransfer service handler registration. protocolPrefix (/optimystic/<networkName>)
1011
+ // MUST match the prefix the node registers its block-transfer handler under, or every
1012
+ // lost-block push dials the wrong protocol and fails to connect.
1013
+ const coordinator = new BlockTransferCoordinator(
1014
+ storageRepo,
1015
+ keyNetwork,
1016
+ restorationCoordinatorV2,
1017
+ partitionDetector,
1018
+ protocolPrefix,
1019
+ );
1020
+
1021
+ const rebalanceMonitor = networkManager.initRebalanceMonitor(
1022
+ partitionDetector,
1023
+ fretAdapter,
1024
+ ownedBlocks,
1025
+ options.rebalance,
1026
+ );
1027
+ await rebalanceMonitor.start();
1028
+
1029
+ // onRebalance fires synchronously from the monitor's debounced check; the coordinator's
1030
+ // reaction (pull gained / push lost, each partition-guarded) is async, so hop it off the
1031
+ // handler rather than blocking the monitor's emit loop. handleRebalanceEvent can REJECT
1032
+ // (e.g. RestorationCoordinator.restore() throws while pulling a gained block) and a bare
1033
+ // `void` would surface that as an unhandled rejection (process-fatal on Node >=15); the
1034
+ // reaction is a resilience optimization, so swallow + log instead.
1035
+ //
1036
+ // ALONGSIDE dispatching to the coordinator, drive the shared owned-block set off this
1037
+ // authoritative responsibility signal. A GAINED block is added immediately so it is
1038
+ // tracked even before its next commit/replica touches the feed.
1039
+ //
1040
+ // A LOST block is NO LONGER released synchronously: doing so stopped spreading a block
1041
+ // whose push to the new owners might fail, drop it below the replication floor, and let a
1042
+ // later sweep reclaim it (docs/arachnode-ring-handoff.md § Why the current code violates
1043
+ // it #2). Instead the release is GATED on confirmation — the coordinator returns the lost
1044
+ // blocks it confirmed replicated to ≥ floor new owners, and ONLY those are untracked
1045
+ // (authoritative eviction from the shared set — complements spread's lazy self-prune) and
1046
+ // marked GC-eligible. A lost block whose push failed / was partition-skipped stays
1047
+ // tracked and served, and is retried on the next rebalance.
1048
+ //
1049
+ // Best-effort iteration safety: this eviction can mutate ownedBlocks while
1050
+ // SpreadOnChurnMonitor (or this monitor) is mid for...of over the same Set inside an
1051
+ // async loop. Adding/deleting a Set entry during iteration does not throw in JS — entries
1052
+ // are visited best-effort — which is acceptable for a resilience mechanism, so we
1053
+ // document it here rather than add locking.
1054
+ rebalanceMonitor.onRebalance((event) => {
1055
+ for (const blockId of event.gained) ownedBlocks.add(blockId);
1056
+ coordinator.handleRebalanceEvent(event).then((result) => {
1057
+ for (const blockId of result.released) {
1058
+ rebalanceMonitor.untrackBlock(blockId); // also evicts from the shared ownedBlocks set
1059
+ gcEligible.add(blockId); // confirmed replicated → safe to sweep
1060
+ }
1061
+ }).catch((err) => {
1062
+ log?.('rebalance reaction failed: %o', err);
1063
+ });
1064
+ });
1065
+
1066
+ // Ring-shift handoff (advertise→confirm→release). It needs the confirmer (this
1067
+ // coordinator) and the cohort-size floor (this monitor), so it is wired here. The
1068
+ // `onRelease` callback runs Phase C's local effect: stop serving/spreading the shed
1069
+ // range and mark it GC-eligible — the same authoritative eviction the confirmed-rebalance
1070
+ // release performs.
1071
+ ringShift = new RingShiftCoordinator({
1072
+ fretAdapter,
1073
+ ringSelector,
1074
+ fret,
1075
+ partitionDetector,
1076
+ confirmer: coordinator,
1077
+ ownedBlocks,
1078
+ selfPeerId: peerId,
1079
+ getFloor: () => rebalanceMonitor.getCohortSize(),
1080
+ onRelease: (blockIds) => {
1081
+ for (const blockId of blockIds) {
1082
+ rebalanceMonitor.untrackBlock(blockId);
1083
+ gcEligible.add(blockId);
1084
+ }
1085
+ }
1086
+ });
1087
+ // Reconcile any stale `moving` advertisement left by a crash mid-handoff (no-op unless
1088
+ // arachnode metadata survived a restart still marked `moving`).
1089
+ ringShift.reconcileOnStart();
1090
+
1091
+ // Feed owned blocks via the SINGLE shared feed (idempotent — already live if the spread
1092
+ // block above wired it). Both monitors read the same ownedBlocks set this populates.
1093
+ ensureOwnedBlockFeed();
1094
+
1095
+ // Expose for tests/diagnostics (mirrors node.spreadOnChurnMonitor).
1096
+ (node as any).rebalanceMonitor = rebalanceMonitor;
1097
+ (node as any).blockTransferCoordinator = coordinator;
1098
+ (node as any).ringShiftCoordinator = ringShift;
1099
+
1100
+ // Disposal: stop the monitor before transports close. Composes with the other stop
1101
+ // wrappers (each calls its captured previousStop last). Idempotent — RebalanceMonitor.stop()
1102
+ // early-returns when not running (NetworkManagerService.stop() also stops it). The shared
1103
+ // owned-block feed teardown is the separate up-front wrapper (not duplicated here).
1104
+ const previousStop = node.stop.bind(node);
1105
+ node.stop = async () => {
1106
+ try {
1107
+ await rebalanceMonitor.stop();
1108
+ } finally {
1109
+ await previousStop();
1110
+ }
1111
+ };
1112
+ } catch (err) {
1113
+ // Rebalance is a resilience optimization, not a correctness requirement - a wiring
1114
+ // failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup.
1115
+ log?.('rebalance wiring init failed: %o', err);
1116
+ }
1117
+ }
1118
+
1119
+ // Monitor capacity and adjust ring periodically. The damped `shouldTransition()` decides
1120
+ // WHETHER/where to move (docs/arachnode-ring-handoff.md § Part 1); the RingShiftCoordinator
1121
+ // carries the move out through the advertise→confirm→release handoff (§ Part 2) so a shift
1122
+ // never drops a key below its replication floor. The old unilateral `setArachnodeInfo` flip —
1123
+ // which changed advertised responsibility instantly with no data handoff — is gone.
1124
+ //
1125
+ // Ring shifts run ONLY when `ringShift` is wired (i.e. the rebalance reaction is enabled): a
1126
+ // move-out is unsafe without the confirm/release path, so a node with the rebalance reaction
1127
+ // disabled stays at its bootstrap ring rather than flipping unsafely.
758
1128
  const monitorInterval = setInterval(async () => {
1129
+ if (!ringShift) return;
759
1130
  const transition = await ringSelector.shouldTransition();
760
- if (transition.shouldMove) {
1131
+ if (transition.shouldMove && transition.direction && transition.newRingDepth !== undefined) {
761
1132
  log?.('Ring transition needed: moving %s to Ring %d', transition.direction, transition.newRingDepth);
762
-
763
- // Update Arachnode info with new ring
764
- const updatedInfo = await ringSelector.createArachnodeInfo(peerId);
765
- fretAdapter.setArachnodeInfo(updatedInfo);
1133
+ try {
1134
+ const outcome = await ringShift.executeShift({
1135
+ direction: transition.direction,
1136
+ newRingDepth: transition.newRingDepth
1137
+ });
1138
+ log?.('Ring shift outcome: %o', outcome);
1139
+ } catch (err) {
1140
+ log?.('Ring shift failed: %o', err);
1141
+ } finally {
1142
+ // Measure the minimum dwell from the SETTLED shift (completed or rolled back), not
1143
+ // just the trigger stamped inside shouldTransition (docs/arachnode-ring-handoff.md §1.3).
1144
+ ringSelector.recordShiftSettled();
1145
+ }
766
1146
  }
767
1147
  }, 60_000);
768
1148
 
@@ -777,6 +1157,45 @@ export async function createLibp2pNodeBase(
777
1157
  }
778
1158
  }
779
1159
 
1160
+ // --- Seed the shared owned-block set from already-durable storage ---
1161
+ // Blocks durable from a previous run are otherwise untracked until next touched (see the
1162
+ // onAnyCollectionChange comment above where ownedBlocks is declared). Placed here, AFTER both
1163
+ // monitor-wiring blocks (spread ~line 862, rebalance ~line 974) have had their chance to call
1164
+ // ensureOwnedBlockFeed():
1165
+ // - Gate on offOwnedBlockFeed: only seed when a monitor actually consumes ownedBlocks; if both
1166
+ // are disabled the set is unused and the scan (plus the background task) is wasted work.
1167
+ // - Feed-before-scan ordering is load-bearing: because the feed is already live, a block
1168
+ // committed/replicated DURING the scan is caught by the feed; Set.add is idempotent so the
1169
+ // overlap is harmless. Scanning before subscribing would drop a block committed in the gap.
1170
+ // - Fire-and-forget so a large store never blocks startup; the .catch keeps a scan rejection
1171
+ // from becoming an unhandled rejection.
1172
+ // - Cancellable: a stop wrapper flips seedStopping so the scan loop breaks against a
1173
+ // stopping/closing backend rather than running the enumeration to completion.
1174
+ // NOTE: a concurrent rebalance release can untrackBlock (delete from ownedBlocks) a confirmed-
1175
+ // released block while this scan is still running, and the scan could then re-add that id. Benign
1176
+ // transient: the block is still in the metadata store (no sweep reclaims metadata yet), so a
1177
+ // re-added released block is simply re-evaluated and re-released on the next rebalance tick. Right
1178
+ // after a restart, responsibility-loss detection lags this fast metadata scan, so the window is
1179
+ // small. Accepted rather than synchronized.
1180
+ if (offOwnedBlockFeed && typeof rawStorage.listBlockIds === 'function') {
1181
+ let seedStopping = false;
1182
+ const previousStop = node.stop.bind(node);
1183
+ node.stop = async () => {
1184
+ seedStopping = true;
1185
+ await previousStop();
1186
+ };
1187
+ void seedOwnedBlocksFromStorage(rawStorage, ownedBlocks, () => seedStopping)
1188
+ .catch((err) => ((node as any).logger?.forComponent?.('db-p2p:owned-block-seed'))?.('seed failed: %o', err));
1189
+ }
1190
+
1191
+ // [dispute-subsystem-dormant] The DisputeService object is constructed below so tests and
1192
+ // getDisputeStatus() work, but it is unreachable from the live network path:
1193
+ // - No inbound handler: disputeProtocolService is NOT in the services map above.
1194
+ // - onInvalidation is deliberately unset: maybeInvalidate() is a no-op on live nodes.
1195
+ // - revalidate is deliberately unset: handleChallenge always votes inconclusive on live nodes.
1196
+ // Full activation requires arbitrator-set anchoring before a forged synthetic cohort can pass resolution.
1197
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
1198
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
780
1199
  // Initialize dispute service if enabled
781
1200
  let disputeServiceInstance: DisputeService | undefined;
782
1201
  if (options.dispute?.disputeEnabled) {
@@ -789,23 +1208,28 @@ export async function createLibp2pNodeBase(
789
1208
  reputation,
790
1209
  validator: options.validator,
791
1210
  config: options.dispute,
792
- selectArbitrators: async (blockId: string, excludePeers: string[], count: number) => {
1211
+ selectArbitrators: async (blockId: string, excludePeers: string[], count: number, round: number, epoch: Uint8Array) => {
793
1212
  const { hashKey: fretHashKey } = await import('p2p-fret');
794
- const blockIdBytes = new TextEncoder().encode(blockId);
795
1213
  const fret = (node as any).services?.fret as FretService | undefined;
796
1214
  if (!fret) return [];
797
- // Get a larger cohort and exclude the original cluster peers
798
- const cohortSize = count + excludePeers.length + 1;
799
- const hashedCoord = await fretHashKey(blockIdBytes);
800
- const allPeerIdStrs = fret.assembleCohort(hashedCoord, cohortSize) as string[];
801
- // Filter out original cluster peers and self, convert to PeerId
1215
+ // Dispersed sampling: draw `count` peers from coordinates spread across the whole keyspace
1216
+ // (hash(blockId round epoch i)) rather than the block's XOR neighborhood, so an attacker
1217
+ // who owns the block's locale does not thereby own the arbitrators. `assembleCohort` already
1218
+ // filters to known members; excluding the original cluster + self keeps arbitrators independent.
802
1219
  const excludeSet = new Set(excludePeers);
1220
+ // NOTE: adding the local node's own id to `exclude` makes the draw node-relative. Cross-node
1221
+ // determinism (the verifiable-recompute property) holds today only because the dissent
1222
+ // coordinator running this is itself a member of the original cluster, so `self` is already in
1223
+ // `excludePeers` — the add is a no-op and every honest node excludes the identical set. When a
1224
+ // verify-path recompute lands, it MUST reconstruct `exclude` from the challenger's identity
1225
+ // (`proof.challengerPeerId`) + original cluster, never the verifier's own id, or re-derivation diverges.
803
1226
  excludeSet.add(node.peerId.toString());
804
- const arbitratorPeerIds = allPeerIdStrs
805
- .filter(pid => !excludeSet.has(pid))
806
- .slice(0, count)
807
- .map(pid => peerIdFromString(pid));
808
- return arbitratorPeerIds;
1227
+ const picks = await sampleArbitrators(
1228
+ { blockId: new TextEncoder().encode(blockId), round, epoch, count, exclude: excludeSet },
1229
+ (coord, wants) => fret.assembleCohort(coord, wants) as string[],
1230
+ fretHashKey,
1231
+ );
1232
+ return picks.map(pid => peerIdFromString(pid));
809
1233
  },
810
1234
  });
811
1235
  }
@@ -830,6 +1254,12 @@ export async function createLibp2pNodeBase(
830
1254
  (node as any).keyNetwork = keyNetwork;
831
1255
  (node as any).reputation = reputation;
832
1256
  (node as any).disputeService = disputeServiceInstance;
1257
+ // The node's libp2p Ed25519 identity key. Exposed on the same `(node as any).*` surface as
1258
+ // coordinatedRepo/keyNetwork so a host can bind a client-transaction signer to it (the Quereus
1259
+ // collection-factory's getSigner reuses this via signPeer). libp2p does not surface the private
1260
+ // key on its public `Libp2p` interface, so this attachment is the sanctioned in-process handle.
1261
+ // Ed25519 by construction (options.privateKey defaults to generateKeyPair('Ed25519')).
1262
+ (node as any).peerPrivateKey = nodePrivateKey;
833
1263
 
834
1264
  // --- Cohort-topic origination activation (post-node: consumes the fully-assembled node + FRET) ---
835
1265
  // This is the only place that is after the node + FRET are assembled (node.start() done, fretSvc