@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
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Key ↔ peer-id binding predicate for the cluster/dispute vote paths.
3
+ *
4
+ * For a libp2p Ed25519 identity the peer-id **is** the multihash of the public key, so a public key
5
+ * that a record self-asserts for a peer id can be checked against the id itself — no network lookup.
6
+ * Both the cluster two-phase-commit path (`cluster-repo.ts`) and the dispute path (`dispute-service.ts`)
7
+ * read a signing key straight from the record they are handed; without this check a coordinator could
8
+ * attach a key it controls under an honest peer's id and have a forged vote verify. This predicate is
9
+ * the gate that ties a self-asserted key back to the identity it claims.
10
+ *
11
+ * Mirrors the binding logic already used by the cohort-topic substrate in `cohort-topic/peer-sig.ts`
12
+ * (`peerIdFromString(str).publicKey.raw`); intentionally NOT importing that module, which is
13
+ * specialized for its own wire encoding.
14
+ *
15
+ * NOTE: this proves only that a vote attributed to peer id `X` was signed by the key `X` names. It does
16
+ * NOT decide whether `X` is legitimately a cohort member — a coordinator that mints fresh keypairs and
17
+ * uses each key's own derived id passes this check for every one of them. Sybil/membership is a separate
18
+ * layer (cohort-topic membership certificates); do not assume it is solved here.
19
+ */
20
+
21
+ import { peerIdFromString } from "@libp2p/peer-id";
22
+
23
+ /**
24
+ * True iff `rawKey` is the Ed25519 public key that `peerIdStr` names. Total: returns `false` on a
25
+ * non-Ed25519 id, a missing/mismatched key, or any malformed input — never throws.
26
+ */
27
+ export function peerIdBindsPublicKey(peerIdStr: string, rawKey: Uint8Array): boolean {
28
+ try {
29
+ const peerId = peerIdFromString(peerIdStr);
30
+ if (peerId.type !== 'Ed25519' || peerId.publicKey === undefined) return false;
31
+ const expected = peerId.publicKey.raw; // 32 bytes for Ed25519
32
+ if (expected.length !== rawKey.length) return false;
33
+ return expected.every((b, i) => b === rawKey[i]);
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
Binary file
@@ -15,6 +15,13 @@ export interface RebalanceEvent {
15
15
  lost: string[]
16
16
  /** Peers that are now closer for the lost blocks: blockId → peerId[] */
17
17
  newOwners: Map<string, string[]>
18
+ /**
19
+ * Replication floor `N` for this event — the cohort size FRET assembled at check time
20
+ * ({@link RebalanceMonitor.getCohortSize}). The reaction gates release of a `lost` block on
21
+ * confirming it replicated to this many new owners, so a lost block is never released below the
22
+ * floor. See `docs/arachnode-ring-handoff.md` § Part 2.
23
+ */
24
+ floor: number
18
25
  /** Timestamp of the topology change that triggered this */
19
26
  triggeredAt: number
20
27
  }
@@ -33,13 +40,27 @@ export interface RebalanceMonitorDeps {
33
40
  fret: FretService
34
41
  partitionDetector: PartitionDetector
35
42
  fretAdapter: ArachnodeFretAdapter
43
+ /**
44
+ * The owned-block tracked set. When provided (e.g. the shared `ownedBlocks` set wired in
45
+ * `libp2p-node-base`), the monitor references this exact `Set` instead of constructing its own,
46
+ * so it stays in lock-step with the `SpreadOnChurnMonitor` that shares it. Omit for standalone
47
+ * construction (unit tests) — a fresh private `Set` preserves all existing behavior. Note: only
48
+ * `trackedBlocks` is shared; `responsibilitySnapshot` stays per-monitor (it is rebalance's own
49
+ * was-responsible memory, not owned-block tracking).
50
+ */
51
+ trackedBlocks?: Set<string>
36
52
  }
37
53
 
38
54
  type RebalanceHandler = (event: RebalanceEvent) => void
39
55
 
40
56
  export class RebalanceMonitor implements Startable {
41
57
  private running = false
42
- private readonly trackedBlocks = new Set<string>()
58
+ private readonly trackedBlocks: Set<string>
59
+ // Per-monitor was-responsible memory (NOT shared, unlike trackedBlocks). When the shared
60
+ // trackedBlocks set is mutated externally — spread's no-local-data self-prune, or the node's
61
+ // responsibility-loss eviction going through untrackBlock — a snapshot entry for a since-removed
62
+ // block may linger here. That is acceptable: performRebalanceCheck only iterates trackedBlocks, so
63
+ // a lingering entry is inert; if the block is later re-fed, its responsibility is simply re-derived.
43
64
  private readonly responsibilitySnapshot = new Map<string, boolean>()
44
65
  private readonly handlers: RebalanceHandler[] = []
45
66
  private debounceTimer: ReturnType<typeof setTimeout> | null = null
@@ -58,6 +79,10 @@ export class RebalanceMonitor implements Startable {
58
79
  private readonly deps: RebalanceMonitorDeps,
59
80
  config: RebalanceMonitorConfig = {}
60
81
  ) {
82
+ // Share the injected owned-block set when present (so spread + rebalance never drift);
83
+ // otherwise own a private set (standalone construction / unit tests). Only trackedBlocks is
84
+ // shared — responsibilitySnapshot stays per-monitor.
85
+ this.trackedBlocks = deps.trackedBlocks ?? new Set<string>()
61
86
  this.debounceMs = config.debounceMs ?? 5000
62
87
  this.minRebalanceIntervalMs = config.minRebalanceIntervalMs ?? 60000
63
88
  this.suppressDuringPartition = config.suppressDuringPartition ?? true
@@ -192,10 +217,16 @@ export class RebalanceMonitor implements Startable {
192
217
 
193
218
  log('rebalance check: gained=%d lost=%d', gained.length, lost.length)
194
219
 
195
- return { gained, lost, newOwners, triggeredAt }
220
+ return { gained, lost, newOwners, floor: this.getCohortSize(), triggeredAt }
196
221
  }
197
222
 
198
- private getCohortSize(): number {
223
+ /**
224
+ * The replication floor `N` — the cohort size FRET assembles for a block. Public so the ring-shift
225
+ * handoff and the rebalance reaction can gate release on confirming replication to this many
226
+ * holders (`docs/arachnode-ring-handoff.md` § Replication floor). Derives from FRET's network-size
227
+ * estimate: `clamp(ceil(sqrt(n_est)), 1, 3)`, defaulting to 3 when no confident estimate exists.
228
+ */
229
+ getCohortSize(): number {
199
230
  const diag: any = (this.deps.fret as any).getDiagnostics?.()
200
231
  const estimate = diag?.estimate ?? diag?.n
201
232
  if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) {
@@ -5,6 +5,7 @@ import type { Startable, Logger, Stream, Connection, StreamHandler, PeerId } fro
5
5
  import type { ICluster, ClusterRecord } from '@optimystic/db-core';
6
6
  import { encodePeers, type RedirectPayload } from '../repo/redirect.js';
7
7
  import { toClusterErrorEnvelope } from './cluster-error.js';
8
+ import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
8
9
  import type { Uint8ArrayList } from 'uint8arraylist';
9
10
 
10
11
  interface BaseComponents {
@@ -204,6 +205,11 @@ export class ClusterService implements Startable {
204
205
 
205
206
  // Encode and yield the response
206
207
  yield new TextEncoder().encode(JSON.stringify(response));
208
+ // One request per stream: every real ClusterClient sends exactly one
209
+ // request per dial (see ProtocolClient.processMessage), so complete the
210
+ // generator after the first response. A second frame a peer queued is
211
+ // then never read or parsed. Mirrors sync/block-transfer.
212
+ return;
207
213
  }
208
214
  };
209
215
 
@@ -211,7 +217,7 @@ export class ClusterService implements Startable {
211
217
  try {
212
218
  const responses = pipe(
213
219
  stream,
214
- (source) => lpDecode(source),
220
+ (source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
215
221
  processStream.bind(this),
216
222
  (source) => lpEncode(source)
217
223
  );
@@ -48,6 +48,13 @@ export interface SpreadOnChurnDeps {
48
48
  peerNetwork: IPeerNetwork
49
49
  clusterSize: number
50
50
  protocolPrefix?: string
51
+ /**
52
+ * The owned-block tracked set. When provided (e.g. the shared `ownedBlocks` set wired in
53
+ * `libp2p-node-base`), the monitor references this exact `Set` instead of constructing its own,
54
+ * so it stays in lock-step with the `RebalanceMonitor` that shares it. Omit for standalone
55
+ * construction (unit tests) — a fresh private `Set` preserves all existing behavior.
56
+ */
57
+ trackedBlocks?: Set<string>
51
58
  }
52
59
 
53
60
  export interface SpreadEvent {
@@ -83,7 +90,7 @@ const DEFAULT_CONFIG: SpreadOnChurnConfig = {
83
90
 
84
91
  export class SpreadOnChurnMonitor implements Startable {
85
92
  private running = false
86
- private readonly trackedBlocks = new Set<string>()
93
+ private readonly trackedBlocks: Set<string>
87
94
  private readonly handlers: SpreadHandler[] = []
88
95
  private debounceTimer: ReturnType<typeof setTimeout> | null = null
89
96
  private departureTimestamps: number[] = []
@@ -96,6 +103,9 @@ export class SpreadOnChurnMonitor implements Startable {
96
103
  private readonly deps: SpreadOnChurnDeps,
97
104
  config: Partial<SpreadOnChurnConfig> = {}
98
105
  ) {
106
+ // Share the injected owned-block set when present (so spread + rebalance never drift);
107
+ // otherwise own a private set (standalone construction / unit tests).
108
+ this.trackedBlocks = deps.trackedBlocks ?? new Set<string>()
99
109
  this.config = { ...DEFAULT_CONFIG, ...config }
100
110
  this.onConnectionClose = () => this.handleDeparture()
101
111
  }
@@ -0,0 +1,37 @@
1
+ import { DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
2
+
3
+ /** A component that exposes the super-majority threshold it actually runs on. */
4
+ export interface HasEffectiveSuperMajorityThreshold {
5
+ readonly effectiveSuperMajorityThreshold: number;
6
+ }
7
+
8
+ /**
9
+ * Fail-fast coupling check for a live node's consensus wiring.
10
+ *
11
+ * The cluster **member** (what accepts a super-majority of promises as sufficient for a commit) and the
12
+ * **coordinator** (what declares a transaction committed once it has that super-majority) MUST run the
13
+ * SAME `superMajorityThreshold`. If they disagree, the coordinator can declare a transaction final on a
14
+ * super-majority the member never accepts as final — a silent phase-disagreement that only surfaces
15
+ * mid-consensus, far too late.
16
+ *
17
+ * On a live node both are fed from a single resolved `consensusConfig`, so this check normally passes. It
18
+ * exists to catch *future* drift — divergent fallback defaults, a mis-threaded `clusterPolicy`, a
19
+ * per-deployment config that reaches only one side — by throwing at construction with both values and
20
+ * their provenance, rather than letting the node come up mismatched.
21
+ *
22
+ * @throws Error naming both resolved thresholds and where they come from, if they are not equal.
23
+ */
24
+ export function assertSuperMajorityCoupling(
25
+ member: HasEffectiveSuperMajorityThreshold,
26
+ coordinator: HasEffectiveSuperMajorityThreshold
27
+ ): void {
28
+ const memberThreshold = member.effectiveSuperMajorityThreshold;
29
+ const coordinatorThreshold = coordinator.effectiveSuperMajorityThreshold;
30
+ if (memberThreshold !== coordinatorThreshold) {
31
+ throw new Error(
32
+ `Super-majority threshold mismatch at node startup: cluster member resolved ${memberThreshold} but coordinator resolved ${coordinatorThreshold}. ` +
33
+ `Both derive from consensusConfig.superMajorityThreshold (options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD=${DEFAULT_SUPER_MAJORITY_THRESHOLD}); ` +
34
+ `they must be equal or the coordinator would declare transactions committed on a super-majority the member rejects as final.`
35
+ );
36
+ }
37
+ }
@@ -16,6 +16,7 @@ import {
16
16
  toGossipRecord,
17
17
  willingnessBitsHex,
18
18
  selfWillingnessBits,
19
+ type ChildLinkRefV1,
19
20
  type CohortGossipV1,
20
21
  type CohortTopicSummary,
21
22
  type GossipRecordRefV1,
@@ -35,6 +36,20 @@ import {
35
36
  */
36
37
  export const DEFAULT_GOSSIP_INTERVAL_MS = 5_000;
37
38
 
39
+ /**
40
+ * Default `T_willingness_heartbeat` (ms): how often a genuinely-**idle** but **willing** engine re-broadcasts
41
+ * a willingness-only heartbeat so a cold cohort can bootstrap (siblings hear it, instantiate, and reciprocate
42
+ * their own willingness) without waiting on a first registration that can never be admitted while the view is
43
+ * empty. See `docs/cohort-topic.md` §Cold-start instantiation / §Configuration.
44
+ *
45
+ * On the order of the ping interval (~30 s, `ttl/3` Core) — a few gossip rounds at the 5 s cadence. A
46
+ * record-carrying (non-idle) round already ships willingness every round and resets this clock, so the
47
+ * throttle governs only engines with nothing else to say. The very first idle round after an engine is
48
+ * created emits immediately (no wait), so bootstrap converges in ~2 rounds; the throttle only paces the
49
+ * steady-state re-broadcast of an idle willing cohort.
50
+ */
51
+ export const DEFAULT_WILLINGNESS_HEARTBEAT_MS = 30_000;
52
+
38
53
  /**
39
54
  * Per-`CoordEngine` queue of registration-record deltas accumulated between gossip rounds. The renewal
40
55
  * cohort side calls {@link PendingDeltas.touch} on every served ping/re-attach and
@@ -46,18 +61,46 @@ export interface PendingDeltas {
46
61
  touch(rec: RegistrationRecord): void;
47
62
  /** Queue an eviction ref and drop any pending record for the same key (a stale record can't also re-advertise). */
48
63
  evicted(rec: RegistrationRecord): void;
64
+ /**
65
+ * Queue a child-cohort **link** for replication (keyed by `(topicId, childCohortCoord)`; last write wins on
66
+ * `effectiveAt`). Enqueued by the parent engine only when the local child registry actually changed, so a
67
+ * stale/no-op record is not re-gossiped. A link and a later unlink for the same child in one round collapse
68
+ * to whichever carries the newer `effectiveAt`.
69
+ */
70
+ childLink(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): void;
71
+ /** Queue a child-cohort **unlink** (a released/demoted child) for replication; same key + last-writer-wins as {@link childLink}. */
72
+ childUnlink(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): void;
49
73
  /** True iff nothing is queued. */
50
74
  isEmpty(): boolean;
51
75
  /** Drain the queue into wire-shaped deltas, clearing it. */
52
- drain(): { records: GossipRecordV1[]; evicted: GossipRecordRefV1[] };
76
+ drain(): { records: GossipRecordV1[]; evicted: GossipRecordRefV1[]; childLinks: ChildLinkRefV1[]; childUnlinks: ChildLinkRefV1[] };
77
+ }
78
+
79
+ /** A queued child link/unlink: the wire ref plus whether it is a link (`true`) or an unlink (`false`). */
80
+ interface PendingChildDelta {
81
+ ref: ChildLinkRefV1;
82
+ linked: boolean;
53
83
  }
54
84
 
55
85
  /** Build an empty {@link PendingDeltas} queue. */
56
86
  export function createPendingDeltas(): PendingDeltas {
57
87
  const records = new Map<string, RegistrationRecord>();
58
88
  const evicted = new Map<string, GossipRecordRefV1>();
89
+ const childDeltas = new Map<string, PendingChildDelta>();
59
90
  const keyOf = (topicId: Uint8Array, participantId: Uint8Array): string =>
60
91
  `${bytesToB64url(topicId)}|${bytesToB64url(participantId)}`;
92
+ // A child delta and its later opposite (link→unlink) share one key so the round drains only the newest.
93
+ const childKeyOf = (topicId: Uint8Array, childCohortCoord: Uint8Array): string =>
94
+ `${bytesToB64url(topicId)}|${bytesToB64url(childCohortCoord)}`;
95
+ const queueChild = (topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number, linked: boolean): void => {
96
+ const key = childKeyOf(topicId, childCohortCoord);
97
+ const held = childDeltas.get(key);
98
+ // Last-writer-wins on effectiveAt: never let an older link/unlink shadow a newer one queued the same round.
99
+ if (held !== undefined && effectiveAt < held.ref.effectiveAt) {
100
+ return;
101
+ }
102
+ childDeltas.set(key, { ref: { topicId: bytesToB64url(topicId), childCohortCoord: bytesToB64url(childCohortCoord), effectiveAt }, linked });
103
+ };
61
104
  return {
62
105
  touch(rec: RegistrationRecord): void {
63
106
  const key = keyOf(rec.topicId, rec.participantId);
@@ -70,19 +113,35 @@ export function createPendingDeltas(): PendingDeltas {
70
113
  },
71
114
  evicted(rec: RegistrationRecord): void {
72
115
  const key = keyOf(rec.topicId, rec.participantId);
73
- evicted.set(key, { topicId: bytesToB64url(rec.topicId), participantId: bytesToB64url(rec.participantId) });
116
+ // Stamp the evicted record's lastPing so the receiver can gate the delete on freshness (a stale
117
+ // eviction must not delete a record the participant has since re-registered — see bus.mergeRecords).
118
+ evicted.set(key, { topicId: bytesToB64url(rec.topicId), participantId: bytesToB64url(rec.participantId), lastPing: rec.lastPing });
74
119
  records.delete(key);
75
120
  },
121
+ childLink(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): void {
122
+ queueChild(topicId, childCohortCoord, effectiveAt, true);
123
+ },
124
+ childUnlink(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): void {
125
+ queueChild(topicId, childCohortCoord, effectiveAt, false);
126
+ },
76
127
  isEmpty(): boolean {
77
- return records.size === 0 && evicted.size === 0;
128
+ return records.size === 0 && evicted.size === 0 && childDeltas.size === 0;
78
129
  },
79
- drain(): { records: GossipRecordV1[]; evicted: GossipRecordRefV1[] } {
130
+ drain(): { records: GossipRecordV1[]; evicted: GossipRecordRefV1[]; childLinks: ChildLinkRefV1[]; childUnlinks: ChildLinkRefV1[] } {
131
+ const childLinks: ChildLinkRefV1[] = [];
132
+ const childUnlinks: ChildLinkRefV1[] = [];
133
+ for (const delta of childDeltas.values()) {
134
+ (delta.linked ? childLinks : childUnlinks).push(delta.ref);
135
+ }
80
136
  const out = {
81
137
  records: [...records.values()].map(toGossipRecord),
82
138
  evicted: [...evicted.values()],
139
+ childLinks,
140
+ childUnlinks,
83
141
  };
84
142
  records.clear();
85
143
  evicted.clear();
144
+ childDeltas.clear();
86
145
  return out;
87
146
  },
88
147
  };
@@ -96,6 +155,16 @@ export interface GossipFrameInputs {
96
155
  readonly coord: string;
97
156
  /** This cohort's epoch, base64url. */
98
157
  readonly cohortEpoch: string;
158
+ /** This cohort's tree tier `d` — carried on the frame so a cold sibling instantiates at the right tier. */
159
+ readonly treeTier: number;
160
+ /**
161
+ * True to emit a **willingness-only heartbeat** even when this engine is idle (no resident topics, no
162
+ * pending deltas) — provided the node is actually willing for some tier. Lets an idle-but-willing engine
163
+ * still tell siblings it will serve, so a cold cohort can bootstrap (§Cold-start instantiation). The caller
164
+ * (the gossip round) sets this from the per-engine heartbeat clock; a non-idle round ignores it (it already
165
+ * ships willingness).
166
+ */
167
+ readonly heartbeat: boolean;
99
168
  /** This node's tier profile (the `∧` half of the willingness vector). */
100
169
  readonly profile: NodeProfile;
101
170
  /** This member's load barometer (load buckets + the load-shed half of willingness). */
@@ -108,18 +177,29 @@ export interface GossipFrameInputs {
108
177
  readonly records: GossipRecordV1[];
109
178
  /** Drained eviction refs for this round. */
110
179
  readonly evicted: GossipRecordRefV1[];
180
+ /** Drained child-cohort link refs for this round (cross-member child-set convergence). */
181
+ readonly childLinks: ChildLinkRefV1[];
182
+ /** Drained child-cohort unlink refs for this round (a released/demoted child). */
183
+ readonly childUnlinks: ChildLinkRefV1[];
111
184
  /** Round timestamp, unix ms. */
112
185
  readonly timestamp: number;
113
186
  }
114
187
 
115
188
  /**
116
- * Assemble one round's {@link CohortGossipV1}, or `undefined` when this engine is **idle** no resident
117
- * topics and no pending deltas, so it has nothing for siblings and the host skips the broadcast (idle
118
- * empty engines cost no gossip). `willingnessBits` is `profile load` ({@link selfWillingnessBits}); the
189
+ * Assemble one round's {@link CohortGossipV1}, or `undefined` when this engine has nothing to say. An engine
190
+ * is **idle** when it holds no resident topics and no pending deltas. An idle engine normally builds no frame
191
+ * (idle empty engines cost no gossip) **except** on a willingness heartbeat ({@link GossipFrameInputs.heartbeat}),
192
+ * where an idle engine that is willing for at least one tier (`selfWillingnessBits !== 0`) still emits a
193
+ * willingness/load-only frame (empty `topicSummaries`, no `records`/`evicted`) so siblings can hear it and a
194
+ * cold cohort can bootstrap (§Cold-start instantiation). An idle-and-unwilling engine stays silent even on a
195
+ * heartbeat (nothing to bootstrap). `willingnessBits` is `profile ∧ load` ({@link selfWillingnessBits}); the
119
196
  * `signature` slot is left empty for the host's peer-key signer to fill before broadcast.
120
197
  */
121
198
  export function buildCohortGossip(i: GossipFrameInputs): CohortGossipV1 | undefined {
122
- if (i.topicSummaries.length === 0 && i.records.length === 0 && i.evicted.length === 0) {
199
+ const willingness = selfWillingnessBits(i.profile, i.barometer);
200
+ const idle = i.topicSummaries.length === 0 && i.records.length === 0 && i.evicted.length === 0
201
+ && i.childLinks.length === 0 && i.childUnlinks.length === 0;
202
+ if (idle && !(i.heartbeat && willingness !== 0)) {
123
203
  return undefined;
124
204
  }
125
205
  const g: CohortGossipV1 = {
@@ -127,7 +207,8 @@ export function buildCohortGossip(i: GossipFrameInputs): CohortGossipV1 | undefi
127
207
  fromMember: i.fromMember,
128
208
  coord: i.coord,
129
209
  cohortEpoch: i.cohortEpoch,
130
- willingnessBits: willingnessBitsHex(selfWillingnessBits(i.profile, i.barometer)),
210
+ treeTier: i.treeTier,
211
+ willingnessBits: willingnessBitsHex(willingness),
131
212
  loadBuckets: i.barometer.loadBuckets(),
132
213
  windowSeconds: i.windowSeconds,
133
214
  topicSummaries: i.topicSummaries,
@@ -140,5 +221,11 @@ export function buildCohortGossip(i: GossipFrameInputs): CohortGossipV1 | undefi
140
221
  if (i.evicted.length > 0) {
141
222
  g.evicted = i.evicted;
142
223
  }
224
+ if (i.childLinks.length > 0) {
225
+ g.childLinks = i.childLinks;
226
+ }
227
+ if (i.childUnlinks.length > 0) {
228
+ g.childUnlinks = i.childUnlinks;
229
+ }
143
230
  return g;
144
231
  }
@@ -64,6 +64,16 @@ export class FretCohortGossipTransport implements ICohortGossipTransport {
64
64
  return () => this.handlers.delete(handler);
65
65
  }
66
66
 
67
+ /**
68
+ * Number of live inbound subscriptions (one per subscribed gossip bus). Test/diagnostic introspection:
69
+ * each {@link CoordEngine}'s bus subscribes via {@link onMessage} and drops its subscription on `close()`,
70
+ * so this tracks the number of live coord engines (+ the node-level participant bus) — used to observe that
71
+ * the coord-engine registry's LRU eviction actually tears evicted engines down (no leaked subscriptions).
72
+ */
73
+ get subscriberCount(): number {
74
+ return this.handlers.size;
75
+ }
76
+
67
77
  /** Feed an inbound gossip frame (called by the host's `/cohort-gossip` protocol handler). */
68
78
  deliver(fromPeerId: string, msg: Uint8Array): void {
69
79
  const from: PeerRef = { id: peerIdToBytes(fromPeerId) };