@optimystic/db-p2p 0.14.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/README.md +616 -609
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +4 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +52 -3
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +129 -6
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts.map +1 -1
  10. package/dist/src/cluster/client.js +3 -1
  11. package/dist/src/cluster/client.js.map +1 -1
  12. package/dist/src/cluster/cluster-repo.d.ts +182 -11
  13. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  14. package/dist/src/cluster/cluster-repo.js +429 -69
  15. package/dist/src/cluster/cluster-repo.js.map +1 -1
  16. package/dist/src/cluster/peer-key-binding.d.ts +25 -0
  17. package/dist/src/cluster/peer-key-binding.d.ts.map +1 -0
  18. package/dist/src/cluster/peer-key-binding.js +39 -0
  19. package/dist/src/cluster/peer-key-binding.js.map +1 -0
  20. package/dist/src/cluster/quorum-restore.d.ts +72 -0
  21. package/dist/src/cluster/quorum-restore.d.ts.map +1 -0
  22. package/dist/src/cluster/quorum-restore.js +0 -0
  23. package/dist/src/cluster/quorum-restore.js.map +1 -0
  24. package/dist/src/cluster/rebalance-monitor.d.ts +23 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  26. package/dist/src/cluster/rebalance-monitor.js +17 -2
  27. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  28. package/dist/src/cluster/service.d.ts.map +1 -1
  29. package/dist/src/cluster/service.js +7 -1
  30. package/dist/src/cluster/service.js.map +1 -1
  31. package/dist/src/cluster/spread-on-churn.d.ts +7 -0
  32. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  33. package/dist/src/cluster/spread-on-churn.js +4 -1
  34. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  35. package/dist/src/cluster/supermajority-coupling.d.ts +22 -0
  36. package/dist/src/cluster/supermajority-coupling.d.ts.map +1 -0
  37. package/dist/src/cluster/supermajority-coupling.js +27 -0
  38. package/dist/src/cluster/supermajority-coupling.js.map +1 -0
  39. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +46 -4
  40. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -1
  41. package/dist/src/cohort-topic/cohort-gossip-driver.js +62 -7
  42. package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -1
  43. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +7 -0
  44. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -1
  45. package/dist/src/cohort-topic/cohort-gossip-transport.js +9 -0
  46. package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -1
  47. package/dist/src/cohort-topic/host.d.ts +197 -27
  48. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  49. package/dist/src/cohort-topic/host.js +643 -87
  50. package/dist/src/cohort-topic/host.js.map +1 -1
  51. package/dist/src/dispute/arbitrator-selection.d.ts +68 -7
  52. package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -1
  53. package/dist/src/dispute/arbitrator-selection.js +105 -16
  54. package/dist/src/dispute/arbitrator-selection.js.map +1 -1
  55. package/dist/src/dispute/cascade.d.ts +7 -0
  56. package/dist/src/dispute/cascade.d.ts.map +1 -1
  57. package/dist/src/dispute/cascade.js +1 -1
  58. package/dist/src/dispute/cascade.js.map +1 -1
  59. package/dist/src/dispute/client.d.ts.map +1 -1
  60. package/dist/src/dispute/client.js +3 -2
  61. package/dist/src/dispute/client.js.map +1 -1
  62. package/dist/src/dispute/dispute-service.d.ts +32 -3
  63. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  64. package/dist/src/dispute/dispute-service.js +82 -9
  65. package/dist/src/dispute/dispute-service.js.map +1 -1
  66. package/dist/src/dispute/index.d.ts +1 -1
  67. package/dist/src/dispute/index.d.ts.map +1 -1
  68. package/dist/src/dispute/index.js +1 -1
  69. package/dist/src/dispute/index.js.map +1 -1
  70. package/dist/src/dispute/invalidation.d.ts +25 -0
  71. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  72. package/dist/src/dispute/invalidation.js +14 -2
  73. package/dist/src/dispute/invalidation.js.map +1 -1
  74. package/dist/src/dispute/service.d.ts.map +1 -1
  75. package/dist/src/dispute/service.js +7 -1
  76. package/dist/src/dispute/service.js.map +1 -1
  77. package/dist/src/index.d.ts +4 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/index.js +4 -1
  80. package/dist/src/index.js.map +1 -1
  81. package/dist/src/it-utility.d.ts +1 -1
  82. package/dist/src/it-utility.d.ts.map +1 -1
  83. package/dist/src/it-utility.js +4 -13
  84. package/dist/src/it-utility.js.map +1 -1
  85. package/dist/src/libp2p-key-network.d.ts +98 -1
  86. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  87. package/dist/src/libp2p-key-network.js +272 -17
  88. package/dist/src/libp2p-key-network.js.map +1 -1
  89. package/dist/src/libp2p-node-base.d.ts +12 -0
  90. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  91. package/dist/src/libp2p-node-base.js +441 -89
  92. package/dist/src/libp2p-node-base.js.map +1 -1
  93. package/dist/src/matchmaking/query-transport.d.ts +2 -0
  94. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  95. package/dist/src/matchmaking/query-transport.js +6 -0
  96. package/dist/src/matchmaking/query-transport.js.map +1 -1
  97. package/dist/src/matchmaking/traffic-validation.js +1 -0
  98. package/dist/src/matchmaking/traffic-validation.js.map +1 -1
  99. package/dist/src/network/network-manager-service.d.ts +2 -2
  100. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  101. package/dist/src/network/network-manager-service.js +8 -4
  102. package/dist/src/network/network-manager-service.js.map +1 -1
  103. package/dist/src/owned-block-seed.d.ts +27 -0
  104. package/dist/src/owned-block-seed.d.ts.map +1 -0
  105. package/dist/src/owned-block-seed.js +37 -0
  106. package/dist/src/owned-block-seed.js.map +1 -0
  107. package/dist/src/protocol-client.d.ts +1 -0
  108. package/dist/src/protocol-client.d.ts.map +1 -1
  109. package/dist/src/protocol-client.js +9 -2
  110. package/dist/src/protocol-client.js.map +1 -1
  111. package/dist/src/protocol-limits.d.ts +32 -0
  112. package/dist/src/protocol-limits.d.ts.map +1 -0
  113. package/dist/src/protocol-limits.js +32 -0
  114. package/dist/src/protocol-limits.js.map +1 -0
  115. package/dist/src/repo/client.d.ts.map +1 -1
  116. package/dist/src/repo/client.js +3 -0
  117. package/dist/src/repo/client.js.map +1 -1
  118. package/dist/src/repo/cluster-coordinator.d.ts +41 -4
  119. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  120. package/dist/src/repo/cluster-coordinator.js +128 -80
  121. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  122. package/dist/src/repo/coordinator-repo.d.ts +38 -1
  123. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  124. package/dist/src/repo/coordinator-repo.js +92 -13
  125. package/dist/src/repo/coordinator-repo.js.map +1 -1
  126. package/dist/src/repo/service.d.ts.map +1 -1
  127. package/dist/src/repo/service.js +7 -1
  128. package/dist/src/repo/service.js.map +1 -1
  129. package/dist/src/reputation/peer-reputation.js +1 -1
  130. package/dist/src/reputation/peer-reputation.js.map +1 -1
  131. package/dist/src/reputation/types.d.ts +3 -1
  132. package/dist/src/reputation/types.d.ts.map +1 -1
  133. package/dist/src/reputation/types.js +3 -0
  134. package/dist/src/reputation/types.js.map +1 -1
  135. package/dist/src/rn.d.ts +5 -1
  136. package/dist/src/rn.d.ts.map +1 -1
  137. package/dist/src/rn.js +7 -1
  138. package/dist/src/rn.js.map +1 -1
  139. package/dist/src/storage/arachnode-fret-adapter.d.ts +30 -1
  140. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
  141. package/dist/src/storage/arachnode-fret-adapter.js +15 -2
  142. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
  143. package/dist/src/storage/arachnode-partition.d.ts +49 -0
  144. package/dist/src/storage/arachnode-partition.d.ts.map +1 -0
  145. package/dist/src/storage/arachnode-partition.js +61 -0
  146. package/dist/src/storage/arachnode-partition.js.map +1 -0
  147. package/dist/src/storage/block-storage.d.ts +40 -1
  148. package/dist/src/storage/block-storage.d.ts.map +1 -1
  149. package/dist/src/storage/block-storage.js +150 -57
  150. package/dist/src/storage/block-storage.js.map +1 -1
  151. package/dist/src/storage/i-block-storage.d.ts +8 -0
  152. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/i-raw-storage.d.ts +13 -0
  154. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  155. package/dist/src/storage/kv-raw-storage.d.ts +45 -0
  156. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -0
  157. package/dist/src/storage/kv-raw-storage.js +113 -0
  158. package/dist/src/storage/kv-raw-storage.js.map +1 -0
  159. package/dist/src/storage/memory-storage.d.ts +20 -58
  160. package/dist/src/storage/memory-storage.d.ts.map +1 -1
  161. package/dist/src/storage/memory-storage.js +22 -148
  162. package/dist/src/storage/memory-storage.js.map +1 -1
  163. package/dist/src/storage/memory-store-driver.d.ts +39 -0
  164. package/dist/src/storage/memory-store-driver.d.ts.map +1 -0
  165. package/dist/src/storage/memory-store-driver.js +129 -0
  166. package/dist/src/storage/memory-store-driver.js.map +1 -0
  167. package/dist/src/storage/raw-store-codec.d.ts +10 -0
  168. package/dist/src/storage/raw-store-codec.d.ts.map +1 -0
  169. package/dist/src/storage/raw-store-codec.js +36 -0
  170. package/dist/src/storage/raw-store-codec.js.map +1 -0
  171. package/dist/src/storage/raw-store-driver.d.ts +69 -0
  172. package/dist/src/storage/raw-store-driver.d.ts.map +1 -0
  173. package/dist/src/storage/raw-store-driver.js +2 -0
  174. package/dist/src/storage/raw-store-driver.js.map +1 -0
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +1 -1
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +1 -1
  178. package/dist/src/storage/restoration-coordinator.d.ts +73 -0
  179. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -0
  180. package/dist/src/storage/restoration-coordinator.js +181 -0
  181. package/dist/src/storage/restoration-coordinator.js.map +1 -0
  182. package/dist/src/storage/ring-selector.d.ts +92 -5
  183. package/dist/src/storage/ring-selector.d.ts.map +1 -1
  184. package/dist/src/storage/ring-selector.js +153 -51
  185. package/dist/src/storage/ring-selector.js.map +1 -1
  186. package/dist/src/storage/ring-shift-coordinator.d.ts +128 -0
  187. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -0
  188. package/dist/src/storage/ring-shift-coordinator.js +196 -0
  189. package/dist/src/storage/ring-shift-coordinator.js.map +1 -0
  190. package/dist/src/storage/storage-monitor.d.ts +28 -0
  191. package/dist/src/storage/storage-monitor.d.ts.map +1 -1
  192. package/dist/src/storage/storage-monitor.js +42 -2
  193. package/dist/src/storage/storage-monitor.js.map +1 -1
  194. package/dist/src/storage/storage-repo.d.ts +19 -0
  195. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  196. package/dist/src/storage/storage-repo.js +172 -22
  197. package/dist/src/storage/storage-repo.js.map +1 -1
  198. package/dist/src/sync/client.d.ts.map +1 -1
  199. package/dist/src/sync/client.js +5 -1
  200. package/dist/src/sync/client.js.map +1 -1
  201. package/dist/src/sync/service.d.ts.map +1 -1
  202. package/dist/src/sync/service.js +2 -1
  203. package/dist/src/sync/service.js.map +1 -1
  204. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +28 -4
  205. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  206. package/dist/src/testing/cohort-topic-mesh-harness.js +51 -10
  207. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  208. package/dist/src/testing/index.d.ts +1 -0
  209. package/dist/src/testing/index.d.ts.map +1 -1
  210. package/dist/src/testing/index.js +1 -0
  211. package/dist/src/testing/index.js.map +1 -1
  212. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  213. package/dist/src/testing/mesh-harness.js +15 -7
  214. package/dist/src/testing/mesh-harness.js.map +1 -1
  215. package/dist/src/testing/raw-storage-conformance.d.ts +24 -0
  216. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -0
  217. package/dist/src/testing/raw-storage-conformance.js +313 -0
  218. package/dist/src/testing/raw-storage-conformance.js.map +1 -0
  219. package/package.json +4 -4
  220. package/src/cluster/block-transfer-service.ts +4 -3
  221. package/src/cluster/block-transfer.ts +424 -265
  222. package/src/cluster/client.ts +3 -1
  223. package/src/cluster/cluster-repo.ts +481 -71
  224. package/src/cluster/peer-key-binding.ts +37 -0
  225. package/src/cluster/quorum-restore.ts +0 -0
  226. package/src/cluster/rebalance-monitor.ts +34 -3
  227. package/src/cluster/service.ts +7 -1
  228. package/src/cluster/spread-on-churn.ts +11 -1
  229. package/src/cluster/supermajority-coupling.ts +37 -0
  230. package/src/cohort-topic/cohort-gossip-driver.ts +96 -9
  231. package/src/cohort-topic/cohort-gossip-transport.ts +10 -0
  232. package/src/cohort-topic/host.ts +831 -105
  233. package/src/dispute/arbitrator-selection.ts +138 -28
  234. package/src/dispute/cascade.ts +8 -1
  235. package/src/dispute/client.ts +3 -2
  236. package/src/dispute/dispute-service.ts +94 -9
  237. package/src/dispute/index.ts +7 -1
  238. package/src/dispute/invalidation.ts +40 -2
  239. package/src/dispute/service.ts +7 -1
  240. package/src/index.ts +46 -43
  241. package/src/it-utility.ts +5 -13
  242. package/src/libp2p-key-network.ts +281 -18
  243. package/src/libp2p-node-base.ts +511 -81
  244. package/src/matchmaking/query-transport.ts +9 -0
  245. package/src/network/network-manager-service.ts +8 -2
  246. package/src/owned-block-seed.ts +40 -0
  247. package/src/protocol-client.ts +10 -3
  248. package/src/protocol-limits.ts +33 -0
  249. package/src/repo/client.ts +3 -0
  250. package/src/repo/cluster-coordinator.ts +145 -82
  251. package/src/repo/coordinator-repo.ts +96 -16
  252. package/src/repo/service.ts +7 -1
  253. package/src/reputation/types.ts +3 -0
  254. package/src/rn.ts +7 -1
  255. package/src/storage/arachnode-fret-adapter.ts +33 -2
  256. package/src/storage/arachnode-partition.ts +74 -0
  257. package/src/storage/block-storage.ts +169 -61
  258. package/src/storage/i-block-storage.ts +9 -0
  259. package/src/storage/i-raw-storage.ts +14 -0
  260. package/src/storage/kv-raw-storage.ts +135 -0
  261. package/src/storage/memory-storage.ts +25 -173
  262. package/src/storage/memory-store-driver.ts +157 -0
  263. package/src/storage/raw-store-codec.ts +42 -0
  264. package/src/storage/raw-store-driver.ts +80 -0
  265. package/src/storage/{restoration-coordinator-v2.ts → restoration-coordinator.ts} +43 -37
  266. package/src/storage/ring-selector.ts +317 -176
  267. package/src/storage/ring-shift-coordinator.ts +271 -0
  268. package/src/storage/storage-monitor.ts +70 -3
  269. package/src/storage/storage-repo.ts +710 -558
  270. package/src/sync/client.ts +5 -1
  271. package/src/sync/service.ts +2 -1
  272. package/src/testing/cohort-topic-mesh-harness.ts +61 -11
  273. package/src/testing/index.ts +1 -0
  274. package/src/testing/mesh-harness.ts +14 -6
  275. package/src/testing/raw-storage-conformance.ts +397 -0
@@ -1,15 +1,15 @@
1
- import type { IRepo, ClusterRecord, Signature, RepoMessage, ITransactionValidator, ClusterConsensusConfig, CommitResult, BlockId, ActionId, ActionRev, CommitRequest, CommitCert, InvalidateRequest } from "@optimystic/db-core";
1
+ import type { IRepo, ClusterRecord, ClusterPeers, Signature, RepoMessage, ITransactionValidator, ClusterConsensusConfig, CommitResult, BlockId, ActionId, ActionRev, CommitRequest, CommitCert, InvalidateRequest } from "@optimystic/db-core";
2
2
  import type { ICluster } from "@optimystic/db-core";
3
3
  import type { IPeerNetwork } from "@optimystic/db-core";
4
- import { blockIdsForTransforms } from "@optimystic/db-core";
4
+ import { blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
5
+ import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority } from "@optimystic/db-core";
5
6
  import { verifyInvalidationCertificate, type ArbitratorSetRecompute } from "../dispute/invalidation.js";
6
7
  import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
7
8
  import { ClusterClient } from "./client.js";
8
9
  import type { PeerId, PrivateKey } from "@libp2p/interface";
9
10
  import { peerIdFromString } from "@libp2p/peer-id";
10
11
  import { publicKeyFromRaw } from "@libp2p/crypto/keys";
11
- import { sha256 } from "multiformats/hashes/sha2";
12
- import { base58btc } from "multiformats/bases/base58";
12
+ import { peerIdBindsPublicKey } from "./peer-key-binding.js";
13
13
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
14
14
  import { createLogger } from '../logger.js'
15
15
  import type { PartitionDetector } from "./partition-detector.js";
@@ -37,6 +37,18 @@ interface TransactionState {
37
37
  lastUpdate: number;
38
38
  }
39
39
 
40
+ /**
41
+ * Result of verifying one vote signature. `penalize` distinguishes "identity was never proven"
42
+ * (no key / not Ed25519 / key not bound to the peer id / malformed input — reject, but do NOT
43
+ * report the named peer, whose id may have been attacker-chosen) from "the key IS the one the
44
+ * peer id names, yet the signature does not verify" (reject AND report — a genuine bad vote from
45
+ * that proven identity). Collapsing both into a bare `false` would let a coordinator get an honest
46
+ * peer penalized just by attaching a key it controls under that peer's id.
47
+ */
48
+ type VerifyOutcome =
49
+ | { valid: true }
50
+ | { valid: false; penalize: boolean };
51
+
40
52
  /**
41
53
  * Actively reconciles a block this member committed without having seen the matching
42
54
  * pend (cohort drift between the independent pend and commit cluster-transactions).
@@ -85,6 +97,33 @@ export type InvalidationApplySink = (request: InvalidateRequest) => Promise<void
85
97
  */
86
98
  export type RecomputeArbitratorSetCapability = ArbitratorSetRecompute;
87
99
 
100
+ /**
101
+ * The member's own independently-derived view of a block's responsible cluster, plus FRET's confidence in
102
+ * the underlying network-size estimate (0..1). Returned by {@link DeriveExpectedClusterCallback} and
103
+ * consumed by {@link ClusterMember}'s membership admission gate: the member checks a coordinator-declared
104
+ * peer set against `peers` (its expected set `E`) and gates on `confidence` (low confidence ⇒ fail closed
105
+ * for any downsizing, the partition posture).
106
+ */
107
+ export type ExpectedClusterView = {
108
+ /** The member's own derived responsible-peer set for the block (its view of the legitimate cluster). */
109
+ peers: ClusterPeers;
110
+ /** FRET's confidence in the current network-size estimate (0..1); ≤ threshold ⇒ treated as untrusted. */
111
+ confidence: number;
112
+ };
113
+
114
+ /**
115
+ * Independently derive this member's own view of a block's responsible cluster. Injected so
116
+ * {@link ClusterMember} stays transport-agnostic — the composition root supplies it from
117
+ * `IKeyNetwork.findCluster` + FRET (mirroring how the coordinator derives the cluster). Absent on nodes
118
+ * that cannot derive a view (no FRET, unit tests): with no derived view AND no configured full-size
119
+ * reference the gate preserves legacy approve behavior, but a configured `clusterSize` still lets the gate
120
+ * fail closed on an unjustified downsize. See {@link ClusterMember} admission gate.
121
+ */
122
+ export type DeriveExpectedClusterCallback = (blockId: BlockId) => Promise<ExpectedClusterView>;
123
+
124
+ /** Stable reject reason a member emits when a declared peer set fails the membership admission gate. */
125
+ export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
126
+
88
127
  interface ClusterMemberComponents {
89
128
  storageRepo: IRepo;
90
129
  peerNetwork: IPeerNetwork;
@@ -106,6 +145,8 @@ interface ClusterMemberComponents {
106
145
  onInvalidate?: InvalidationApplySink;
107
146
  /** Layer-2 arbitrator-set recompute for invalidation verification; see {@link RecomputeArbitratorSetCapability}. */
108
147
  recomputeArbitratorSet?: RecomputeArbitratorSetCapability;
148
+ /** Member-side cluster derivation for the membership admission gate; see {@link DeriveExpectedClusterCallback}. */
149
+ deriveExpectedCluster?: DeriveExpectedClusterCallback;
109
150
  }
110
151
 
111
152
  export function clusterMember(components: ClusterMemberComponents): ClusterMember {
@@ -124,7 +165,8 @@ export function clusterMember(components: ClusterMemberComponents): ClusterMembe
124
165
  components.reconcileBlock,
125
166
  components.onCommitCertificate,
126
167
  components.onInvalidate,
127
- components.recomputeArbitratorSet
168
+ components.recomputeArbitratorSet,
169
+ components.deriveExpectedCluster
128
170
  );
129
171
  }
130
172
 
@@ -168,8 +210,28 @@ export class ClusterMember implements ICluster {
168
210
  private readonly expirationInterval: NodeJS.Timeout;
169
211
  private readonly cleanupInterval: NodeJS.Timeout;
170
212
 
171
- /** Effective super-majority threshold. Defaults to 1.0 (unanimity) for backward compatibility. */
213
+ /**
214
+ * Confidence floor at/below which FRET's network-size view is treated as untrustworthy for the
215
+ * membership gate. Above it the member trusts its derived view (confident path); at/below it the gate
216
+ * fails closed for downsizing. Matches the coordinator's `validateSmallCluster` confidence gate (> 0.5).
217
+ */
218
+ private static readonly MembershipConfidenceThreshold = 0.5;
219
+
220
+ /**
221
+ * Effective super-majority threshold this member accepts as sufficient for a commit. Defaults to
222
+ * {@link DEFAULT_SUPER_MAJORITY_THRESHOLD} (0.75) when no config is supplied — the SAME default the
223
+ * coordinator uses, so the two can never silently disagree about whether a transaction is final.
224
+ * (Previously defaulted to 1.0/unanimity, which split the member from a coordinator committing at 0.75.)
225
+ */
172
226
  private readonly superMajorityThreshold: number;
227
+ // Membership admission gate parameters (see {@link admitMembership}). Read once from consensusConfig
228
+ // so the gate has stable thresholds independent of the (untrusted) values a record declares.
229
+ private readonly minAbsoluteClusterSize: number;
230
+ private readonly clusterSizeTolerance: number;
231
+ private readonly membershipAdmissionFraction: number;
232
+ /** Configured full cluster size, or undefined when unknown (then the gate cannot judge a downsize). */
233
+ private readonly configuredClusterSize: number | undefined;
234
+ private readonly allowUnvalidatedSmallCluster: boolean;
173
235
 
174
236
  constructor(
175
237
  private readonly storageRepo: IRepo,
@@ -187,9 +249,15 @@ export class ClusterMember implements ICluster {
187
249
  private readonly reconcileBlock?: ReconcileBlockCallback,
188
250
  private readonly onCommitCertificate?: CommitCertificateSink,
189
251
  private readonly onInvalidate?: InvalidationApplySink,
190
- private readonly recomputeArbitratorSet?: RecomputeArbitratorSetCapability
252
+ private readonly recomputeArbitratorSet?: RecomputeArbitratorSetCapability,
253
+ private readonly deriveExpectedCluster?: DeriveExpectedClusterCallback
191
254
  ) {
192
- this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? 1.0;
255
+ this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD;
256
+ this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
257
+ this.clusterSizeTolerance = consensusConfig?.clusterSizeTolerance ?? 0.5;
258
+ this.membershipAdmissionFraction = consensusConfig?.membershipAdmissionFraction ?? 0.75;
259
+ this.configuredClusterSize = consensusConfig?.clusterSize;
260
+ this.allowUnvalidatedSmallCluster = consensusConfig?.allowUnvalidatedSmallCluster ?? false;
193
261
  // Periodically clean up expired transactions (.unref() so tests/short-lived processes can exit)
194
262
  this.expirationInterval = setInterval(() => this.queueExpiredTransactions(), 60000);
195
263
  this.expirationInterval.unref();
@@ -198,6 +266,16 @@ export class ClusterMember implements ICluster {
198
266
  this.cleanupInterval.unref();
199
267
  }
200
268
 
269
+ /**
270
+ * The resolved super-majority threshold this member runs on. Exposed so the composition root can
271
+ * fail-fast if the member and the coordinator would run different thresholds (see the coupling
272
+ * assertion in `libp2p-node-base.ts`) — a mismatch is a latent phase-disagreement, caught at startup
273
+ * rather than mid-consensus.
274
+ */
275
+ get effectiveSuperMajorityThreshold(): number {
276
+ return this.superMajorityThreshold;
277
+ }
278
+
201
279
  /**
202
280
  * Clears all interval and timeout handles and empties active state.
203
281
  * Called during node shutdown to prevent leaked timers.
@@ -338,10 +416,7 @@ export class ClusterMember implements ICluster {
338
416
  log('cluster-member:action-consensus-after-commit', {
339
417
  messageHash: record.messageHash
340
418
  });
341
- // Check persistent store for post-recovery dedup before synchronous guard
342
- if (!await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
343
- await this.handleConsensus(currentRecord);
344
- }
419
+ await this.handleConsensus(currentRecord);
345
420
  }
346
421
  }
347
422
  shouldPersist = false;
@@ -350,12 +425,7 @@ export class ClusterMember implements ICluster {
350
425
  log('cluster-member:action-consensus', {
351
426
  messageHash: record.messageHash
352
427
  });
353
- // Check persistent store for post-recovery dedup before synchronous guard
354
- if (await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
355
- log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
356
- } else {
357
- await this.handleConsensus(currentRecord);
358
- }
428
+ await this.handleConsensus(currentRecord);
359
429
  // Don't call clearTransaction here - it happens in handleConsensus
360
430
  shouldPersist = false;
361
431
  break;
@@ -432,10 +502,32 @@ export class ClusterMember implements ICluster {
432
502
  if (existing.messageHash !== incoming.messageHash) {
433
503
  throw new Error('Message hash mismatch');
434
504
  }
435
- if (JSON.stringify(existing.message) !== JSON.stringify(incoming.message)) {
505
+ if (ClusterMember.canonicalJson(existing.message) !== ClusterMember.canonicalJson(incoming.message)) {
436
506
  throw new Error('Message content mismatch');
437
507
  }
438
- if (JSON.stringify(existing.peers) !== JSON.stringify(incoming.peers)) {
508
+ if (existing.membershipVersion === 2 || incoming.membershipVersion === 2) {
509
+ // v2: the sorted peer-id set (captured by membershipDigest) is bound into messageHash, so equal
510
+ // messageHash MUST imply equal membership on any honest path. A mismatch here — different digest
511
+ // or version at equal hash — is a protocol violation (a bug or a hash-collision attack), NOT an
512
+ // honest divergence: two honest members with different views now hold two DIFFERENT hashes, i.e.
513
+ // two competing transactions the race machinery resolves, not one contested record. Log loudly
514
+ // and reject; never silently adopt the incoming set. (validateRecord already proved each record's
515
+ // own digest matches its own peers, so multiaddr / pubkey churn within the SAME id set — which
516
+ // keeps the same digest and hash — does NOT trip this.)
517
+ if (existing.membershipVersion !== incoming.membershipVersion || existing.membershipDigest !== incoming.membershipDigest) {
518
+ log('cluster-member:peers-mismatch-invariant-violation', {
519
+ messageHash: existing.messageHash,
520
+ existingVersion: existing.membershipVersion,
521
+ incomingVersion: incoming.membershipVersion,
522
+ existingDigest: existing.membershipDigest,
523
+ incomingDigest: incoming.membershipDigest,
524
+ existingPeers: Object.keys(existing.peers ?? {}).sort(),
525
+ incomingPeers: Object.keys(incoming.peers ?? {}).sort()
526
+ });
527
+ throw new Error('Peers mismatch');
528
+ }
529
+ } else if (ClusterMember.canonicalJson(existing.peers) !== ClusterMember.canonicalJson(incoming.peers)) {
530
+ // v1 (legacy, membership unbound): full peer-object equality is the only available guard.
439
531
  throw new Error('Peers mismatch');
440
532
  }
441
533
 
@@ -466,6 +558,10 @@ export class ClusterMember implements ICluster {
466
558
  phase: 'promise' | 'commit',
467
559
  messageHash: string
468
560
  ): Record<string, Signature> {
561
+ // NOTE: relies on validateSignatures() (via validateRecord in processUpdate) having already run on
562
+ // every record reaching here, so each peerId's signature is key-bound. Without that guarantee the
563
+ // Equivocation penalty below would act on self-asserted, unverified peer ids and could frame an
564
+ // honest peer. Do not call this on unvalidated signatures.
469
565
  const merged = { ...existing };
470
566
 
471
567
  for (const [peerId, incomingSig] of Object.entries(incoming)) {
@@ -498,8 +594,27 @@ export class ClusterMember implements ICluster {
498
594
  }
499
595
 
500
596
  private async validateRecord(record: ClusterRecord): Promise<void> {
501
- // Validate message hash matches the message content
502
- const expectedHash = await this.computeMessageHash(record.message);
597
+ // Reject a record whose membership-binding version this code does not implement. The cluster
598
+ // consensus code is a single deployable unit (all cluster members upgrade together), so a version
599
+ // we don't understand is rejected rather than cross-version-consensus'd.
600
+ if (record.membershipVersion !== undefined && record.membershipVersion !== 1 && record.membershipVersion !== 2) {
601
+ throw new Error(`Unsupported membershipVersion: ${record.membershipVersion}`);
602
+ }
603
+
604
+ // v2: the declared membership digest must match the record's own peer set. A record whose declared
605
+ // digest doesn't match its peers is malformed (and its messageHash — computed over that digest —
606
+ // would not bind the real membership).
607
+ // NOTE: recomputes membershipDigest (one SHA256 over the sorted peer-id list) on every incoming v2
608
+ // record; if a hot cluster ever shows this as a cost, memoize per (messageHash → digest).
609
+ if (record.membershipVersion === 2) {
610
+ const expectedDigest = await membershipDigest(record.peers);
611
+ if (expectedDigest !== record.membershipDigest) {
612
+ throw new Error(`Membership digest mismatch: expected=${expectedDigest}, received=${record.membershipDigest ?? 'undefined'}`);
613
+ }
614
+ }
615
+
616
+ // Validate message hash matches the message content (v2 folds in the membership digest)
617
+ const expectedHash = await this.computeMessageHash(record);
503
618
  if (expectedHash !== record.messageHash) {
504
619
  throw new Error(`Message hash mismatch: expected=${expectedHash}, received=${record.messageHash}`);
505
620
  }
@@ -514,21 +629,25 @@ export class ClusterMember implements ICluster {
514
629
  }
515
630
 
516
631
  /**
517
- * Compute message hash using the same algorithm as the coordinator.
518
- * Must match cluster-coordinator.ts createMessageHash().
632
+ * Compute message hash using the same algorithm as the coordinator. Version-dispatched: a v2 record
633
+ * folds its membership digest into the preimage, a v1 / unversioned record hashes byte-identically to
634
+ * before this change. Must match cluster-coordinator.ts createMessageHash().
519
635
  */
520
- private async computeMessageHash(message: RepoMessage): Promise<string> {
521
- const msgBytes = new TextEncoder().encode(ClusterMember.canonicalJson(message));
522
- const hashBytes = await sha256.digest(msgBytes);
523
- return base58btc.encode(hashBytes.digest);
636
+ private async computeMessageHash(record: Pick<ClusterRecord, 'message' | 'membershipVersion' | 'membershipDigest'>): Promise<string> {
637
+ return computeClusterMessageHash(record.message, recordMembershipDigest(record));
524
638
  }
525
639
 
526
640
  private async validateSignatures(record: ClusterRecord): Promise<void> {
527
- // Validate promise signatures
641
+ // Validate promise signatures. Reject on any failure, but only report an InvalidSignature
642
+ // penalty when the key was proven to belong to `peerId` (outcome.penalize) — otherwise the id
643
+ // is attacker-chosen and reporting it would let a coordinator frame an honest peer.
528
644
  const promiseHash = await this.computePromiseHash(record);
529
645
  for (const [peerId, signature] of Object.entries(record.promises)) {
530
- if (!await this.verifySignature(record, peerId, promiseHash, signature)) {
531
- this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `promise:${record.messageHash}`);
646
+ const outcome = await this.verifySignature(record, peerId, promiseHash, signature);
647
+ if (!outcome.valid) {
648
+ if (outcome.penalize) {
649
+ this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `promise:${record.messageHash}`);
650
+ }
532
651
  throw new Error(`Invalid promise signature from ${peerId}`);
533
652
  }
534
653
  }
@@ -536,14 +655,23 @@ export class ClusterMember implements ICluster {
536
655
  // Validate commit signatures
537
656
  const commitHash = await this.computeCommitHash(record);
538
657
  for (const [peerId, signature] of Object.entries(record.commits)) {
539
- if (!await this.verifySignature(record, peerId, commitHash, signature)) {
540
- this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `commit:${record.messageHash}`);
658
+ const outcome = await this.verifySignature(record, peerId, commitHash, signature);
659
+ if (!outcome.valid) {
660
+ if (outcome.penalize) {
661
+ this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `commit:${record.messageHash}`);
662
+ }
541
663
  throw new Error(`Invalid commit signature from ${peerId}`);
542
664
  }
543
665
  }
544
666
  }
545
667
 
546
- /** Deterministic JSON: sorts object keys so hash is order-independent */
668
+ /**
669
+ * Deterministic JSON: sorts object keys so comparisons are order-independent.
670
+ * NOTE: this is a second copy of the canonicalization in db-core `membership.ts` (which feeds the hash
671
+ * preimages). It is used here only for equality checks (message-content / v1-peers compare), and those
672
+ * run *after* a messageHash-equality gate, so a drift between the two can't silently forge agreement —
673
+ * but keep them byte-identical. If a third caller appears, promote to a single exported helper.
674
+ */
547
675
  private static canonicalJson(value: unknown): string {
548
676
  return JSON.stringify(value, (_, v) =>
549
677
  v && typeof v === 'object' && !Array.isArray(v)
@@ -553,15 +681,11 @@ export class ClusterMember implements ICluster {
553
681
  }
554
682
 
555
683
  private async computePromiseHash(record: ClusterRecord): Promise<string> {
556
- const msgBytes = new TextEncoder().encode(record.messageHash + ClusterMember.canonicalJson(record.message));
557
- const hashBytes = await sha256.digest(msgBytes);
558
- return uint8ArrayToString(hashBytes.digest, 'base64url');
684
+ return computeClusterPromiseHash(record.messageHash, record.message, recordMembershipDigest(record));
559
685
  }
560
686
 
561
687
  private async computeCommitHash(record: ClusterRecord): Promise<string> {
562
- const msgBytes = new TextEncoder().encode(record.messageHash + ClusterMember.canonicalJson(record.message) + ClusterMember.canonicalJson(record.promises));
563
- const hashBytes = await sha256.digest(msgBytes);
564
- return uint8ArrayToString(hashBytes.digest, 'base64url');
688
+ return computeClusterCommitHash(record.messageHash, record.message, record.promises, recordMembershipDigest(record));
565
689
  }
566
690
 
567
691
  private computeSigningPayload(hash: string, type: string, rejectReason?: string): Uint8Array {
@@ -575,17 +699,53 @@ export class ClusterMember implements ICluster {
575
699
  return uint8ArrayToString(sigBytes, 'base64url');
576
700
  }
577
701
 
578
- private async verifySignature(record: ClusterRecord, peerId: string, hash: string, signature: Signature): Promise<boolean> {
702
+ /**
703
+ * Verify one vote signature and classify the outcome (see {@link VerifyOutcome}). Total on hostile
704
+ * input: a missing/empty key, a non-Ed25519 id, a key not bound to `peerId`, or malformed bytes all
705
+ * yield `{ valid:false, penalize:false }` (reject without penalizing an unproven identity) rather
706
+ * than throwing. Only after the key is proven to be the one `peerId` names does a failed
707
+ * cryptographic verify yield `{ valid:false, penalize:true }`.
708
+ *
709
+ * NOTE: the binding check (`peerIdBindsPublicKey`) proves the vote was signed by the key `peerId`
710
+ * names — it does NOT establish that `peerId` is legitimately in the cohort. A coordinator minting
711
+ * fresh keypairs and using each key's own derived id passes this for every one. Sybil/cohort
712
+ * membership is a separate layer (cohort-topic membership certificates), not solved here.
713
+ */
714
+ private async verifySignature(record: ClusterRecord, peerId: string, hash: string, signature: Signature): Promise<VerifyOutcome> {
579
715
  const peerInfo = record.peers[peerId];
580
716
  if (!peerInfo?.publicKey?.length) {
581
- throw new Error(`No public key for peer ${peerId}`);
582
- }
583
- // publicKey is base64url-encoded string (JSON-serialization safe)
584
- const keyBytes = uint8ArrayFromString(peerInfo.publicKey, 'base64url');
585
- const pubKey = publicKeyFromRaw(keyBytes);
586
- const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
587
- const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
588
- return pubKey.verify(payload, sigBytes);
717
+ // No key to check against — identity not proven. Reject without penalty.
718
+ return { valid: false, penalize: false };
719
+ }
720
+ let keyBytes: Uint8Array;
721
+ try {
722
+ // publicKey is base64url-encoded string (JSON-serialization safe)
723
+ keyBytes = uint8ArrayFromString(peerInfo.publicKey, 'base64url');
724
+ } catch {
725
+ return { valid: false, penalize: false };
726
+ }
727
+ // The key must be the one `peerId` provably names, else the vote could be attributed to any peer
728
+ // id while signed by a key the coordinator controls. Binding failure ⇒ identity unproven ⇒ no penalty.
729
+ if (!peerIdBindsPublicKey(peerId, keyBytes)) {
730
+ return { valid: false, penalize: false };
731
+ }
732
+ try {
733
+ const pubKey = publicKeyFromRaw(keyBytes);
734
+ const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
735
+ const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
736
+ const ok = await pubKey.verify(payload, sigBytes);
737
+ // Key is bound to peerId: a failed verify is a genuine bad vote from a proven identity → penalize.
738
+ // NOTE: residual — an Ed25519 peer's public key is derivable from its (public) id, so an attacker
739
+ // can attach a victim's REAL key with a garbage signature and still trip this InvalidSignature
740
+ // penalty on the victim. Binding narrows framing (the attacker must use the victim's own key, not
741
+ // an arbitrary one) but cannot eliminate it here: a single signature can't distinguish "victim
742
+ // signed badly" from "someone pasted the victim's public key + junk". Fully closing it needs an
743
+ // authenticated membership/channel layer (cohort-topic membership certs), out of scope for this fix.
744
+ return ok ? { valid: true } : { valid: false, penalize: true };
745
+ } catch {
746
+ // Malformed signature bytes / key decode failure: reject, but do not penalize on unparseable input.
747
+ return { valid: false, penalize: false };
748
+ }
589
749
  }
590
750
 
591
751
  private async getTransactionPhase(record: ClusterRecord): Promise<TransactionPhase> {
@@ -633,8 +793,11 @@ export class ClusterMember implements ICluster {
633
793
  }
634
794
 
635
795
  private async handlePromiseNeeded(record: ClusterRecord): Promise<ClusterRecord> {
636
- // Validate pend operations if we have a validator
637
- const validationResult = await this.validatePendOperations(record);
796
+ // Membership admission gate runs BEFORE pend validation: a member independently checks the declared
797
+ // peer set is a legitimate cluster it belongs to, and refuses (reject vote) rather than rubber-stamping
798
+ // a set the coordinator chose (e.g. a self-shrunk minority-partition set). On admission failure we skip
799
+ // pend validation entirely and emit the membership rejection.
800
+ const validationResult = await this.evaluatePromise(record);
638
801
 
639
802
  const promiseHash = await this.computePromiseHash(record);
640
803
  const type = validationResult.valid ? 'approve' as const : 'reject' as const;
@@ -661,6 +824,161 @@ export class ClusterMember implements ICluster {
661
824
  };
662
825
  }
663
826
 
827
+ /**
828
+ * The full promise-phase decision for a record: admit the declared membership FIRST, then (only if
829
+ * admitted) validate its pend operations. Failing either yields a `{ valid:false, reason }` the caller
830
+ * turns into a `reject` vote. Splitting membership from pend validation keeps the reason strings
831
+ * distinct — a `membership-not-admitted` reject is a different signal (feeds the dispute path) than a
832
+ * stale-revision / custom-validator reject.
833
+ */
834
+ private async evaluatePromise(record: ClusterRecord): Promise<{ valid: boolean; reason?: string }> {
835
+ const admission = await this.admitMembership(record);
836
+ if (!admission.admit) {
837
+ return { valid: false, reason: admission.reason ?? MEMBERSHIP_NOT_ADMITTED };
838
+ }
839
+ return await this.validatePendOperations(record);
840
+ }
841
+
842
+ /**
843
+ * Membership admission gate. Decides whether the coordinator-declared peer set (`record.peers`, call it
844
+ * `D`) is a *legitimate* cluster this member may vote inside, judged against the member's OWN
845
+ * independently-derived view — not against anything the (untrusted) record declares about its size.
846
+ * Evaluated on the promise path before the member signs an approve.
847
+ *
848
+ * The predicate admits `D` iff ALL hold:
849
+ * 1. **Self-membership** — this member's id ∈ `D`; else this block is not its responsibility (and a
850
+ * coordinator must not route a record to a non-member to pad approval counts).
851
+ * 2. **Not a self-shrink below the floor** — with a confident derived view `E`, `|D| ≥ ⌈fraction·|E|⌉`
852
+ * (and ≥ minAbsoluteClusterSize). `|E|` is the member's own confident cluster-size estimate `K_est`,
853
+ * so a minority-partition set (small `D`) is rejected against the member's larger view.
854
+ * 3. **Consistency with the derived view** — `|D △ E|` within `clusterSizeTolerance·|E|`; honest churn
855
+ * of a peer or two is absorbed, a wholesale-disjoint or half-size set is not.
856
+ *
857
+ * **Fail-closed posture.** When the member cannot confidently derive `E` (no capability, low FRET
858
+ * confidence — exactly what a partition induces), it must refuse any *downsizing* decision: a
859
+ * below-full-size `D` is rejected against the configured full `clusterSize`. With NEITHER a confident
860
+ * view NOR a configured full size the gate cannot judge a downsize at all, so it preserves the legacy
861
+ * approve behavior (backward-compatible for nodes/tests with no derivation wired). `allowUnvalidatedSmallCluster`
862
+ * is the explicit opt-in (single-node / local dev knowingly below the safe floor), matching the
863
+ * coordinator's `validateSmallCluster` semantics.
864
+ */
865
+ private async admitMembership(record: ClusterRecord): Promise<{ admit: boolean; reason?: string }> {
866
+ const ourId = this.peerId.toString();
867
+ const declared = Object.keys(record.peers ?? {});
868
+
869
+ // Predicate 1: self-membership. Always enforced (independent of any opt-in): a member does not vote
870
+ // in a cluster it is not part of.
871
+ if (!declared.includes(ourId)) {
872
+ log('cluster-member:admission-reject', { messageHash: record.messageHash, reason: 'self-not-member', declaredSize: declared.length });
873
+ return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:self-not-member` };
874
+ }
875
+
876
+ // Explicit opt-in: knowingly transact below the safe floor (single-node / local dev). Skips the
877
+ // size/consistency gates but not self-membership above.
878
+ if (this.allowUnvalidatedSmallCluster) {
879
+ return { admit: true };
880
+ }
881
+
882
+ const derived = await this.deriveExpectedClusterView(record);
883
+ // An empty derived view (kEst === 0) carries no usable reference set: measured against it every
884
+ // non-empty declared set is wholly "inconsistent" (maxDiff = ceil(tol·0) = 0), which would spuriously
885
+ // reject a legitimate full cluster — a stricter, worse outcome than an absent view. Treat empty as
886
+ // not-confident so it takes the fail-closed-or-legacy branch below instead. (Not normally reachable:
887
+ // a responsible member's findCluster includes at least itself; this guards a transient empty read.)
888
+ const derivedSize = derived !== undefined ? Object.keys(derived.peers ?? {}).length : 0;
889
+ const confident = derived !== undefined
890
+ && derived.confidence > ClusterMember.MembershipConfidenceThreshold
891
+ && derivedSize > 0;
892
+
893
+ if (!confident) {
894
+ // Fail closed for downsizing under low/absent confidence. A full-size (or larger) declared set is
895
+ // still admitted — there is nothing to shrink. Without a configured full-size reference we cannot
896
+ // tell a downsize from a legitimate small cluster, so we preserve legacy approve behavior.
897
+ if (this.configuredClusterSize === undefined) {
898
+ return { admit: true };
899
+ }
900
+ if (declared.length >= this.configuredClusterSize) {
901
+ return { admit: true };
902
+ }
903
+ log('cluster-member:admission-reject', {
904
+ messageHash: record.messageHash,
905
+ reason: 'low-confidence-downsize',
906
+ declaredSize: declared.length,
907
+ configuredClusterSize: this.configuredClusterSize,
908
+ confidence: derived?.confidence
909
+ });
910
+ return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:low-confidence-downsize` };
911
+ }
912
+
913
+ const expected = Object.keys(derived!.peers ?? {});
914
+ const kEst = expected.length;
915
+
916
+ // Predicate 2: floor derived from the member's OWN confident estimate.
917
+ const floor = Math.max(this.minAbsoluteClusterSize, Math.ceil(this.membershipAdmissionFraction * kEst));
918
+ if (declared.length < floor) {
919
+ log('cluster-member:admission-reject', {
920
+ messageHash: record.messageHash,
921
+ reason: 'below-floor',
922
+ declaredSize: declared.length,
923
+ floor,
924
+ kEst
925
+ });
926
+ return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:below-floor` };
927
+ }
928
+
929
+ // Predicate 3: consistency with the derived view within tolerance.
930
+ const symmetricDiff = ClusterMember.symmetricDifferenceSize(declared, expected);
931
+ const maxDiff = Math.ceil(this.clusterSizeTolerance * kEst);
932
+ if (symmetricDiff > maxDiff) {
933
+ log('cluster-member:admission-reject', {
934
+ messageHash: record.messageHash,
935
+ reason: 'inconsistent-with-derived-view',
936
+ declaredSize: declared.length,
937
+ kEst,
938
+ symmetricDiff,
939
+ maxDiff
940
+ });
941
+ return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:inconsistent-with-derived-view` };
942
+ }
943
+
944
+ return { admit: true };
945
+ }
946
+
947
+ /**
948
+ * Derive this member's own view of the record's block cluster via the injected capability, or
949
+ * `undefined` when it cannot (no capability, no coordinating block id, or a derivation error — all of
950
+ * which the gate treats as "not confident"). Derived from the record's coordinating block, the same key
951
+ * the coordinator used to select the cluster.
952
+ */
953
+ private async deriveExpectedClusterView(record: ClusterRecord): Promise<ExpectedClusterView | undefined> {
954
+ if (!this.deriveExpectedCluster) {
955
+ return undefined;
956
+ }
957
+ const blockId = record.coordinatingBlockIds?.[0];
958
+ if (blockId === undefined) {
959
+ return undefined;
960
+ }
961
+ try {
962
+ // NOTE: derives (findCluster) once per inbound record on the promise path — one routing lookup
963
+ // per vote. If this shows up as hot, cache the derived view per (blockId, short TTL): it is a
964
+ // pure read of current topology, so a few-seconds-stale view is safe for admission.
965
+ return await this.deriveExpectedCluster(blockId as BlockId);
966
+ } catch (err) {
967
+ log('cluster-member:derive-expected-cluster-error', { messageHash: record.messageHash, error: (err as Error).message });
968
+ return undefined;
969
+ }
970
+ }
971
+
972
+ /** |A △ B| over two id lists (order-independent set symmetric difference). */
973
+ private static symmetricDifferenceSize(a: string[], b: string[]): number {
974
+ const setA = new Set(a);
975
+ const setB = new Set(b);
976
+ let count = 0;
977
+ for (const x of setA) if (!setB.has(x)) count++;
978
+ for (const x of setB) if (!setA.has(x)) count++;
979
+ return count;
980
+ }
981
+
664
982
  /**
665
983
  * Validates pend operations in a cluster record using the transaction validator.
666
984
  * Also checks for stale revisions to prevent consensus on operations that would fail.
@@ -731,9 +1049,12 @@ export class ClusterMember implements ICluster {
731
1049
  * @warning This method executes on ALL cluster peers, not just the coordinator.
732
1050
  * Each peer independently applies the operations to its local storage.
733
1051
  *
734
- * @pitfall **Check-then-act race** - Must check AND mark as executed atomically
735
- * (before any `await`) to prevent duplicate execution. JavaScript's single-threaded
736
- * nature makes synchronous check-and-set atomic.
1052
+ * @pitfall **Check-then-act race** - The in-memory guard must be checked AND set
1053
+ * atomically (before any `await`) to prevent duplicate execution; JavaScript's
1054
+ * single-threaded nature makes that synchronous check-and-set atomic. The durable
1055
+ * marker, by contrast, is persisted only *after* apply succeeds — writing it eagerly
1056
+ * would leave a stuck marker on a caught fault or a crash mid-apply, silently dropping
1057
+ * the transaction on this member on redelivery.
737
1058
  *
738
1059
  * @pitfall **Independent node storage** - Each node has its own storage. After consensus,
739
1060
  * each node applies operations locally. Nodes must fetch missing blocks from cluster
@@ -742,31 +1063,52 @@ export class ClusterMember implements ICluster {
742
1063
  * @see docs/internals.md "Check-Then-Act Race in Consensus" and "Independent Node Storage" pitfalls
743
1064
  */
744
1065
  private async handleConsensus(record: ClusterRecord): Promise<void> {
1066
+ // Check persistent store first for post-recovery dedup (in-memory map is cleared on restart).
1067
+ // wasTransactionExecutedAsync also checks the in-memory map as a fast path.
1068
+ if (await this.wasTransactionExecutedAsync(record.messageHash)) {
1069
+ log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
1070
+ return;
1071
+ }
745
1072
  // Check-and-set ATOMICALLY to prevent race condition where multiple calls
746
- // pass the check before any completes. Since JavaScript is single-threaded,
1073
+ // pass the async check before any completes. Since JavaScript is single-threaded,
747
1074
  // this synchronous check-and-set is atomic before any await.
748
1075
  if (this.executedTransactions.has(record.messageHash)) {
749
1076
  log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
750
1077
  return;
751
1078
  }
752
- // Mark as executing IMMEDIATELY before any async operations
1079
+ // Set the in-memory guard IMMEDIATELY, before any async operations: its synchronous
1080
+ // check-and-set (line above's `has` + this `set`) is what prevents the concurrent
1081
+ // apply-window race where two handleConsensus calls for the same hash both pass the
1082
+ // async check. The durable marker is deliberately NOT written here — see below.
753
1083
  const executedAt = Date.now();
754
1084
  this.executedTransactions.set(record.messageHash, executedAt);
755
- this.stateStore?.markExecuted(record.messageHash, executedAt)
756
- .catch(err => log('cluster-member:persist-executed-error', { messageHash: record.messageHash, error: (err as Error).message }));
757
1085
 
758
1086
  try {
759
1087
  for (const operation of record.message.operations) {
760
1088
  await this.applyConsensusOperation(record, operation);
761
1089
  }
762
1090
  } catch (err) {
763
- // A genuinely unexpected fault (e.g. storage I/O) — roll back the executed
1091
+ // A genuinely unexpected fault (e.g. storage I/O) — roll back the in-memory
764
1092
  // marker so a corrected retry can re-run, and propagate so the caller learns
765
- // the real cause. Recoverable local divergence is absorbed inside
766
- // applyConsensusOperation and never reaches here.
1093
+ // the real cause. The durable marker was never written (it lands only after
1094
+ // apply succeeds, below), so there is nothing to roll back. Recoverable local
1095
+ // divergence is absorbed inside applyConsensusOperation and never reaches here.
767
1096
  this.executedTransactions.delete(record.messageHash);
768
1097
  throw err;
769
1098
  }
1099
+
1100
+ // Persist the durable marker only now that apply has actually succeeded. Writing it
1101
+ // eagerly (before the loop) would leave a stuck marker on a caught fault OR a crash
1102
+ // mid-apply, and on redelivery handleConsensus short-circuits at the async
1103
+ // wasTransactionExecuted check — silently dropping the transaction on this member
1104
+ // forever. The durable marker exists only for post-restart dedup (the in-memory map
1105
+ // is empty after restart), and the narrow window between "apply succeeded" and
1106
+ // "durable write landed" is safe to re-run on restart: re-applying an
1107
+ // already-applied consensus transaction is idempotent (the "ahead" divergence path
1108
+ // in applyConsensusOperation tolerates it as a no-op), so it converges rather than
1109
+ // dropping. Fire-and-forget: a persist failure must not fail the apply that succeeded.
1110
+ this.stateStore?.markExecuted(record.messageHash, executedAt)
1111
+ .catch(err => log('cluster-member:persist-executed-error', { messageHash: record.messageHash, error: (err as Error).message }));
770
1112
  }
771
1113
 
772
1114
  /**
@@ -1136,25 +1478,93 @@ export class ClusterMember implements ICluster {
1136
1478
  }
1137
1479
 
1138
1480
  /**
1139
- * Resolve race between two conflicting transactions.
1140
- * Transaction with more promises wins. If tied, higher hash wins.
1481
+ * Resolve a race between two conflicting transactions. Total and deterministic, so every honest
1482
+ * member computes the identical winner (the Theorem 1 Case-2 premise). Order:
1483
+ * 1. more promise signatures wins (progress monotonicity — see safety note below);
1484
+ * 2. equal promise counts → higher aged priority wins (fairness — see {@link recordPriority});
1485
+ * 3. still tied → higher message hash wins.
1486
+ *
1487
+ * Promise count is FIRST so this comparison never displaces a transaction that is further along.
1488
+ * That restores the pre-priority safety invariant: a member commits purely on promise supermajority
1489
+ * (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
1490
+ * conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
1491
+ * With promises-first, once transaction X holds a promise supermajority every conflicting rival Y has
1492
+ * strictly fewer promises — Y can only match X's count by getting the intersecting quorum member to
1493
+ * promise it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
1494
+ * `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
1495
+ * overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
1496
+ * Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
1497
+ * higher-priority Y with fewer promises, letting BOTH commit (split brain) — the regression fixed by
1498
+ * ticket occ-priority-first-breaks-promise-monotonicity.
1499
+ *
1500
+ * Priority is now a tie-break that runs only at EQUAL promise counts, which is exactly the
1501
+ * concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
1502
+ * on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
1503
+ * fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
1504
+ * defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
1505
+ * is the deferred feat-occ-priority-reservation).
1506
+ *
1507
+ * NOTE: residual-fairness tripwire. Under promises-first an aged transaction can still lose to a fresh
1508
+ * rival that has *legitimately* gathered even one more promise — that is not the pure-coin-flip
1509
+ * starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
1510
+ * more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
1511
+ * rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
1512
+ * to this race tie-break.
1513
+ *
1514
+ * NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
1515
+ * priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
1516
+ * exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
1517
+ * below the promise count, so it can only break equal-count ties it might have ~50% won anyway,
1518
+ * degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
1519
+ * honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
1520
+ *
1521
+ * NOTE: keep priority a self-contained additive message field + this one comparison key so it
1522
+ * composes with — does not block — a future HLC/crdt-sync redesign of this same path
1523
+ * (design-hot-log-tail-sharding-guidance).
1141
1524
  */
1142
1525
  private resolveRace(existing: ClusterRecord, incoming: ClusterRecord): 'keep-existing' | 'accept-incoming' {
1526
+ // 1. Transaction with more promises wins — never displace a more-progressed rival (safety, see above).
1143
1527
  const existingCount = Object.keys(existing.promises).length;
1144
1528
  const incomingCount = Object.keys(incoming.promises).length;
1145
-
1146
- // Transaction with more promises wins
1147
- if (existingCount > incomingCount) {
1148
- return 'keep-existing';
1529
+ if (existingCount !== incomingCount) {
1530
+ return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
1149
1531
  }
1150
- if (incomingCount > existingCount) {
1151
- return 'accept-incoming';
1532
+
1533
+ // 2. Equal promise counts → higher aged priority wins (fairness tie-break).
1534
+ const existingPriority = this.recordPriority(existing);
1535
+ const incomingPriority = this.recordPriority(incoming);
1536
+ if (existingPriority !== incomingPriority) {
1537
+ return existingPriority > incomingPriority ? 'keep-existing' : 'accept-incoming';
1152
1538
  }
1153
1539
 
1154
- // Tie-breaker: higher message hash wins (deterministic)
1540
+ // 3. Tie-breaker: higher message hash wins (deterministic).
1155
1541
  return existing.messageHash > incoming.messageHash ? 'keep-existing' : 'accept-incoming';
1156
1542
  }
1157
1543
 
1544
+ /**
1545
+ * Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
1546
+ * The multi-collection path carries it on `pend.transaction.priority`; the single-collection
1547
+ * (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
1548
+ * legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
1549
+ * (backward compatible: such transactions simply never age). Both carriers live inside the signed
1550
+ * `message`, so priority is integrity-protected in transit; clamping here bounds a self-asserted
1551
+ * out-of-range value to the cap.
1552
+ *
1553
+ * NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
1554
+ * separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
1555
+ * the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
1556
+ * consulted at the promise decision (hasConflict), i.e. between two still-open conflicting
1557
+ * transactions, which is exactly the concurrent-contention case priority is meant to order.
1558
+ */
1559
+ private recordPriority(record: ClusterRecord): number {
1560
+ for (const op of record.message.operations) {
1561
+ if ('pend' in op) {
1562
+ return clampPriority(op.pend.transaction?.priority ?? op.pend.priority);
1563
+ }
1564
+ }
1565
+ return 0;
1566
+ }
1567
+
1158
1568
  private operationsConflict(ops1: RepoMessage['operations'], ops2: RepoMessage['operations']): boolean {
1159
1569
  // Check if one is a commit for the same action as a pend - these don't conflict
1160
1570
  const actionId1 = this.getActionId(ops1);
@@ -1226,7 +1636,7 @@ export class ClusterMember implements ICluster {
1226
1636
  const client = ClusterClient.create(peerIdFromString(peerId), this.peerNetwork, this.protocolPrefix);
1227
1637
  promises.push(client.update(record));
1228
1638
  } catch (error) {
1229
- console.error(`Failed to propagate to peer ${peerId}:`, error);
1639
+ log('ERROR: Failed to propagate to peer %s: %o', peerId, error);
1230
1640
  }
1231
1641
  }
1232
1642
  await Promise.allSettled(promises);