@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
@@ -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,24 +405,34 @@ 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: ({
362
436
  identify: identify({
363
437
  protocolPrefix: `/optimystic/${options.networkName}`
364
438
  }),
@@ -457,7 +531,9 @@ export async function createLibp2pNodeBase(
457
531
  clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5
458
532
  });
459
533
  const svc = svcFactory(components);
460
- try { (svc as any).setLibp2p?.(components.libp2p); } catch { }
534
+ // Best-effort proxy-time injection; the real node is re-injected post-construction below.
535
+ try { (svc as SetLibp2pCapable).setLibp2p(components.libp2p); }
536
+ catch (err) { wiringLog('networkManager in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err); }
461
537
  return svc;
462
538
  },
463
539
  fret: (components: any) => {
@@ -470,26 +546,39 @@ export async function createLibp2pNodeBase(
470
546
  bootstraps: options.bootstrapNodes ?? []
471
547
  });
472
548
  const svc = svcFactory(components) as Libp2pFretService;
473
- try { svc.setLibp2p(components.libp2p); } catch { }
549
+ // Best-effort proxy-time injection; the real node is re-injected post-construction below.
550
+ try { (svc as SetLibp2pCapable).setLibp2p(components.libp2p); }
551
+ catch (err) { wiringLog('fret in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err); }
474
552
  return svc;
475
553
  }
476
- },
554
+
555
+ // [dispute-subsystem-dormant] The /optimystic/<network>/dispute/1.0.0 handler
556
+ // (disputeProtocolService / DisputeProtocolService) is intentionally NOT registered here.
557
+ // The subsystem is staged dormant pending arbitrator-set anchoring — without it, a peer
558
+ // minting throwaway keypairs can forge a synthetic super-majority and pass resolution.
559
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
560
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
561
+ }) as unknown as NonNullable<Libp2pInit['services']>,
477
562
  // Add bootstrap nodes as needed
478
563
  peerDiscovery: [
479
564
  ...(options.bootstrapNodes?.length ? [bootstrap({ list: options.bootstrapNodes })] : [])
480
565
  ],
481
566
  };
482
567
 
483
- const node = await createLibp2p(libp2pOptions as any);
568
+ const node = await createLibp2p(libp2pOptions);
484
569
 
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 { }
570
+ // Inject the REAL libp2p node into the services that need it, before start(). These are
571
+ // load-bearing and the node has NOT started yet, so any throw fails fast and rejects node
572
+ // creation (nothing started leaks) far better than the service silently falling back to the
573
+ // unreliable `components.libp2p` proxy and surfacing later as routing/consensus failures.
574
+ const wired = node.services as unknown as WiredServices;
575
+ wired.fret.setLibp2p(node);
576
+ wired.networkManager.setLibp2p(node);
488
577
  // RepoService.checkRedirect resolves the network manager / self id / connection
489
578
  // addrs through this injected node (the components.libp2p proxy is unreliable
490
579
  // from inside a service at request time). Done before start() so the protocol
491
580
  // handler is live with a resolvable node from its first request.
492
- try { ((node as any).services?.repo as any)?.setLibp2p?.(node); } catch { }
581
+ wired.repo.setLibp2p(node);
493
582
 
494
583
  await node.start();
495
584
 
@@ -498,25 +587,42 @@ export async function createLibp2pNodeBase(
498
587
 
499
588
  // Initialize cluster coordination components
500
589
  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();
590
+ // Network-namespaced protocol prefix, threaded into the key network so coordinator/
591
+ // cohort selection is scoped to peers that serve THIS network's cluster/repo protocol.
592
+ // A peer that only belongs to another network sharing the same physical nodes/
593
+ // bootstraps registers a different (network-namespaced) identify protocol, so it is
594
+ // never selected and can't drag this network's super-majority below quorum.
503
595
  const protocolPrefix = `/optimystic/${options.networkName}`;
596
+ const keyNetwork = new Libp2pKeyPeerNetwork(node, options.clusterSize, undefined, networkMode, options.persistence, reputation, protocolPrefix);
597
+ await keyNetwork.initFromPersistedState();
504
598
  const createClusterClient = (peerId: any) => ClusterClient.create(peerId, keyNetwork, protocolPrefix);
505
599
 
506
- // Inject reputation into NetworkManagerService
507
- try { ((node as any).services?.networkManager as any)?.setReputation?.(reputation); } catch { }
600
+ // Inject reputation into NetworkManagerService. Load-bearing and non-optional: the service is
601
+ // unconditionally present, so a throw is a real wiring bug. Unlike the pre-start injections above
602
+ // the node has already started here, so stop it before rethrowing rather than leaking a started
603
+ // node + open transports (mirrors the cohortTopic hard-fail blocks below).
604
+ try {
605
+ wired.networkManager.setReputation(reputation);
606
+ } catch (err) {
607
+ await node.stop();
608
+ throw err;
609
+ }
508
610
 
509
611
  // Create partition detector and get FRET service
510
612
  const partitionDetector = new PartitionDetector();
511
613
  const fretSvc = (node as any).services?.fret as FretService | undefined;
512
614
 
513
615
  const consensusConfig = {
514
- superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? 0.67,
616
+ superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
515
617
  simpleMajorityThreshold: 0.51,
516
618
  minAbsoluteClusterSize: 2,
517
619
  allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
518
620
  clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
519
- partitionDetectionWindow: 60000
621
+ partitionDetectionWindow: 60000,
622
+ // Configured full cluster size — the member's own reference for "full size" in the membership
623
+ // admission gate (a below-full-size declared set under low FRET confidence is refused as a possible
624
+ // self-shrink). Matches the size threaded into the coordinator below.
625
+ clusterSize: options.clusterSize ?? 10
520
626
  };
521
627
 
522
628
  // Fetch a block archive from one cohort peer over the sync protocol, bounded by a
@@ -552,25 +658,78 @@ export async function createLibp2pNodeBase(
552
658
  const targets = cohortPeerIds.filter(id => id !== node.peerId.toString());
553
659
  if (targets.length === 0) return;
554
660
 
555
- const archives = await Promise.all(targets.map(peerIdStr => fetchArchiveFromPeer(peerIdStr, blockId)));
661
+ const fetched = await Promise.all(
662
+ targets.map(async peerIdStr => ({ peerIdStr, archive: await fetchArchiveFromPeer(peerIdStr, blockId) }))
663
+ );
556
664
 
557
- let best: { block: IBlock; source: ActionRev } | undefined;
558
- for (const archive of archives) {
665
+ // Each cohort archive contributes one (rev, actionId) claim from its max
666
+ // revision (>= the rev we committed). Pick the target rev by quorum
667
+ // corroboration rather than raw Math.max — a lone peer inflating its rev
668
+ // cannot steer reconciliation. Keep the serving peer + block per candidate
669
+ // so we can then verify content agreement.
670
+ // NOTE: this quorum is corroboration-of-a-claim, NOT Sybil-resistant cohort
671
+ // membership — deferred to backlog `debt-read-repair-commit-cert-verification`.
672
+ const candidates: { peerIdStr: string; rev: number; actionId: string; block?: IBlock }[] = [];
673
+ for (const { peerIdStr, archive } of fetched) {
559
674
  if (!archive) continue;
560
675
  const revs = Object.keys(archive.revisions).map(Number);
561
676
  if (revs.length === 0) continue;
562
677
  const maxRev = Math.max(...revs);
563
678
  if (maxRev < committed.rev) continue;
564
679
  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
- }
680
+ if (!data?.action) continue;
681
+ candidates.push({ peerIdStr, rev: maxRev, actionId: data.action.actionId, block: data.block });
569
682
  }
570
683
 
571
- if (best) {
572
- await storageRepo.saveReplicatedBlock(blockId, best.block, best.source);
684
+ const revClaims: RevClaim[] = candidates.map(c => ({ peerId: c.peerIdStr, rev: c.rev, actionId: c.actionId }));
685
+ const selected = selectQuorumRev(revClaims, consensusConfig.simpleMajorityThreshold);
686
+ if (!selected) return; // no rev corroborated by a quorum → leave block, churn/rebalance retries later
687
+
688
+ // Content agreement: among archives corroborating the chosen (rev, actionId)
689
+ // and actually carrying the block, the content must be byte-identical across
690
+ // a quorum. A cohort member serving content that hashes differently is rejected.
691
+ // NOTE: selectQuorumBlock recomputes its quorum over only the block-CARRYING
692
+ // corroborators, not the full rev-responder set. If most peers corroborate the
693
+ // rev but few carry block bytes (e.g. mid-prune), the content quorum can shrink
694
+ // to 2. Harmless with honest peers; if a colluding pair ever becomes the only
695
+ // block-servers for an agreed rev, that is the Sybil regime already deferred to
696
+ // backlog `debt-read-repair-commit-cert-verification`.
697
+ const corroborating = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
698
+ const hashCandidates: BlockHashCandidate[] = await Promise.all(
699
+ corroborating.map(async c => ({ peerId: c.peerIdStr, hash: await canonicalBlockHash(c.block!), block: c.block! }))
700
+ );
701
+ const agreed = selectQuorumBlock(hashCandidates, consensusConfig.simpleMajorityThreshold);
702
+ if (!agreed) return; // no content quorum → skip persist
703
+
704
+ // Best-effort: penalize cohort members that served content contradicting the
705
+ // agreed hash for the same committed (rev, actionId). Never let this throw.
706
+ try {
707
+ for (const c of hashCandidates) {
708
+ if (c.hash !== agreed.hash) {
709
+ reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
710
+ }
711
+ }
712
+ } catch { /* reputation write must never block restoration */ }
713
+
714
+ await storageRepo.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
715
+ };
716
+
717
+ // Member-side membership derivation for the admission gate: independently re-derive this block's
718
+ // responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
719
+ // network-size confidence. A member gates a coordinator-declared peer set against this view before
720
+ // voting, so a self-shrunk minority-partition set cannot be voted into super-majority (see cluster-repo
721
+ // admitMembership). No FRET ⇒ confidence 0 ⇒ the gate fails closed for any downsize.
722
+ const deriveExpectedCluster: DeriveExpectedClusterCallback = async (blockId) => {
723
+ const peers = await keyNetwork.findCluster(new TextEncoder().encode(blockId));
724
+ let confidence = 0;
725
+ if (fretSvc) {
726
+ try {
727
+ confidence = fretSvc.getNetworkSizeEstimate().confidence;
728
+ } catch {
729
+ // Leave confidence 0 → fail closed for downsizing.
730
+ }
573
731
  }
732
+ return { peers: peers ?? {}, confidence };
574
733
  };
575
734
 
576
735
  clusterImpl = clusterMember({
@@ -586,7 +745,8 @@ export async function createLibp2pNodeBase(
586
745
  consensusConfig,
587
746
  stateStore: options.transactionStateStore,
588
747
  reconcileBlock,
589
- onCommitCertificate
748
+ onCommitCertificate,
749
+ deriveExpectedCluster
590
750
  // `recomputeArbitratorSet` (invalidation layer-2) is intentionally NOT wired here yet: a live FRET
591
751
  // recompute needs a churn-tolerance window so it does not false-reject legitimate certificates from
592
752
  // late-joiners (a liveness regression). Until that is tuned against live topology — and the
@@ -599,7 +759,7 @@ export async function createLibp2pNodeBase(
599
759
  keyNetwork,
600
760
  createClusterClient,
601
761
  {
602
- clusterSize: options.clusterSize ?? 10,
762
+ // clusterSize is now part of consensusConfig (member + coordinator share one reference).
603
763
  ...consensusConfig
604
764
  },
605
765
  fretSvc,
@@ -647,20 +807,66 @@ export async function createLibp2pNodeBase(
647
807
  clusterLatestCallback
648
808
  });
649
809
 
810
+ // Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
811
+ // coordinator (what declares a transaction committed on that super-majority) MUST run the same
812
+ // threshold, or the node would come up able to disagree with itself mid-consensus. Both are fed from
813
+ // the single `consensusConfig` above; this asserts on their RESOLVED values so any future drift throws
814
+ // HERE at construction. See `assertSuperMajorityCoupling`.
815
+ assertSuperMajorityCoupling(
816
+ clusterImpl as import('./cluster/cluster-repo.js').ClusterMember,
817
+ coordinatedRepo as import('./repo/coordinator-repo.js').CoordinatorRepo
818
+ );
819
+
650
820
  // Recover persisted transaction state before accepting new requests
651
821
  if (options.transactionStateStore) {
652
822
  await (clusterImpl as import('./cluster/cluster-repo.js').ClusterMember).recoverTransactions();
653
823
  await (coordinatedRepo as import('./repo/coordinator-repo.js').CoordinatorRepo).recoverTransactions();
654
824
  }
655
825
 
826
+ // --- Shared owned-block set for the resilience monitors ---
827
+ // SpreadOnChurnMonitor (sender) and RebalanceMonitor (responsibility tracker) both act on "the
828
+ // blocks this node physically holds". They share ONE Set so the two can never drift: a single
829
+ // owned-block feed populates it, and the rebalance responsibility-loss signal evicts from it
830
+ // (in the rebalance block below). Both monitors take this exact instance via deps.trackedBlocks.
831
+ const networkManager = (node as any).services?.networkManager as NetworkManagerService | undefined;
832
+ const ownedBlocks = new Set<string>();
833
+ // Single owned-block feed: every block this node commits OR receives as a replica fires
834
+ // storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
835
+ // node.blockChangeNotifier): the cohort-topic activation block below may replace
836
+ // blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its own
837
+ // surface regardless of that opt-in. NOTE: this feed does NOT re-emit blocks already durable
838
+ // from a previous run; those are seeded once at startup by the storage-enumeration scan wired
839
+ // below (seedOwnedBlocksFromStorage), so a restarted node protects on-disk data without waiting
840
+ // for each block to be touched again. Registered lazily the first time a
841
+ // monitor that reads ownedBlocks is wired, so when BOTH monitors are disabled no subscription
842
+ // leaks; torn down exactly once in the stop wrapper below.
843
+ let offOwnedBlockFeed: (() => void) | undefined;
844
+ const ensureOwnedBlockFeed = (): void => {
845
+ if (offOwnedBlockFeed) return;
846
+ offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
847
+ for (const blockId of e.blockIds) ownedBlocks.add(blockId);
848
+ });
849
+ };
850
+ // Single owned-block-feed teardown. Registered up front (before either monitor's own stop
851
+ // wrapper) so it runs regardless of WHICH monitor subscribed the feed - including the
852
+ // spread-disabled / rebalance-only case. Idempotent: offOwnedBlockFeed is undefined-guarded.
853
+ {
854
+ const previousStop = node.stop.bind(node);
855
+ node.stop = async () => {
856
+ try {
857
+ offOwnedBlockFeed?.();
858
+ } finally {
859
+ await previousStop();
860
+ }
861
+ };
862
+ }
863
+
656
864
  // --- Churn-resilient spread: drive SpreadOnChurnMonitor on a live node ---
657
865
  // 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;
866
+ // real node. Here we init + start the monitor (sharing ownedBlocks) and ensure the single
867
+ // owned-block feed is live, so a debounced connection:close re-pushes the node's blocks to
868
+ // expansion-cohort peers (the receiver durably persists each push via saveReplicatedBlock).
662
869
  let spreadMonitor: SpreadOnChurnMonitor | undefined;
663
- let offOwnedBlockFeed: (() => void) | undefined;
664
870
  if (networkManager && (options.spreadOnChurn?.enabled ?? true) !== false) {
665
871
  try {
666
872
  spreadMonitor = networkManager.initSpreadOnChurnMonitor(
@@ -669,21 +875,11 @@ export async function createLibp2pNodeBase(
669
875
  keyNetwork,
670
876
  options.clusterSize ?? 10,
671
877
  protocolPrefix,
878
+ ownedBlocks,
672
879
  options.spreadOnChurn,
673
880
  );
674
881
  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
- });
882
+ ensureOwnedBlockFeed();
687
883
  } catch (err) {
688
884
  // Spread is a resilience optimization, not a correctness requirement - a wiring
689
885
  // failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup, unlike the
@@ -695,16 +891,15 @@ export async function createLibp2pNodeBase(
695
891
  // Expose for tests/diagnostics (mirrors node.keyNetwork / node.reputation).
696
892
  (node as any).spreadOnChurnMonitor = spreadMonitor;
697
893
 
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.
894
+ // Disposal: stop the spread monitor deterministically before the transports close. Composes
895
+ // with the arachnode / clusterMember / cohort-topic stop wrappers (each calls its captured
896
+ // previousStop last). Idempotent (SpreadOnChurnMonitor.stop early-returns when not running), so
897
+ // a double node.stop() does not throw. The owned-block feed teardown is the separate up-front
898
+ // wrapper above (shared across both monitors).
703
899
  {
704
900
  const previousStop = node.stop.bind(node);
705
901
  node.stop = async () => {
706
902
  try {
707
- offOwnedBlockFeed?.();
708
903
  if (spreadMonitor) await spreadMonitor.stop();
709
904
  } finally {
710
905
  await previousStop();
@@ -719,7 +914,24 @@ export async function createLibp2pNodeBase(
719
914
  const fret = (node as any).services?.fret as any;
720
915
 
721
916
  if (fret) {
722
- const fretAdapter = new ArachnodeFretAdapter(fret);
917
+ const fretAdapter = new ArachnodeFretAdapter(fret, node.peerId.toString());
918
+
919
+ // Blocks whose shed range has been RELEASED (Phase C of a ring shift, or a confirmed
920
+ // rebalance release). This is the GC-eligibility signal the future storage sweep
921
+ // (`st-storage-sweep-archival-and-capacity-estimate`) must consult: a block's local bytes may
922
+ // be reclaimed ONLY once it appears here, so an unconfirmed / still-served range is never
923
+ // swept. Populated strictly after replication is confirmed. See
924
+ // docs/arachnode-ring-handoff.md § Part 2 (Local bytes vs. tracking).
925
+ // NOTE: no sweep consumes this set yet; it is the coordinated eligibility handoff the sweep
926
+ // ticket will read. Until then it grows unbounded — bound it when the sweep lands.
927
+ const gcEligible = new Set<string>();
928
+ (node as any).gcEligibleBlocks = gcEligible;
929
+
930
+ // The ring-shift state machine (advertise→confirm→release). Wired inside the rebalance block
931
+ // below (it needs the BlockTransferCoordinator confirmer + the cohort-size floor); left
932
+ // undefined when the rebalance reaction is not wired, in which case ring shifts stay inert —
933
+ // a move-out is unsafe without the confirm/release path.
934
+ let ringShift: RingShiftCoordinator | undefined;
723
935
 
724
936
  const storageMonitor = new StorageMonitor(rawStorage, options.arachnode?.storage ?? {});
725
937
  const ringSelector = new RingSelector(fretAdapter, storageMonitor, {
@@ -727,7 +939,12 @@ export async function createLibp2pNodeBase(
727
939
  thresholds: {
728
940
  moveOut: 0.85,
729
941
  moveIn: 0.40
730
- }
942
+ },
943
+ // Damping so the ring decision cannot thrash near a boundary
944
+ // (docs/arachnode-ring-handoff.md § Part 1).
945
+ smoothingAlpha: 0.2,
946
+ deadband: 0.5,
947
+ minDwellMs: 10 * 60 * 1000
731
948
  });
732
949
 
733
950
  // Determine and announce ring membership
@@ -754,15 +971,154 @@ export async function createLibp2pNodeBase(
754
971
  (storageRepo as any).createBlockStorage = (blockId: string) =>
755
972
  new BlockStorage(blockId, rawStorage, newRestoreCallback);
756
973
 
757
- // Monitor capacity and adjust ring periodically
974
+ // --- Rebalance reaction: drive RebalanceMonitor + react via BlockTransferCoordinator ---
975
+ // Nothing previously activated the rebalance path on a real node: initRebalanceMonitor was
976
+ // never called, the monitor was never start()ed, and BlockTransferCoordinator (the
977
+ // pull-gained / push-lost reaction primitive) was never constructed in src. This block lives
978
+ // inside the arachnode `if (fret)` gate because both dependencies only exist here — the
979
+ // fretAdapter and the RestorationCoordinator. When arachnode is disabled or FRET is absent the
980
+ // rebalance path stays inert (acceptable: rebalance is a resilience optimization). A wiring
981
+ // failure here is non-fatal (log + continue), unlike the operator-opted-in cohortTopic block.
982
+ if (networkManager && (options.rebalance?.enabled ?? true) !== false) {
983
+ try {
984
+ // repo → the LOCAL storageRepo (not repoProxy/coordinatedRepo): a pulled/pushed replica
985
+ // must land in / be read from this node's own storage, same reasoning as the
986
+ // blockTransfer service handler registration. protocolPrefix (/optimystic/<networkName>)
987
+ // MUST match the prefix the node registers its block-transfer handler under, or every
988
+ // lost-block push dials the wrong protocol and fails to connect.
989
+ const coordinator = new BlockTransferCoordinator(
990
+ storageRepo,
991
+ keyNetwork,
992
+ restorationCoordinatorV2,
993
+ partitionDetector,
994
+ protocolPrefix,
995
+ );
996
+
997
+ const rebalanceMonitor = networkManager.initRebalanceMonitor(
998
+ partitionDetector,
999
+ fretAdapter,
1000
+ ownedBlocks,
1001
+ options.rebalance,
1002
+ );
1003
+ await rebalanceMonitor.start();
1004
+
1005
+ // onRebalance fires synchronously from the monitor's debounced check; the coordinator's
1006
+ // reaction (pull gained / push lost, each partition-guarded) is async, so hop it off the
1007
+ // handler rather than blocking the monitor's emit loop. handleRebalanceEvent can REJECT
1008
+ // (e.g. RestorationCoordinator.restore() throws while pulling a gained block) and a bare
1009
+ // `void` would surface that as an unhandled rejection (process-fatal on Node >=15); the
1010
+ // reaction is a resilience optimization, so swallow + log instead.
1011
+ //
1012
+ // ALONGSIDE dispatching to the coordinator, drive the shared owned-block set off this
1013
+ // authoritative responsibility signal. A GAINED block is added immediately so it is
1014
+ // tracked even before its next commit/replica touches the feed.
1015
+ //
1016
+ // A LOST block is NO LONGER released synchronously: doing so stopped spreading a block
1017
+ // whose push to the new owners might fail, drop it below the replication floor, and let a
1018
+ // later sweep reclaim it (docs/arachnode-ring-handoff.md § Why the current code violates
1019
+ // it #2). Instead the release is GATED on confirmation — the coordinator returns the lost
1020
+ // blocks it confirmed replicated to ≥ floor new owners, and ONLY those are untracked
1021
+ // (authoritative eviction from the shared set — complements spread's lazy self-prune) and
1022
+ // marked GC-eligible. A lost block whose push failed / was partition-skipped stays
1023
+ // tracked and served, and is retried on the next rebalance.
1024
+ //
1025
+ // Best-effort iteration safety: this eviction can mutate ownedBlocks while
1026
+ // SpreadOnChurnMonitor (or this monitor) is mid for...of over the same Set inside an
1027
+ // async loop. Adding/deleting a Set entry during iteration does not throw in JS — entries
1028
+ // are visited best-effort — which is acceptable for a resilience mechanism, so we
1029
+ // document it here rather than add locking.
1030
+ rebalanceMonitor.onRebalance((event) => {
1031
+ for (const blockId of event.gained) ownedBlocks.add(blockId);
1032
+ coordinator.handleRebalanceEvent(event).then((result) => {
1033
+ for (const blockId of result.released) {
1034
+ rebalanceMonitor.untrackBlock(blockId); // also evicts from the shared ownedBlocks set
1035
+ gcEligible.add(blockId); // confirmed replicated → safe to sweep
1036
+ }
1037
+ }).catch((err) => {
1038
+ log?.('rebalance reaction failed: %o', err);
1039
+ });
1040
+ });
1041
+
1042
+ // Ring-shift handoff (advertise→confirm→release). It needs the confirmer (this
1043
+ // coordinator) and the cohort-size floor (this monitor), so it is wired here. The
1044
+ // `onRelease` callback runs Phase C's local effect: stop serving/spreading the shed
1045
+ // range and mark it GC-eligible — the same authoritative eviction the confirmed-rebalance
1046
+ // release performs.
1047
+ ringShift = new RingShiftCoordinator({
1048
+ fretAdapter,
1049
+ ringSelector,
1050
+ fret,
1051
+ partitionDetector,
1052
+ confirmer: coordinator,
1053
+ ownedBlocks,
1054
+ selfPeerId: peerId,
1055
+ getFloor: () => rebalanceMonitor.getCohortSize(),
1056
+ onRelease: (blockIds) => {
1057
+ for (const blockId of blockIds) {
1058
+ rebalanceMonitor.untrackBlock(blockId);
1059
+ gcEligible.add(blockId);
1060
+ }
1061
+ }
1062
+ });
1063
+ // Reconcile any stale `moving` advertisement left by a crash mid-handoff (no-op unless
1064
+ // arachnode metadata survived a restart still marked `moving`).
1065
+ ringShift.reconcileOnStart();
1066
+
1067
+ // Feed owned blocks via the SINGLE shared feed (idempotent — already live if the spread
1068
+ // block above wired it). Both monitors read the same ownedBlocks set this populates.
1069
+ ensureOwnedBlockFeed();
1070
+
1071
+ // Expose for tests/diagnostics (mirrors node.spreadOnChurnMonitor).
1072
+ (node as any).rebalanceMonitor = rebalanceMonitor;
1073
+ (node as any).blockTransferCoordinator = coordinator;
1074
+ (node as any).ringShiftCoordinator = ringShift;
1075
+
1076
+ // Disposal: stop the monitor before transports close. Composes with the other stop
1077
+ // wrappers (each calls its captured previousStop last). Idempotent — RebalanceMonitor.stop()
1078
+ // early-returns when not running (NetworkManagerService.stop() also stops it). The shared
1079
+ // owned-block feed teardown is the separate up-front wrapper (not duplicated here).
1080
+ const previousStop = node.stop.bind(node);
1081
+ node.stop = async () => {
1082
+ try {
1083
+ await rebalanceMonitor.stop();
1084
+ } finally {
1085
+ await previousStop();
1086
+ }
1087
+ };
1088
+ } catch (err) {
1089
+ // Rebalance is a resilience optimization, not a correctness requirement - a wiring
1090
+ // failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup.
1091
+ log?.('rebalance wiring init failed: %o', err);
1092
+ }
1093
+ }
1094
+
1095
+ // Monitor capacity and adjust ring periodically. The damped `shouldTransition()` decides
1096
+ // WHETHER/where to move (docs/arachnode-ring-handoff.md § Part 1); the RingShiftCoordinator
1097
+ // carries the move out through the advertise→confirm→release handoff (§ Part 2) so a shift
1098
+ // never drops a key below its replication floor. The old unilateral `setArachnodeInfo` flip —
1099
+ // which changed advertised responsibility instantly with no data handoff — is gone.
1100
+ //
1101
+ // Ring shifts run ONLY when `ringShift` is wired (i.e. the rebalance reaction is enabled): a
1102
+ // move-out is unsafe without the confirm/release path, so a node with the rebalance reaction
1103
+ // disabled stays at its bootstrap ring rather than flipping unsafely.
758
1104
  const monitorInterval = setInterval(async () => {
1105
+ if (!ringShift) return;
759
1106
  const transition = await ringSelector.shouldTransition();
760
- if (transition.shouldMove) {
1107
+ if (transition.shouldMove && transition.direction && transition.newRingDepth !== undefined) {
761
1108
  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);
1109
+ try {
1110
+ const outcome = await ringShift.executeShift({
1111
+ direction: transition.direction,
1112
+ newRingDepth: transition.newRingDepth
1113
+ });
1114
+ log?.('Ring shift outcome: %o', outcome);
1115
+ } catch (err) {
1116
+ log?.('Ring shift failed: %o', err);
1117
+ } finally {
1118
+ // Measure the minimum dwell from the SETTLED shift (completed or rolled back), not
1119
+ // just the trigger stamped inside shouldTransition (docs/arachnode-ring-handoff.md §1.3).
1120
+ ringSelector.recordShiftSettled();
1121
+ }
766
1122
  }
767
1123
  }, 60_000);
768
1124
 
@@ -777,6 +1133,45 @@ export async function createLibp2pNodeBase(
777
1133
  }
778
1134
  }
779
1135
 
1136
+ // --- Seed the shared owned-block set from already-durable storage ---
1137
+ // Blocks durable from a previous run are otherwise untracked until next touched (see the
1138
+ // onAnyCollectionChange comment above where ownedBlocks is declared). Placed here, AFTER both
1139
+ // monitor-wiring blocks (spread ~line 862, rebalance ~line 974) have had their chance to call
1140
+ // ensureOwnedBlockFeed():
1141
+ // - Gate on offOwnedBlockFeed: only seed when a monitor actually consumes ownedBlocks; if both
1142
+ // are disabled the set is unused and the scan (plus the background task) is wasted work.
1143
+ // - Feed-before-scan ordering is load-bearing: because the feed is already live, a block
1144
+ // committed/replicated DURING the scan is caught by the feed; Set.add is idempotent so the
1145
+ // overlap is harmless. Scanning before subscribing would drop a block committed in the gap.
1146
+ // - Fire-and-forget so a large store never blocks startup; the .catch keeps a scan rejection
1147
+ // from becoming an unhandled rejection.
1148
+ // - Cancellable: a stop wrapper flips seedStopping so the scan loop breaks against a
1149
+ // stopping/closing backend rather than running the enumeration to completion.
1150
+ // NOTE: a concurrent rebalance release can untrackBlock (delete from ownedBlocks) a confirmed-
1151
+ // released block while this scan is still running, and the scan could then re-add that id. Benign
1152
+ // transient: the block is still in the metadata store (no sweep reclaims metadata yet), so a
1153
+ // re-added released block is simply re-evaluated and re-released on the next rebalance tick. Right
1154
+ // after a restart, responsibility-loss detection lags this fast metadata scan, so the window is
1155
+ // small. Accepted rather than synchronized.
1156
+ if (offOwnedBlockFeed && typeof rawStorage.listBlockIds === 'function') {
1157
+ let seedStopping = false;
1158
+ const previousStop = node.stop.bind(node);
1159
+ node.stop = async () => {
1160
+ seedStopping = true;
1161
+ await previousStop();
1162
+ };
1163
+ void seedOwnedBlocksFromStorage(rawStorage, ownedBlocks, () => seedStopping)
1164
+ .catch((err) => ((node as any).logger?.forComponent?.('db-p2p:owned-block-seed'))?.('seed failed: %o', err));
1165
+ }
1166
+
1167
+ // [dispute-subsystem-dormant] The DisputeService object is constructed below so tests and
1168
+ // getDisputeStatus() work, but it is unreachable from the live network path:
1169
+ // - No inbound handler: disputeProtocolService is NOT in the services map above.
1170
+ // - onInvalidation is deliberately unset: maybeInvalidate() is a no-op on live nodes.
1171
+ // - revalidate is deliberately unset: handleChallenge always votes inconclusive on live nodes.
1172
+ // Full activation requires arbitrator-set anchoring before a forged synthetic cohort can pass resolution.
1173
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
1174
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
780
1175
  // Initialize dispute service if enabled
781
1176
  let disputeServiceInstance: DisputeService | undefined;
782
1177
  if (options.dispute?.disputeEnabled) {
@@ -789,23 +1184,28 @@ export async function createLibp2pNodeBase(
789
1184
  reputation,
790
1185
  validator: options.validator,
791
1186
  config: options.dispute,
792
- selectArbitrators: async (blockId: string, excludePeers: string[], count: number) => {
1187
+ selectArbitrators: async (blockId: string, excludePeers: string[], count: number, round: number, epoch: Uint8Array) => {
793
1188
  const { hashKey: fretHashKey } = await import('p2p-fret');
794
- const blockIdBytes = new TextEncoder().encode(blockId);
795
1189
  const fret = (node as any).services?.fret as FretService | undefined;
796
1190
  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
1191
+ // Dispersed sampling: draw `count` peers from coordinates spread across the whole keyspace
1192
+ // (hash(blockId round epoch i)) rather than the block's XOR neighborhood, so an attacker
1193
+ // who owns the block's locale does not thereby own the arbitrators. `assembleCohort` already
1194
+ // filters to known members; excluding the original cluster + self keeps arbitrators independent.
802
1195
  const excludeSet = new Set(excludePeers);
1196
+ // NOTE: adding the local node's own id to `exclude` makes the draw node-relative. Cross-node
1197
+ // determinism (the verifiable-recompute property) holds today only because the dissent
1198
+ // coordinator running this is itself a member of the original cluster, so `self` is already in
1199
+ // `excludePeers` — the add is a no-op and every honest node excludes the identical set. When a
1200
+ // verify-path recompute lands, it MUST reconstruct `exclude` from the challenger's identity
1201
+ // (`proof.challengerPeerId`) + original cluster, never the verifier's own id, or re-derivation diverges.
803
1202
  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;
1203
+ const picks = await sampleArbitrators(
1204
+ { blockId: new TextEncoder().encode(blockId), round, epoch, count, exclude: excludeSet },
1205
+ (coord, wants) => fret.assembleCohort(coord, wants) as string[],
1206
+ fretHashKey,
1207
+ );
1208
+ return picks.map(pid => peerIdFromString(pid));
809
1209
  },
810
1210
  });
811
1211
  }
@@ -830,6 +1230,12 @@ export async function createLibp2pNodeBase(
830
1230
  (node as any).keyNetwork = keyNetwork;
831
1231
  (node as any).reputation = reputation;
832
1232
  (node as any).disputeService = disputeServiceInstance;
1233
+ // The node's libp2p Ed25519 identity key. Exposed on the same `(node as any).*` surface as
1234
+ // coordinatedRepo/keyNetwork so a host can bind a client-transaction signer to it (the Quereus
1235
+ // collection-factory's getSigner reuses this via signPeer). libp2p does not surface the private
1236
+ // key on its public `Libp2p` interface, so this attachment is the sanctioned in-process handle.
1237
+ // Ed25519 by construction (options.privateKey defaults to generateKeyPair('Ed25519')).
1238
+ (node as any).peerPrivateKey = nodePrivateKey;
833
1239
 
834
1240
  // --- Cohort-topic origination activation (post-node: consumes the fully-assembled node + FRET) ---
835
1241
  // This is the only place that is after the node + FRET are assembled (node.start() done, fretSvc