@optimystic/db-p2p 0.14.1 → 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
@@ -1,11 +1,11 @@
1
- import { blockIdsForTransforms } from "@optimystic/db-core";
1
+ import { blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
2
+ import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority } from "@optimystic/db-core";
2
3
  import { verifyInvalidationCertificate } from "../dispute/invalidation.js";
3
4
  import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
4
5
  import { ClusterClient } from "./client.js";
5
6
  import { peerIdFromString } from "@libp2p/peer-id";
6
7
  import { publicKeyFromRaw } from "@libp2p/crypto/keys";
7
- import { sha256 } from "multiformats/hashes/sha2";
8
- import { base58btc } from "multiformats/bases/base58";
8
+ import { peerIdBindsPublicKey } from "./peer-key-binding.js";
9
9
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
10
10
  import { createLogger } from '../logger.js';
11
11
  import { PenaltyReason } from "../reputation/types.js";
@@ -20,8 +20,10 @@ var TransactionPhase;
20
20
  TransactionPhase[TransactionPhase["Rejected"] = 4] = "Rejected";
21
21
  TransactionPhase[TransactionPhase["Propagating"] = 5] = "Propagating"; // Transaction is being propagated
22
22
  })(TransactionPhase || (TransactionPhase = {}));
23
+ /** Stable reject reason a member emits when a declared peer set fails the membership admission gate. */
24
+ export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
23
25
  export function clusterMember(components) {
24
- return new ClusterMember(components.storageRepo, components.peerNetwork, components.peerId, components.privateKey, components.protocolPrefix, components.partitionDetector, components.fretService, components.validator, components.reputation, components.consensusConfig, components.stateStore, components.reconcileBlock, components.onCommitCertificate, components.onInvalidate, components.recomputeArbitratorSet);
26
+ return new ClusterMember(components.storageRepo, components.peerNetwork, components.peerId, components.privateKey, components.protocolPrefix, components.partitionDetector, components.fretService, components.validator, components.reputation, components.consensusConfig, components.stateStore, components.reconcileBlock, components.onCommitCertificate, components.onInvalidate, components.recomputeArbitratorSet, components.deriveExpectedCluster);
25
27
  }
26
28
  // How long to keep executed transaction records (10 minutes)
27
29
  const ExecutedTransactionTtlMs = 10 * 60 * 1000;
@@ -56,6 +58,7 @@ export class ClusterMember {
56
58
  onCommitCertificate;
57
59
  onInvalidate;
58
60
  recomputeArbitratorSet;
61
+ deriveExpectedCluster;
59
62
  // Track active transactions by their message hash
60
63
  activeTransactions = new Map();
61
64
  // Track executed consensus transactions to prevent duplicate execution (messageHash -> executedAt timestamp)
@@ -72,11 +75,30 @@ export class ClusterMember {
72
75
  // Interval handles for periodic cleanup (stored so dispose() can clear them)
73
76
  expirationInterval;
74
77
  cleanupInterval;
75
- /** Effective super-majority threshold. Defaults to 1.0 (unanimity) for backward compatibility. */
78
+ /**
79
+ * Confidence floor at/below which FRET's network-size view is treated as untrustworthy for the
80
+ * membership gate. Above it the member trusts its derived view (confident path); at/below it the gate
81
+ * fails closed for downsizing. Matches the coordinator's `validateSmallCluster` confidence gate (> 0.5).
82
+ */
83
+ static MembershipConfidenceThreshold = 0.5;
84
+ /**
85
+ * Effective super-majority threshold this member accepts as sufficient for a commit. Defaults to
86
+ * {@link DEFAULT_SUPER_MAJORITY_THRESHOLD} (0.75) when no config is supplied — the SAME default the
87
+ * coordinator uses, so the two can never silently disagree about whether a transaction is final.
88
+ * (Previously defaulted to 1.0/unanimity, which split the member from a coordinator committing at 0.75.)
89
+ */
76
90
  superMajorityThreshold;
91
+ // Membership admission gate parameters (see {@link admitMembership}). Read once from consensusConfig
92
+ // so the gate has stable thresholds independent of the (untrusted) values a record declares.
93
+ minAbsoluteClusterSize;
94
+ clusterSizeTolerance;
95
+ membershipAdmissionFraction;
96
+ /** Configured full cluster size, or undefined when unknown (then the gate cannot judge a downsize). */
97
+ configuredClusterSize;
98
+ allowUnvalidatedSmallCluster;
77
99
  constructor(storageRepo, peerNetwork, peerId, privateKey, protocolPrefix,
78
100
  // Reserved for partition-healing (backlog ticket 6.5-partition-healing); held but not yet consumed.
79
- _partitionDetector, fretService, validator, reputation, consensusConfig, stateStore, reconcileBlock, onCommitCertificate, onInvalidate, recomputeArbitratorSet) {
101
+ _partitionDetector, fretService, validator, reputation, consensusConfig, stateStore, reconcileBlock, onCommitCertificate, onInvalidate, recomputeArbitratorSet, deriveExpectedCluster) {
80
102
  this.storageRepo = storageRepo;
81
103
  this.peerNetwork = peerNetwork;
82
104
  this.peerId = peerId;
@@ -90,7 +112,13 @@ export class ClusterMember {
90
112
  this.onCommitCertificate = onCommitCertificate;
91
113
  this.onInvalidate = onInvalidate;
92
114
  this.recomputeArbitratorSet = recomputeArbitratorSet;
93
- this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? 1.0;
115
+ this.deriveExpectedCluster = deriveExpectedCluster;
116
+ this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD;
117
+ this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
118
+ this.clusterSizeTolerance = consensusConfig?.clusterSizeTolerance ?? 0.5;
119
+ this.membershipAdmissionFraction = consensusConfig?.membershipAdmissionFraction ?? 0.75;
120
+ this.configuredClusterSize = consensusConfig?.clusterSize;
121
+ this.allowUnvalidatedSmallCluster = consensusConfig?.allowUnvalidatedSmallCluster ?? false;
94
122
  // Periodically clean up expired transactions (.unref() so tests/short-lived processes can exit)
95
123
  this.expirationInterval = setInterval(() => this.queueExpiredTransactions(), 60000);
96
124
  this.expirationInterval.unref();
@@ -98,6 +126,15 @@ export class ClusterMember {
98
126
  this.cleanupInterval = setInterval(() => this.processCleanupQueue(), 1000);
99
127
  this.cleanupInterval.unref();
100
128
  }
129
+ /**
130
+ * The resolved super-majority threshold this member runs on. Exposed so the composition root can
131
+ * fail-fast if the member and the coordinator would run different thresholds (see the coupling
132
+ * assertion in `libp2p-node-base.ts`) — a mismatch is a latent phase-disagreement, caught at startup
133
+ * rather than mid-consensus.
134
+ */
135
+ get effectiveSuperMajorityThreshold() {
136
+ return this.superMajorityThreshold;
137
+ }
101
138
  /**
102
139
  * Clears all interval and timeout handles and empties active state.
103
140
  * Called during node shutdown to prevent leaked timers.
@@ -227,10 +264,7 @@ export class ClusterMember {
227
264
  log('cluster-member:action-consensus-after-commit', {
228
265
  messageHash: record.messageHash
229
266
  });
230
- // Check persistent store for post-recovery dedup before synchronous guard
231
- if (!await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
232
- await this.handleConsensus(currentRecord);
233
- }
267
+ await this.handleConsensus(currentRecord);
234
268
  }
235
269
  }
236
270
  shouldPersist = false;
@@ -239,13 +273,7 @@ export class ClusterMember {
239
273
  log('cluster-member:action-consensus', {
240
274
  messageHash: record.messageHash
241
275
  });
242
- // Check persistent store for post-recovery dedup before synchronous guard
243
- if (await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
244
- log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
245
- }
246
- else {
247
- await this.handleConsensus(currentRecord);
248
- }
276
+ await this.handleConsensus(currentRecord);
249
277
  // Don't call clearTransaction here - it happens in handleConsensus
250
278
  shouldPersist = false;
251
279
  break;
@@ -319,10 +347,33 @@ export class ClusterMember {
319
347
  if (existing.messageHash !== incoming.messageHash) {
320
348
  throw new Error('Message hash mismatch');
321
349
  }
322
- if (JSON.stringify(existing.message) !== JSON.stringify(incoming.message)) {
350
+ if (ClusterMember.canonicalJson(existing.message) !== ClusterMember.canonicalJson(incoming.message)) {
323
351
  throw new Error('Message content mismatch');
324
352
  }
325
- if (JSON.stringify(existing.peers) !== JSON.stringify(incoming.peers)) {
353
+ if (existing.membershipVersion === 2 || incoming.membershipVersion === 2) {
354
+ // v2: the sorted peer-id set (captured by membershipDigest) is bound into messageHash, so equal
355
+ // messageHash MUST imply equal membership on any honest path. A mismatch here — different digest
356
+ // or version at equal hash — is a protocol violation (a bug or a hash-collision attack), NOT an
357
+ // honest divergence: two honest members with different views now hold two DIFFERENT hashes, i.e.
358
+ // two competing transactions the race machinery resolves, not one contested record. Log loudly
359
+ // and reject; never silently adopt the incoming set. (validateRecord already proved each record's
360
+ // own digest matches its own peers, so multiaddr / pubkey churn within the SAME id set — which
361
+ // keeps the same digest and hash — does NOT trip this.)
362
+ if (existing.membershipVersion !== incoming.membershipVersion || existing.membershipDigest !== incoming.membershipDigest) {
363
+ log('cluster-member:peers-mismatch-invariant-violation', {
364
+ messageHash: existing.messageHash,
365
+ existingVersion: existing.membershipVersion,
366
+ incomingVersion: incoming.membershipVersion,
367
+ existingDigest: existing.membershipDigest,
368
+ incomingDigest: incoming.membershipDigest,
369
+ existingPeers: Object.keys(existing.peers ?? {}).sort(),
370
+ incomingPeers: Object.keys(incoming.peers ?? {}).sort()
371
+ });
372
+ throw new Error('Peers mismatch');
373
+ }
374
+ }
375
+ else if (ClusterMember.canonicalJson(existing.peers) !== ClusterMember.canonicalJson(incoming.peers)) {
376
+ // v1 (legacy, membership unbound): full peer-object equality is the only available guard.
326
377
  throw new Error('Peers mismatch');
327
378
  }
328
379
  // Merge signatures with equivocation detection
@@ -341,6 +392,10 @@ export class ClusterMember {
341
392
  * New peers are accepted normally.
342
393
  */
343
394
  detectEquivocation(existing, incoming, phase, messageHash) {
395
+ // NOTE: relies on validateSignatures() (via validateRecord in processUpdate) having already run on
396
+ // every record reaching here, so each peerId's signature is key-bound. Without that guarantee the
397
+ // Equivocation penalty below would act on self-asserted, unverified peer ids and could frame an
398
+ // honest peer. Do not call this on unvalidated signatures.
344
399
  const merged = { ...existing };
345
400
  for (const [peerId, incomingSig] of Object.entries(incoming)) {
346
401
  const existingSig = existing[peerId];
@@ -367,8 +422,25 @@ export class ClusterMember {
367
422
  return merged;
368
423
  }
369
424
  async validateRecord(record) {
370
- // Validate message hash matches the message content
371
- const expectedHash = await this.computeMessageHash(record.message);
425
+ // Reject a record whose membership-binding version this code does not implement. The cluster
426
+ // consensus code is a single deployable unit (all cluster members upgrade together), so a version
427
+ // we don't understand is rejected rather than cross-version-consensus'd.
428
+ if (record.membershipVersion !== undefined && record.membershipVersion !== 1 && record.membershipVersion !== 2) {
429
+ throw new Error(`Unsupported membershipVersion: ${record.membershipVersion}`);
430
+ }
431
+ // v2: the declared membership digest must match the record's own peer set. A record whose declared
432
+ // digest doesn't match its peers is malformed (and its messageHash — computed over that digest —
433
+ // would not bind the real membership).
434
+ // NOTE: recomputes membershipDigest (one SHA256 over the sorted peer-id list) on every incoming v2
435
+ // record; if a hot cluster ever shows this as a cost, memoize per (messageHash → digest).
436
+ if (record.membershipVersion === 2) {
437
+ const expectedDigest = await membershipDigest(record.peers);
438
+ if (expectedDigest !== record.membershipDigest) {
439
+ throw new Error(`Membership digest mismatch: expected=${expectedDigest}, received=${record.membershipDigest ?? 'undefined'}`);
440
+ }
441
+ }
442
+ // Validate message hash matches the message content (v2 folds in the membership digest)
443
+ const expectedHash = await this.computeMessageHash(record);
372
444
  if (expectedHash !== record.messageHash) {
373
445
  throw new Error(`Message hash mismatch: expected=${expectedHash}, received=${record.messageHash}`);
374
446
  }
@@ -380,47 +452,56 @@ export class ClusterMember {
380
452
  }
381
453
  }
382
454
  /**
383
- * Compute message hash using the same algorithm as the coordinator.
384
- * Must match cluster-coordinator.ts createMessageHash().
455
+ * Compute message hash using the same algorithm as the coordinator. Version-dispatched: a v2 record
456
+ * folds its membership digest into the preimage, a v1 / unversioned record hashes byte-identically to
457
+ * before this change. Must match cluster-coordinator.ts createMessageHash().
385
458
  */
386
- async computeMessageHash(message) {
387
- const msgBytes = new TextEncoder().encode(ClusterMember.canonicalJson(message));
388
- const hashBytes = await sha256.digest(msgBytes);
389
- return base58btc.encode(hashBytes.digest);
459
+ async computeMessageHash(record) {
460
+ return computeClusterMessageHash(record.message, recordMembershipDigest(record));
390
461
  }
391
462
  async validateSignatures(record) {
392
- // Validate promise signatures
463
+ // Validate promise signatures. Reject on any failure, but only report an InvalidSignature
464
+ // penalty when the key was proven to belong to `peerId` (outcome.penalize) — otherwise the id
465
+ // is attacker-chosen and reporting it would let a coordinator frame an honest peer.
393
466
  const promiseHash = await this.computePromiseHash(record);
394
467
  for (const [peerId, signature] of Object.entries(record.promises)) {
395
- if (!await this.verifySignature(record, peerId, promiseHash, signature)) {
396
- this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `promise:${record.messageHash}`);
468
+ const outcome = await this.verifySignature(record, peerId, promiseHash, signature);
469
+ if (!outcome.valid) {
470
+ if (outcome.penalize) {
471
+ this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `promise:${record.messageHash}`);
472
+ }
397
473
  throw new Error(`Invalid promise signature from ${peerId}`);
398
474
  }
399
475
  }
400
476
  // Validate commit signatures
401
477
  const commitHash = await this.computeCommitHash(record);
402
478
  for (const [peerId, signature] of Object.entries(record.commits)) {
403
- if (!await this.verifySignature(record, peerId, commitHash, signature)) {
404
- this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `commit:${record.messageHash}`);
479
+ const outcome = await this.verifySignature(record, peerId, commitHash, signature);
480
+ if (!outcome.valid) {
481
+ if (outcome.penalize) {
482
+ this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `commit:${record.messageHash}`);
483
+ }
405
484
  throw new Error(`Invalid commit signature from ${peerId}`);
406
485
  }
407
486
  }
408
487
  }
409
- /** Deterministic JSON: sorts object keys so hash is order-independent */
488
+ /**
489
+ * Deterministic JSON: sorts object keys so comparisons are order-independent.
490
+ * NOTE: this is a second copy of the canonicalization in db-core `membership.ts` (which feeds the hash
491
+ * preimages). It is used here only for equality checks (message-content / v1-peers compare), and those
492
+ * run *after* a messageHash-equality gate, so a drift between the two can't silently forge agreement —
493
+ * but keep them byte-identical. If a third caller appears, promote to a single exported helper.
494
+ */
410
495
  static canonicalJson(value) {
411
496
  return JSON.stringify(value, (_, v) => v && typeof v === 'object' && !Array.isArray(v)
412
497
  ? Object.keys(v).sort().reduce((o, k) => { o[k] = v[k]; return o; }, {})
413
498
  : v);
414
499
  }
415
500
  async computePromiseHash(record) {
416
- const msgBytes = new TextEncoder().encode(record.messageHash + ClusterMember.canonicalJson(record.message));
417
- const hashBytes = await sha256.digest(msgBytes);
418
- return uint8ArrayToString(hashBytes.digest, 'base64url');
501
+ return computeClusterPromiseHash(record.messageHash, record.message, recordMembershipDigest(record));
419
502
  }
420
503
  async computeCommitHash(record) {
421
- const msgBytes = new TextEncoder().encode(record.messageHash + ClusterMember.canonicalJson(record.message) + ClusterMember.canonicalJson(record.promises));
422
- const hashBytes = await sha256.digest(msgBytes);
423
- return uint8ArrayToString(hashBytes.digest, 'base64url');
504
+ return computeClusterCommitHash(record.messageHash, record.message, record.promises, recordMembershipDigest(record));
424
505
  }
425
506
  computeSigningPayload(hash, type, rejectReason) {
426
507
  const payload = hash + ':' + type + (rejectReason ? ':' + rejectReason : '');
@@ -431,17 +512,55 @@ export class ClusterMember {
431
512
  const sigBytes = await this.privateKey.sign(payload);
432
513
  return uint8ArrayToString(sigBytes, 'base64url');
433
514
  }
515
+ /**
516
+ * Verify one vote signature and classify the outcome (see {@link VerifyOutcome}). Total on hostile
517
+ * input: a missing/empty key, a non-Ed25519 id, a key not bound to `peerId`, or malformed bytes all
518
+ * yield `{ valid:false, penalize:false }` (reject without penalizing an unproven identity) rather
519
+ * than throwing. Only after the key is proven to be the one `peerId` names does a failed
520
+ * cryptographic verify yield `{ valid:false, penalize:true }`.
521
+ *
522
+ * NOTE: the binding check (`peerIdBindsPublicKey`) proves the vote was signed by the key `peerId`
523
+ * names — it does NOT establish that `peerId` is legitimately in the cohort. A coordinator minting
524
+ * fresh keypairs and using each key's own derived id passes this for every one. Sybil/cohort
525
+ * membership is a separate layer (cohort-topic membership certificates), not solved here.
526
+ */
434
527
  async verifySignature(record, peerId, hash, signature) {
435
528
  const peerInfo = record.peers[peerId];
436
529
  if (!peerInfo?.publicKey?.length) {
437
- throw new Error(`No public key for peer ${peerId}`);
438
- }
439
- // publicKey is base64url-encoded string (JSON-serialization safe)
440
- const keyBytes = uint8ArrayFromString(peerInfo.publicKey, 'base64url');
441
- const pubKey = publicKeyFromRaw(keyBytes);
442
- const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
443
- const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
444
- return pubKey.verify(payload, sigBytes);
530
+ // No key to check against — identity not proven. Reject without penalty.
531
+ return { valid: false, penalize: false };
532
+ }
533
+ let keyBytes;
534
+ try {
535
+ // publicKey is base64url-encoded string (JSON-serialization safe)
536
+ keyBytes = uint8ArrayFromString(peerInfo.publicKey, 'base64url');
537
+ }
538
+ catch {
539
+ return { valid: false, penalize: false };
540
+ }
541
+ // The key must be the one `peerId` provably names, else the vote could be attributed to any peer
542
+ // id while signed by a key the coordinator controls. Binding failure ⇒ identity unproven ⇒ no penalty.
543
+ if (!peerIdBindsPublicKey(peerId, keyBytes)) {
544
+ return { valid: false, penalize: false };
545
+ }
546
+ try {
547
+ const pubKey = publicKeyFromRaw(keyBytes);
548
+ const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
549
+ const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
550
+ const ok = await pubKey.verify(payload, sigBytes);
551
+ // Key is bound to peerId: a failed verify is a genuine bad vote from a proven identity → penalize.
552
+ // NOTE: residual — an Ed25519 peer's public key is derivable from its (public) id, so an attacker
553
+ // can attach a victim's REAL key with a garbage signature and still trip this InvalidSignature
554
+ // penalty on the victim. Binding narrows framing (the attacker must use the victim's own key, not
555
+ // an arbitrary one) but cannot eliminate it here: a single signature can't distinguish "victim
556
+ // signed badly" from "someone pasted the victim's public key + junk". Fully closing it needs an
557
+ // authenticated membership/channel layer (cohort-topic membership certs), out of scope for this fix.
558
+ return ok ? { valid: true } : { valid: false, penalize: true };
559
+ }
560
+ catch {
561
+ // Malformed signature bytes / key decode failure: reject, but do not penalize on unparseable input.
562
+ return { valid: false, penalize: false };
563
+ }
445
564
  }
446
565
  async getTransactionPhase(record) {
447
566
  const peerCount = Object.keys(record.peers).length;
@@ -479,8 +598,11 @@ export class ClusterMember {
479
598
  return count > total / 2;
480
599
  }
481
600
  async handlePromiseNeeded(record) {
482
- // Validate pend operations if we have a validator
483
- const validationResult = await this.validatePendOperations(record);
601
+ // Membership admission gate runs BEFORE pend validation: a member independently checks the declared
602
+ // peer set is a legitimate cluster it belongs to, and refuses (reject vote) rather than rubber-stamping
603
+ // a set the coordinator chose (e.g. a self-shrunk minority-partition set). On admission failure we skip
604
+ // pend validation entirely and emit the membership rejection.
605
+ const validationResult = await this.evaluatePromise(record);
484
606
  const promiseHash = await this.computePromiseHash(record);
485
607
  const type = validationResult.valid ? 'approve' : 'reject';
486
608
  const rejectReason = validationResult.valid ? undefined : validationResult.reason;
@@ -502,6 +624,154 @@ export class ClusterMember {
502
624
  }
503
625
  };
504
626
  }
627
+ /**
628
+ * The full promise-phase decision for a record: admit the declared membership FIRST, then (only if
629
+ * admitted) validate its pend operations. Failing either yields a `{ valid:false, reason }` the caller
630
+ * turns into a `reject` vote. Splitting membership from pend validation keeps the reason strings
631
+ * distinct — a `membership-not-admitted` reject is a different signal (feeds the dispute path) than a
632
+ * stale-revision / custom-validator reject.
633
+ */
634
+ async evaluatePromise(record) {
635
+ const admission = await this.admitMembership(record);
636
+ if (!admission.admit) {
637
+ return { valid: false, reason: admission.reason ?? MEMBERSHIP_NOT_ADMITTED };
638
+ }
639
+ return await this.validatePendOperations(record);
640
+ }
641
+ /**
642
+ * Membership admission gate. Decides whether the coordinator-declared peer set (`record.peers`, call it
643
+ * `D`) is a *legitimate* cluster this member may vote inside, judged against the member's OWN
644
+ * independently-derived view — not against anything the (untrusted) record declares about its size.
645
+ * Evaluated on the promise path before the member signs an approve.
646
+ *
647
+ * The predicate admits `D` iff ALL hold:
648
+ * 1. **Self-membership** — this member's id ∈ `D`; else this block is not its responsibility (and a
649
+ * coordinator must not route a record to a non-member to pad approval counts).
650
+ * 2. **Not a self-shrink below the floor** — with a confident derived view `E`, `|D| ≥ ⌈fraction·|E|⌉`
651
+ * (and ≥ minAbsoluteClusterSize). `|E|` is the member's own confident cluster-size estimate `K_est`,
652
+ * so a minority-partition set (small `D`) is rejected against the member's larger view.
653
+ * 3. **Consistency with the derived view** — `|D △ E|` within `clusterSizeTolerance·|E|`; honest churn
654
+ * of a peer or two is absorbed, a wholesale-disjoint or half-size set is not.
655
+ *
656
+ * **Fail-closed posture.** When the member cannot confidently derive `E` (no capability, low FRET
657
+ * confidence — exactly what a partition induces), it must refuse any *downsizing* decision: a
658
+ * below-full-size `D` is rejected against the configured full `clusterSize`. With NEITHER a confident
659
+ * view NOR a configured full size the gate cannot judge a downsize at all, so it preserves the legacy
660
+ * approve behavior (backward-compatible for nodes/tests with no derivation wired). `allowUnvalidatedSmallCluster`
661
+ * is the explicit opt-in (single-node / local dev knowingly below the safe floor), matching the
662
+ * coordinator's `validateSmallCluster` semantics.
663
+ */
664
+ async admitMembership(record) {
665
+ const ourId = this.peerId.toString();
666
+ const declared = Object.keys(record.peers ?? {});
667
+ // Predicate 1: self-membership. Always enforced (independent of any opt-in): a member does not vote
668
+ // in a cluster it is not part of.
669
+ if (!declared.includes(ourId)) {
670
+ log('cluster-member:admission-reject', { messageHash: record.messageHash, reason: 'self-not-member', declaredSize: declared.length });
671
+ return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:self-not-member` };
672
+ }
673
+ // Explicit opt-in: knowingly transact below the safe floor (single-node / local dev). Skips the
674
+ // size/consistency gates but not self-membership above.
675
+ if (this.allowUnvalidatedSmallCluster) {
676
+ return { admit: true };
677
+ }
678
+ const derived = await this.deriveExpectedClusterView(record);
679
+ // An empty derived view (kEst === 0) carries no usable reference set: measured against it every
680
+ // non-empty declared set is wholly "inconsistent" (maxDiff = ceil(tol·0) = 0), which would spuriously
681
+ // reject a legitimate full cluster — a stricter, worse outcome than an absent view. Treat empty as
682
+ // not-confident so it takes the fail-closed-or-legacy branch below instead. (Not normally reachable:
683
+ // a responsible member's findCluster includes at least itself; this guards a transient empty read.)
684
+ const derivedSize = derived !== undefined ? Object.keys(derived.peers ?? {}).length : 0;
685
+ const confident = derived !== undefined
686
+ && derived.confidence > ClusterMember.MembershipConfidenceThreshold
687
+ && derivedSize > 0;
688
+ if (!confident) {
689
+ // Fail closed for downsizing under low/absent confidence. A full-size (or larger) declared set is
690
+ // still admitted — there is nothing to shrink. Without a configured full-size reference we cannot
691
+ // tell a downsize from a legitimate small cluster, so we preserve legacy approve behavior.
692
+ if (this.configuredClusterSize === undefined) {
693
+ return { admit: true };
694
+ }
695
+ if (declared.length >= this.configuredClusterSize) {
696
+ return { admit: true };
697
+ }
698
+ log('cluster-member:admission-reject', {
699
+ messageHash: record.messageHash,
700
+ reason: 'low-confidence-downsize',
701
+ declaredSize: declared.length,
702
+ configuredClusterSize: this.configuredClusterSize,
703
+ confidence: derived?.confidence
704
+ });
705
+ return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:low-confidence-downsize` };
706
+ }
707
+ const expected = Object.keys(derived.peers ?? {});
708
+ const kEst = expected.length;
709
+ // Predicate 2: floor derived from the member's OWN confident estimate.
710
+ const floor = Math.max(this.minAbsoluteClusterSize, Math.ceil(this.membershipAdmissionFraction * kEst));
711
+ if (declared.length < floor) {
712
+ log('cluster-member:admission-reject', {
713
+ messageHash: record.messageHash,
714
+ reason: 'below-floor',
715
+ declaredSize: declared.length,
716
+ floor,
717
+ kEst
718
+ });
719
+ return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:below-floor` };
720
+ }
721
+ // Predicate 3: consistency with the derived view within tolerance.
722
+ const symmetricDiff = ClusterMember.symmetricDifferenceSize(declared, expected);
723
+ const maxDiff = Math.ceil(this.clusterSizeTolerance * kEst);
724
+ if (symmetricDiff > maxDiff) {
725
+ log('cluster-member:admission-reject', {
726
+ messageHash: record.messageHash,
727
+ reason: 'inconsistent-with-derived-view',
728
+ declaredSize: declared.length,
729
+ kEst,
730
+ symmetricDiff,
731
+ maxDiff
732
+ });
733
+ return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:inconsistent-with-derived-view` };
734
+ }
735
+ return { admit: true };
736
+ }
737
+ /**
738
+ * Derive this member's own view of the record's block cluster via the injected capability, or
739
+ * `undefined` when it cannot (no capability, no coordinating block id, or a derivation error — all of
740
+ * which the gate treats as "not confident"). Derived from the record's coordinating block, the same key
741
+ * the coordinator used to select the cluster.
742
+ */
743
+ async deriveExpectedClusterView(record) {
744
+ if (!this.deriveExpectedCluster) {
745
+ return undefined;
746
+ }
747
+ const blockId = record.coordinatingBlockIds?.[0];
748
+ if (blockId === undefined) {
749
+ return undefined;
750
+ }
751
+ try {
752
+ // NOTE: derives (findCluster) once per inbound record on the promise path — one routing lookup
753
+ // per vote. If this shows up as hot, cache the derived view per (blockId, short TTL): it is a
754
+ // pure read of current topology, so a few-seconds-stale view is safe for admission.
755
+ return await this.deriveExpectedCluster(blockId);
756
+ }
757
+ catch (err) {
758
+ log('cluster-member:derive-expected-cluster-error', { messageHash: record.messageHash, error: err.message });
759
+ return undefined;
760
+ }
761
+ }
762
+ /** |A △ B| over two id lists (order-independent set symmetric difference). */
763
+ static symmetricDifferenceSize(a, b) {
764
+ const setA = new Set(a);
765
+ const setB = new Set(b);
766
+ let count = 0;
767
+ for (const x of setA)
768
+ if (!setB.has(x))
769
+ count++;
770
+ for (const x of setB)
771
+ if (!setA.has(x))
772
+ count++;
773
+ return count;
774
+ }
505
775
  /**
506
776
  * Validates pend operations in a cluster record using the transaction validator.
507
777
  * Also checks for stale revisions to prevent consensus on operations that would fail.
@@ -566,9 +836,12 @@ export class ClusterMember {
566
836
  * @warning This method executes on ALL cluster peers, not just the coordinator.
567
837
  * Each peer independently applies the operations to its local storage.
568
838
  *
569
- * @pitfall **Check-then-act race** - Must check AND mark as executed atomically
570
- * (before any `await`) to prevent duplicate execution. JavaScript's single-threaded
571
- * nature makes synchronous check-and-set atomic.
839
+ * @pitfall **Check-then-act race** - The in-memory guard must be checked AND set
840
+ * atomically (before any `await`) to prevent duplicate execution; JavaScript's
841
+ * single-threaded nature makes that synchronous check-and-set atomic. The durable
842
+ * marker, by contrast, is persisted only *after* apply succeeds — writing it eagerly
843
+ * would leave a stuck marker on a caught fault or a crash mid-apply, silently dropping
844
+ * the transaction on this member on redelivery.
572
845
  *
573
846
  * @pitfall **Independent node storage** - Each node has its own storage. After consensus,
574
847
  * each node applies operations locally. Nodes must fetch missing blocks from cluster
@@ -577,31 +850,51 @@ export class ClusterMember {
577
850
  * @see docs/internals.md "Check-Then-Act Race in Consensus" and "Independent Node Storage" pitfalls
578
851
  */
579
852
  async handleConsensus(record) {
853
+ // Check persistent store first for post-recovery dedup (in-memory map is cleared on restart).
854
+ // wasTransactionExecutedAsync also checks the in-memory map as a fast path.
855
+ if (await this.wasTransactionExecutedAsync(record.messageHash)) {
856
+ log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
857
+ return;
858
+ }
580
859
  // Check-and-set ATOMICALLY to prevent race condition where multiple calls
581
- // pass the check before any completes. Since JavaScript is single-threaded,
860
+ // pass the async check before any completes. Since JavaScript is single-threaded,
582
861
  // this synchronous check-and-set is atomic before any await.
583
862
  if (this.executedTransactions.has(record.messageHash)) {
584
863
  log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
585
864
  return;
586
865
  }
587
- // Mark as executing IMMEDIATELY before any async operations
866
+ // Set the in-memory guard IMMEDIATELY, before any async operations: its synchronous
867
+ // check-and-set (line above's `has` + this `set`) is what prevents the concurrent
868
+ // apply-window race where two handleConsensus calls for the same hash both pass the
869
+ // async check. The durable marker is deliberately NOT written here — see below.
588
870
  const executedAt = Date.now();
589
871
  this.executedTransactions.set(record.messageHash, executedAt);
590
- this.stateStore?.markExecuted(record.messageHash, executedAt)
591
- .catch(err => log('cluster-member:persist-executed-error', { messageHash: record.messageHash, error: err.message }));
592
872
  try {
593
873
  for (const operation of record.message.operations) {
594
874
  await this.applyConsensusOperation(record, operation);
595
875
  }
596
876
  }
597
877
  catch (err) {
598
- // A genuinely unexpected fault (e.g. storage I/O) — roll back the executed
878
+ // A genuinely unexpected fault (e.g. storage I/O) — roll back the in-memory
599
879
  // marker so a corrected retry can re-run, and propagate so the caller learns
600
- // the real cause. Recoverable local divergence is absorbed inside
601
- // applyConsensusOperation and never reaches here.
880
+ // the real cause. The durable marker was never written (it lands only after
881
+ // apply succeeds, below), so there is nothing to roll back. Recoverable local
882
+ // divergence is absorbed inside applyConsensusOperation and never reaches here.
602
883
  this.executedTransactions.delete(record.messageHash);
603
884
  throw err;
604
885
  }
886
+ // Persist the durable marker only now that apply has actually succeeded. Writing it
887
+ // eagerly (before the loop) would leave a stuck marker on a caught fault OR a crash
888
+ // mid-apply, and on redelivery handleConsensus short-circuits at the async
889
+ // wasTransactionExecuted check — silently dropping the transaction on this member
890
+ // forever. The durable marker exists only for post-restart dedup (the in-memory map
891
+ // is empty after restart), and the narrow window between "apply succeeded" and
892
+ // "durable write landed" is safe to re-run on restart: re-applying an
893
+ // already-applied consensus transaction is idempotent (the "ahead" divergence path
894
+ // in applyConsensusOperation tolerates it as a no-op), so it converges rather than
895
+ // dropping. Fire-and-forget: a persist failure must not fail the apply that succeeded.
896
+ this.stateStore?.markExecuted(record.messageHash, executedAt)
897
+ .catch(err => log('cluster-member:persist-executed-error', { messageHash: record.messageHash, error: err.message }));
605
898
  }
606
899
  /**
607
900
  * Applies one consensus-approved operation to local storage.
@@ -940,22 +1233,89 @@ export class ClusterMember {
940
1233
  return false; // No blocking conflicts
941
1234
  }
942
1235
  /**
943
- * Resolve race between two conflicting transactions.
944
- * Transaction with more promises wins. If tied, higher hash wins.
1236
+ * Resolve a race between two conflicting transactions. Total and deterministic, so every honest
1237
+ * member computes the identical winner (the Theorem 1 Case-2 premise). Order:
1238
+ * 1. more promise signatures wins (progress monotonicity — see safety note below);
1239
+ * 2. equal promise counts → higher aged priority wins (fairness — see {@link recordPriority});
1240
+ * 3. still tied → higher message hash wins.
1241
+ *
1242
+ * Promise count is FIRST so this comparison never displaces a transaction that is further along.
1243
+ * That restores the pre-priority safety invariant: a member commits purely on promise supermajority
1244
+ * (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
1245
+ * conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
1246
+ * With promises-first, once transaction X holds a promise supermajority every conflicting rival Y has
1247
+ * strictly fewer promises — Y can only match X's count by getting the intersecting quorum member to
1248
+ * promise it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
1249
+ * `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
1250
+ * overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
1251
+ * Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
1252
+ * higher-priority Y with fewer promises, letting BOTH commit (split brain) — the regression fixed by
1253
+ * ticket occ-priority-first-breaks-promise-monotonicity.
1254
+ *
1255
+ * Priority is now a tie-break that runs only at EQUAL promise counts, which is exactly the
1256
+ * concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
1257
+ * on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
1258
+ * fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
1259
+ * defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
1260
+ * is the deferred feat-occ-priority-reservation).
1261
+ *
1262
+ * NOTE: residual-fairness tripwire. Under promises-first an aged transaction can still lose to a fresh
1263
+ * rival that has *legitimately* gathered even one more promise — that is not the pure-coin-flip
1264
+ * starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
1265
+ * more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
1266
+ * rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
1267
+ * to this race tie-break.
1268
+ *
1269
+ * NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
1270
+ * priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
1271
+ * exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
1272
+ * below the promise count, so it can only break equal-count ties it might have ~50% won anyway,
1273
+ * degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
1274
+ * honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
1275
+ *
1276
+ * NOTE: keep priority a self-contained additive message field + this one comparison key so it
1277
+ * composes with — does not block — a future HLC/crdt-sync redesign of this same path
1278
+ * (design-hot-log-tail-sharding-guidance).
945
1279
  */
946
1280
  resolveRace(existing, incoming) {
1281
+ // 1. Transaction with more promises wins — never displace a more-progressed rival (safety, see above).
947
1282
  const existingCount = Object.keys(existing.promises).length;
948
1283
  const incomingCount = Object.keys(incoming.promises).length;
949
- // Transaction with more promises wins
950
- if (existingCount > incomingCount) {
951
- return 'keep-existing';
1284
+ if (existingCount !== incomingCount) {
1285
+ return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
952
1286
  }
953
- if (incomingCount > existingCount) {
954
- return 'accept-incoming';
1287
+ // 2. Equal promise counts → higher aged priority wins (fairness tie-break).
1288
+ const existingPriority = this.recordPriority(existing);
1289
+ const incomingPriority = this.recordPriority(incoming);
1290
+ if (existingPriority !== incomingPriority) {
1291
+ return existingPriority > incomingPriority ? 'keep-existing' : 'accept-incoming';
955
1292
  }
956
- // Tie-breaker: higher message hash wins (deterministic)
1293
+ // 3. Tie-breaker: higher message hash wins (deterministic).
957
1294
  return existing.messageHash > incoming.messageHash ? 'keep-existing' : 'accept-incoming';
958
1295
  }
1296
+ /**
1297
+ * Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
1298
+ * The multi-collection path carries it on `pend.transaction.priority`; the single-collection
1299
+ * (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
1300
+ * legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
1301
+ * (backward compatible: such transactions simply never age). Both carriers live inside the signed
1302
+ * `message`, so priority is integrity-protected in transit; clamping here bounds a self-asserted
1303
+ * out-of-range value to the cap.
1304
+ *
1305
+ * NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
1306
+ * separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
1307
+ * the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
1308
+ * consulted at the promise decision (hasConflict), i.e. between two still-open conflicting
1309
+ * transactions, which is exactly the concurrent-contention case priority is meant to order.
1310
+ */
1311
+ recordPriority(record) {
1312
+ for (const op of record.message.operations) {
1313
+ if ('pend' in op) {
1314
+ return clampPriority(op.pend.transaction?.priority ?? op.pend.priority);
1315
+ }
1316
+ }
1317
+ return 0;
1318
+ }
959
1319
  operationsConflict(ops1, ops2) {
960
1320
  // Check if one is a commit for the same action as a pend - these don't conflict
961
1321
  const actionId1 = this.getActionId(ops1);
@@ -1026,7 +1386,7 @@ export class ClusterMember {
1026
1386
  promises.push(client.update(record));
1027
1387
  }
1028
1388
  catch (error) {
1029
- console.error(`Failed to propagate to peer ${peerId}:`, error);
1389
+ log('ERROR: Failed to propagate to peer %s: %o', peerId, error);
1030
1390
  }
1031
1391
  }
1032
1392
  await Promise.allSettled(promises);