@optimystic/db-p2p 0.22.0 → 0.24.1

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 (194) hide show
  1. package/dist/src/cluster/client.d.ts +10 -0
  2. package/dist/src/cluster/client.d.ts.map +1 -1
  3. package/dist/src/cluster/client.js +30 -1
  4. package/dist/src/cluster/client.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +39 -14
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +226 -119
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/service.d.ts +32 -1
  10. package/dist/src/cluster/service.d.ts.map +1 -1
  11. package/dist/src/cluster/service.js +43 -2
  12. package/dist/src/cluster/service.js.map +1 -1
  13. package/dist/src/cohort-topic/host.js +34 -11
  14. package/dist/src/cohort-topic/host.js.map +1 -1
  15. package/dist/src/cohort-topic/stream-util.d.ts +37 -7
  16. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  17. package/dist/src/cohort-topic/stream-util.js +77 -19
  18. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  19. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  20. package/dist/src/dispute/dispute-service.js +9 -3
  21. package/dist/src/dispute/dispute-service.js.map +1 -1
  22. package/dist/src/index.d.ts +3 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +3 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/libp2p-key-network.d.ts +88 -2
  27. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  28. package/dist/src/libp2p-key-network.js +134 -28
  29. package/dist/src/libp2p-key-network.js.map +1 -1
  30. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  31. package/dist/src/libp2p-node-base.js +25 -1
  32. package/dist/src/libp2p-node-base.js.map +1 -1
  33. package/dist/src/logger.d.ts +17 -1
  34. package/dist/src/logger.d.ts.map +1 -1
  35. package/dist/src/logger.js +19 -2
  36. package/dist/src/logger.js.map +1 -1
  37. package/dist/src/matchmaking/query-transport.js +3 -3
  38. package/dist/src/matchmaking/query-transport.js.map +1 -1
  39. package/dist/src/owned-block-seed.d.ts +6 -3
  40. package/dist/src/owned-block-seed.d.ts.map +1 -1
  41. package/dist/src/owned-block-seed.js +16 -3
  42. package/dist/src/owned-block-seed.js.map +1 -1
  43. package/dist/src/peer-address-book.d.ts +72 -0
  44. package/dist/src/peer-address-book.d.ts.map +1 -0
  45. package/dist/src/peer-address-book.js +123 -0
  46. package/dist/src/peer-address-book.js.map +1 -0
  47. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  48. package/dist/src/reactivity/notify-transport.js +6 -6
  49. package/dist/src/reactivity/notify-transport.js.map +1 -1
  50. package/dist/src/reactivity/push-state-gossip.js +2 -2
  51. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  52. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  53. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  54. package/dist/src/reactivity/recover-transport.js +7 -3
  55. package/dist/src/reactivity/recover-transport.js.map +1 -1
  56. package/dist/src/repo/client.d.ts.map +1 -1
  57. package/dist/src/repo/client.js +11 -2
  58. package/dist/src/repo/client.js.map +1 -1
  59. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  60. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  61. package/dist/src/repo/cluster-coordinator.js +95 -3
  62. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  63. package/dist/src/repo/coordinator-repo.d.ts +62 -9
  64. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  65. package/dist/src/repo/coordinator-repo.js +242 -73
  66. package/dist/src/repo/coordinator-repo.js.map +1 -1
  67. package/dist/src/rn.d.ts +3 -0
  68. package/dist/src/rn.d.ts.map +1 -1
  69. package/dist/src/rn.js +3 -0
  70. package/dist/src/rn.js.map +1 -1
  71. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  72. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  73. package/dist/src/storage/cached-raw-storage.js +152 -0
  74. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  75. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  76. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  77. package/dist/src/storage/cached-store-driver.js +775 -0
  78. package/dist/src/storage/cached-store-driver.js.map +1 -0
  79. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  80. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  81. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  82. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  83. package/dist/src/storage/shared-cache-pool.js +354 -0
  84. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  85. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  86. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  87. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  88. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  89. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  90. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  91. package/dist/src/testing/raw-storage-conformance.js +35 -2
  92. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  93. package/package.json +3 -3
  94. package/readme.md +668 -668
  95. package/src/cluster/block-transfer.ts +424 -424
  96. package/src/cluster/client.ts +119 -88
  97. package/src/cluster/cluster-error.ts +64 -64
  98. package/src/cluster/cluster-policy.ts +203 -203
  99. package/src/cluster/cluster-repo.ts +242 -122
  100. package/src/cluster/cluster-size-coupling.ts +45 -45
  101. package/src/cluster/commit-cert.ts +139 -139
  102. package/src/cluster/i-transaction-state-store.ts +43 -43
  103. package/src/cluster/memory-transaction-state-store.ts +56 -56
  104. package/src/cluster/peer-key-binding.ts +37 -37
  105. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  106. package/src/cluster/quorum-restore.ts +223 -223
  107. package/src/cluster/reconcile-block.ts +203 -203
  108. package/src/cluster/service.ts +293 -241
  109. package/src/cluster/supermajority-coupling.ts +37 -37
  110. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  111. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  112. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  113. package/src/cohort-topic/change-bridge.ts +109 -109
  114. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  115. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  116. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  117. package/src/cohort-topic/host.ts +42 -11
  118. package/src/cohort-topic/index.ts +13 -13
  119. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  120. package/src/cohort-topic/membership-source.ts +68 -68
  121. package/src/cohort-topic/peer-codec.ts +31 -31
  122. package/src/cohort-topic/peer-sig.ts +86 -86
  123. package/src/cohort-topic/protocols.ts +71 -71
  124. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  125. package/src/cohort-topic/size-estimator.ts +16 -16
  126. package/src/cohort-topic/stream-util.ts +79 -19
  127. package/src/cohort-topic/threshold-crypto.ts +239 -239
  128. package/src/cohort-topic/topic-router.ts +77 -77
  129. package/src/dispute/arbitrator-selection.ts +138 -138
  130. package/src/dispute/cascade.ts +524 -524
  131. package/src/dispute/dispute-service.ts +11 -5
  132. package/src/dispute/invalidation.ts +625 -625
  133. package/src/inbound-authorization.ts +190 -190
  134. package/src/index.ts +52 -49
  135. package/src/libp2p-key-network.ts +1120 -990
  136. package/src/libp2p-node-base.ts +1675 -1651
  137. package/src/libp2p-node-rn.ts +30 -30
  138. package/src/libp2p-node.ts +36 -36
  139. package/src/logger.ts +19 -2
  140. package/src/matchmaking/aggregate-counts.ts +104 -104
  141. package/src/matchmaking/index.ts +20 -20
  142. package/src/matchmaking/module.ts +363 -363
  143. package/src/matchmaking/protocols.ts +51 -51
  144. package/src/matchmaking/provider-manager.ts +95 -95
  145. package/src/matchmaking/query-handler.ts +88 -88
  146. package/src/matchmaking/query-transport.ts +3 -3
  147. package/src/matchmaking/seeker-manager.ts +64 -64
  148. package/src/matchmaking/seeker-walk-client.ts +293 -293
  149. package/src/matchmaking/traffic-validation.ts +195 -195
  150. package/src/optimystic-node.ts +36 -36
  151. package/src/owned-block-seed.ts +53 -40
  152. package/src/peer-address-book.ts +149 -0
  153. package/src/protocol-limits.ts +33 -33
  154. package/src/reactivity/forwarder-host.ts +438 -438
  155. package/src/reactivity/index.ts +19 -19
  156. package/src/reactivity/notify-transport.ts +144 -144
  157. package/src/reactivity/origination-manager.ts +192 -192
  158. package/src/reactivity/protocols.ts +61 -61
  159. package/src/reactivity/push-state-gossip.ts +291 -291
  160. package/src/reactivity/recover-transport.ts +7 -3
  161. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  162. package/src/reactivity/subscriber-registry.ts +96 -96
  163. package/src/reactivity/subscription-manager.ts +450 -450
  164. package/src/reactivity/topic-bytes.ts +37 -37
  165. package/src/repo/client.ts +12 -2
  166. package/src/repo/cluster-coordinator.ts +99 -3
  167. package/src/repo/coordinator-repo.ts +281 -74
  168. package/src/repo/types.ts +7 -7
  169. package/src/rn.ts +39 -36
  170. package/src/rpc-deadline.ts +45 -45
  171. package/src/storage/arachnode-partition.ts +74 -74
  172. package/src/storage/cached-raw-storage.ts +180 -0
  173. package/src/storage/cached-store-driver.ts +859 -0
  174. package/src/storage/i-kv-store.ts +8 -8
  175. package/src/storage/i-raw-storage.ts +12 -5
  176. package/src/storage/kv-raw-storage.ts +135 -135
  177. package/src/storage/memory-kv-store.ts +28 -28
  178. package/src/storage/memory-storage.ts +25 -25
  179. package/src/storage/memory-store-driver.ts +157 -157
  180. package/src/storage/raw-store-codec.ts +42 -42
  181. package/src/storage/raw-store-driver.ts +80 -80
  182. package/src/storage/ring-selector.ts +317 -317
  183. package/src/storage/ring-shift-coordinator.ts +271 -271
  184. package/src/storage/shared-cache-pool.ts +452 -0
  185. package/src/storage/storage-repo.ts +1014 -1014
  186. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
  187. package/src/testing/index.ts +8 -8
  188. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  189. package/src/testing/raw-storage-conformance.ts +453 -417
  190. package/src/testing/reactivity-mesh-harness.ts +922 -922
  191. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  192. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  193. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  194. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -2,7 +2,7 @@ import type { IRepo, ClusterRecord, ClusterPeers, Signature, RepoMessage, ITrans
2
2
  import type { ICluster } from "@optimystic/db-core";
3
3
  import type { IPeerNetwork } from "@optimystic/db-core";
4
4
  import { blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
5
- import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority } from "@optimystic/db-core";
5
+ import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority, clusterVoteSigningPayload, clusterVoteVerificationPayload } from "@optimystic/db-core";
6
6
  import { verifyInvalidationCertificate, type ArbitratorSetRecompute } from "../dispute/invalidation.js";
7
7
  import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
8
8
  import { ClusterClient } from "./client.js";
@@ -24,14 +24,27 @@ const log = createLogger('cluster-member')
24
24
 
25
25
  /** State of a transaction in the cluster */
26
26
  enum TransactionPhase {
27
- Promising, // Collecting promises from peers
27
+ Promising, // We have voted; still collecting promises from other peers
28
28
  OurPromiseNeeded, // We need to provide our promise
29
+ OurConflictVoteNeeded, // We hold a conflicting race winner; we must answer with a conflict vote
29
30
  OurCommitNeeded, // We need to provide our commit
30
31
  Consensus, // Transaction has reached consensus
31
- Rejected, // Transaction was rejected
32
+ Rejected, // Transaction was rejected (validity judgement — enough reject votes)
33
+ ConflictSuperseded, // Terminal but retryable: conflict votes make super-majority unreachable
32
34
  Propagating // Transaction is being propagated
33
35
  }
34
36
 
37
+ /**
38
+ * A phase plus the data its handler needs. Only {@link TransactionPhase.OurConflictVoteNeeded}
39
+ * carries any — `conflictsWith`, the winning rival's messageHash — so the conflict-vote handler need
40
+ * not re-run conflict detection (whose race resolution has side effects) to learn what blocked it.
41
+ * Split by phase rather than an optional field so the handler reads it without an assertion, and so
42
+ * a future phase-with-data cannot silently inherit this one's payload.
43
+ */
44
+ type PhaseResult =
45
+ | { phase: TransactionPhase.OurConflictVoteNeeded; conflictsWith: string }
46
+ | { phase: Exclude<TransactionPhase, TransactionPhase.OurConflictVoteNeeded> };
47
+
35
48
  interface TransactionState {
36
49
  record: ClusterRecord;
37
50
  promiseTimeout?: NodeJS.Timeout;
@@ -391,79 +404,104 @@ export class ClusterMember implements ICluster {
391
404
  });
392
405
  }
393
406
 
394
- // Get the current transaction state
395
- const phase = await this.getTransactionPhase(currentRecord);
396
- log('cluster-member:phase', {
397
- messageHash: record.messageHash,
398
- phase,
399
- promises: Object.keys(currentRecord.promises ?? {}),
400
- commits: Object.keys(currentRecord.commits ?? {})
401
- });
407
+ // Drive the phase machine to a FIXPOINT rather than handling one phase per delivery. Each
408
+ // vote this member adds can put the record straight into the next phase (our promise
409
+ // completes super-majority ⇒ our commit is due; our commit completes the majority ⇒
410
+ // consensus; our reject/conflict vote makes the record terminal), and any follow-on phase
411
+ // not re-handled here would silently wait for the coordinator's next delivery — e.g. a
412
+ // member whose promise the coordinator never collected receives the commit-phase record,
413
+ // adds its promise, and must then also commit in the SAME delivery. One loop replaces the
414
+ // hand-written per-branch re-checks that used to cover only the follow-ons their authors
415
+ // thought of. Only the three vote-adding phases continue; each strictly grows the record
416
+ // (adds a promise or commit key), so the loop terminates — the cap guards a
417
+ // phase-computation bug, not a real bound.
418
+ const MaxPhaseSteps = 8;
402
419
  let shouldPersist = true;
403
-
404
- // Handle the transaction based on its state
405
- switch (phase) {
406
- case TransactionPhase.OurPromiseNeeded:
407
- log('cluster-member:action-promise', {
408
- messageHash: record.messageHash
409
- });
410
- currentRecord = await this.handlePromiseNeeded(currentRecord);
411
- log('cluster-member:action-promise-complete', {
412
- messageHash: record.messageHash,
413
- promises: Object.keys(currentRecord.promises ?? {})
414
- });
415
- break;
416
- case TransactionPhase.OurCommitNeeded:
417
- log('cluster-member:action-commit', {
418
- messageHash: record.messageHash
419
- });
420
- currentRecord = await this.handleCommitNeeded(currentRecord);
421
- log('cluster-member:action-commit-complete', {
422
- messageHash: record.messageHash,
423
- commits: Object.keys(currentRecord.commits ?? {})
424
- });
425
- // After adding our commit, check if we now have consensus and execute if so
426
- {
427
- const newPhase = await this.getTransactionPhase(currentRecord);
428
- if (newPhase === TransactionPhase.Consensus) {
429
- log('cluster-member:action-consensus-after-commit', {
430
- messageHash: record.messageHash
431
- });
432
- await this.handleConsensus(currentRecord);
433
- }
434
- }
435
- shouldPersist = false;
436
- break;
437
- case TransactionPhase.Consensus:
438
- log('cluster-member:action-consensus', {
439
- messageHash: record.messageHash
440
- });
441
- await this.handleConsensus(currentRecord);
442
- // Don't call clearTransaction here - it happens in handleConsensus
443
- shouldPersist = false;
444
- break;
445
- case TransactionPhase.Rejected:
446
- log('cluster-member:action-rejected', {
447
- messageHash: record.messageHash
448
- });
449
- // Don't call clearTransaction here - it happens in handleRejection
450
- await this.handleRejection(currentRecord);
451
- shouldPersist = false;
452
- break;
453
- case TransactionPhase.Propagating:
454
- // Transaction is complete and propagating - clean it up
455
- log('cluster-member:phase-propagating', {
456
- messageHash: record.messageHash
457
- });
458
- shouldPersist = false;
459
- break;
460
- case TransactionPhase.Promising:
461
- // Still collecting promises from peers - if we haven't added ours and there's no conflict, add it
462
- // This state shouldn't normally be reached since OurPromiseNeeded is checked first
463
- log('cluster-member:phase-promising-blocked', {
464
- messageHash: record.messageHash
465
- });
420
+ phaseLoop: for (let step = 0; ; step++) {
421
+ if (step >= MaxPhaseSteps) {
422
+ log('cluster-member:phase-loop-overflow', { messageHash: record.messageHash, steps: step });
466
423
  break;
424
+ }
425
+ const phaseResult = await this.getTransactionPhase(currentRecord);
426
+ log('cluster-member:phase', {
427
+ messageHash: record.messageHash,
428
+ phase: phaseResult.phase,
429
+ step,
430
+ promises: Object.keys(currentRecord.promises ?? {}),
431
+ commits: Object.keys(currentRecord.commits ?? {})
432
+ });
433
+ switch (phaseResult.phase) {
434
+ case TransactionPhase.OurPromiseNeeded:
435
+ log('cluster-member:action-promise', {
436
+ messageHash: record.messageHash
437
+ });
438
+ currentRecord = await this.handlePromiseNeeded(currentRecord);
439
+ log('cluster-member:action-promise-complete', {
440
+ messageHash: record.messageHash,
441
+ promises: Object.keys(currentRecord.promises ?? {})
442
+ });
443
+ // Our own vote can be terminal (a reject where maxAllowedRejections is 0) or complete
444
+ // the super-majority — recompute rather than guess which.
445
+ continue;
446
+ case TransactionPhase.OurConflictVoteNeeded:
447
+ currentRecord = await this.handleConflictVoteNeeded(currentRecord, phaseResult.conflictsWith);
448
+ // Never persist a record we conflict-voted: this member holds the WINNER, and
449
+ // persisting the loser would reserve the same blocks a second time — half of what
450
+ // made the silent-abstention failure self-sustaining.
451
+ shouldPersist = false;
452
+ continue;
453
+ case TransactionPhase.OurCommitNeeded:
454
+ log('cluster-member:action-commit', {
455
+ messageHash: record.messageHash
456
+ });
457
+ currentRecord = await this.handleCommitNeeded(currentRecord);
458
+ log('cluster-member:action-commit-complete', {
459
+ messageHash: record.messageHash,
460
+ commits: Object.keys(currentRecord.commits ?? {})
461
+ });
462
+ shouldPersist = false;
463
+ // Our commit may have completed the majority — recompute; Consensus executes below.
464
+ continue;
465
+ case TransactionPhase.Consensus:
466
+ log('cluster-member:action-consensus', {
467
+ messageHash: record.messageHash
468
+ });
469
+ await this.handleConsensus(currentRecord);
470
+ shouldPersist = false;
471
+ break phaseLoop;
472
+ case TransactionPhase.Rejected:
473
+ log('cluster-member:action-rejected', {
474
+ messageHash: record.messageHash
475
+ });
476
+ await this.handleRejection(currentRecord);
477
+ shouldPersist = false;
478
+ break phaseLoop;
479
+ case TransactionPhase.ConflictSuperseded:
480
+ // Enough conflict votes that super-majority is unreachable. NOT a rejection — the
481
+ // callers retry it as a fresh transaction — so it gets its own terminal phase and the
482
+ // record is cleared rather than held (holding a provably-dead loser would reserve its
483
+ // blocks against the very retry that is supposed to win).
484
+ log('cluster-member:action-conflict-superseded', {
485
+ messageHash: record.messageHash
486
+ });
487
+ shouldPersist = false;
488
+ break phaseLoop;
489
+ case TransactionPhase.Propagating:
490
+ // Transaction is complete and propagating - clean it up
491
+ log('cluster-member:phase-propagating', {
492
+ messageHash: record.messageHash
493
+ });
494
+ shouldPersist = false;
495
+ break phaseLoop;
496
+ case TransactionPhase.Promising:
497
+ // We have already voted (approve, reject, or conflict); the record is still
498
+ // collecting promises from the rest of the cohort. Nothing to add — retain the
499
+ // record only if our vote wasn't a conflict (`shouldPersist` already reflects that).
500
+ log('cluster-member:phase-promising-waiting', {
501
+ messageHash: record.messageHash
502
+ });
503
+ break phaseLoop;
504
+ }
467
505
  }
468
506
 
469
507
  if (shouldPersist) {
@@ -701,14 +739,8 @@ export class ClusterMember implements ICluster {
701
739
  return computeClusterCommitHash(record.messageHash, record.message, record.promises, recordMembershipDigest(record));
702
740
  }
703
741
 
704
- private computeSigningPayload(hash: string, type: string, rejectReason?: string): Uint8Array {
705
- const payload = hash + ':' + type + (rejectReason ? ':' + rejectReason : '');
706
- return new TextEncoder().encode(payload);
707
- }
708
-
709
- private async signVote(hash: string, type: 'approve' | 'reject', rejectReason?: string): Promise<string> {
710
- const payload = this.computeSigningPayload(hash, type, rejectReason);
711
- const sigBytes = await this.privateKey.sign(payload);
742
+ private async signVote(hash: string, type: Signature['type'], extra?: string): Promise<string> {
743
+ const sigBytes = await this.privateKey.sign(clusterVoteSigningPayload(hash, type, extra));
712
744
  return uint8ArrayToString(sigBytes, 'base64url');
713
745
  }
714
746
 
@@ -744,7 +776,7 @@ export class ClusterMember implements ICluster {
744
776
  }
745
777
  try {
746
778
  const pubKey = publicKeyFromRaw(keyBytes);
747
- const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
779
+ const payload = clusterVoteVerificationPayload(hash, signature);
748
780
  const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
749
781
  const ok = await pubKey.verify(payload, sigBytes);
750
782
  // Key is bound to peerId: a failed verify is a genuine bad vote from a proven identity → penalize.
@@ -761,7 +793,7 @@ export class ClusterMember implements ICluster {
761
793
  }
762
794
  }
763
795
 
764
- private async getTransactionPhase(record: ClusterRecord): Promise<TransactionPhase> {
796
+ private async getTransactionPhase(record: ClusterRecord): Promise<PhaseResult> {
765
797
  const peerCount = Object.keys(record.peers).length;
766
798
  const promiseCount = Object.keys(record.promises).length;
767
799
  const ourId = this.peerId.toString();
@@ -769,36 +801,68 @@ export class ClusterMember implements ICluster {
769
801
  const superMajority = Math.ceil(peerCount * this.superMajorityThreshold);
770
802
  const maxAllowedRejections = peerCount - superMajority;
771
803
 
772
- // Check for rejections — rejected if too many rejections to ever reach super-majority
804
+ // Check for rejections — rejected if too many rejections to ever reach super-majority.
805
+ // ONLY `reject` votes count here: a `conflict` vote is "not now", never a validity
806
+ // judgement, so it must not push a record into the permanent `Rejected` phase.
773
807
  const rejectedPromises = Object.values(record.promises).filter(s => s.type === 'reject');
808
+ const conflictPromises = Object.values(record.promises).filter(s => s.type === 'conflict');
774
809
  const rejectedCommits = Object.values(record.commits).filter(s => s.type === 'reject');
775
810
  if (rejectedPromises.length > maxAllowedRejections || this.hasMajority(rejectedCommits.length, peerCount)) {
776
- return TransactionPhase.Rejected;
777
- }
778
-
779
- // Check if we need to promise
780
- if (!record.promises[ourId] && !this.hasConflict(record)) {
781
- return TransactionPhase.OurPromiseNeeded;
782
- }
783
-
784
- // Check if we have enough approved promises to proceed to commit
811
+ return { phase: TransactionPhase.Rejected };
812
+ }
813
+
814
+ // Conflict votes don't judge validity, but enough of them still make super-majority
815
+ // unreachable — a distinct terminal outcome (retryable as a fresh transaction) so logs and
816
+ // reputation-adjacent paths keep meaning what they say.
817
+ if (conflictPromises.length > 0 && rejectedPromises.length + conflictPromises.length > maxAllowedRejections) {
818
+ return { phase: TransactionPhase.ConflictSuperseded };
819
+ }
820
+
821
+ // Check if we need to vote. A lost race is answered with a conflict vote, not silence:
822
+ // absence used to mean both "unreachable" and "refusing in favour of a rival", and the
823
+ // coordinator could not tell the two apart. Once our conflict vote is merged into
824
+ // `promises`, this branch is skipped forever — a conflict vote is terminal for this record;
825
+ // a retry must be a fresh transaction (new messageHash), which `CoordinatorRepo.pend`
826
+ // already mints per call.
827
+ if (!record.promises[ourId]) {
828
+ const conflict = this.findConflict(record);
829
+ if (conflict) {
830
+ return { phase: TransactionPhase.OurConflictVoteNeeded, conflictsWith: conflict.blockedBy };
831
+ }
832
+ return { phase: TransactionPhase.OurPromiseNeeded };
833
+ }
834
+
835
+ // Check if we have enough approved promises to proceed to commit. Deliberately blind to what
836
+ // OUR own vote was: the rest of the cohort reaching super-majority is the commit rule
837
+ // (Theorem 1 Case 2), so a member that rejected — or conflict-voted — still signs the commit
838
+ // the cohort decided on rather than stalling it. Only the rejection/superseded thresholds
839
+ // above can stop a record here, and both are checked first.
840
+ //
841
+ // NOTE: signing the commit drops this member's reservation on the record
842
+ // (`shouldPersist = false` in the caller), and the phase fixpoint means that can now happen on
843
+ // the FIRST delivery when the record already arrives at super-majority, rather than a
844
+ // round-trip later. The safety argument is quorum intersection (Theorem 9: no rival can
845
+ // assemble its own super-majority once this one has), NOT the reservation — the reservation
846
+ // only orders *concurrently-pending* rivals. If a lost-update between commit-signing and
847
+ // consensus-apply ever shows up, hold the reservation until `handleConsensus` instead of
848
+ // releasing it here.
785
849
  const approvedPromises = Object.values(record.promises).filter(s => s.type === 'approve');
786
850
  if (approvedPromises.length >= superMajority && !record.commits[ourId]) {
787
- return TransactionPhase.OurCommitNeeded;
851
+ return { phase: TransactionPhase.OurCommitNeeded };
788
852
  }
789
853
 
790
854
  // Check if still collecting promises
791
855
  if (promiseCount < peerCount && approvedPromises.length < superMajority) {
792
- return TransactionPhase.Promising;
856
+ return { phase: TransactionPhase.Promising };
793
857
  }
794
858
 
795
859
  // Check for consensus
796
860
  const approvedCommits = Object.values(record.commits).filter(s => s.type === 'approve');
797
861
  if (this.hasMajority(approvedCommits.length, peerCount)) {
798
- return TransactionPhase.Consensus;
862
+ return { phase: TransactionPhase.Consensus };
799
863
  }
800
864
 
801
- return TransactionPhase.Propagating;
865
+ return { phase: TransactionPhase.Propagating };
802
866
  }
803
867
 
804
868
  private hasMajority(count: number, total: number): boolean {
@@ -837,6 +901,33 @@ export class ClusterMember implements ICluster {
837
901
  };
838
902
  }
839
903
 
904
+ /**
905
+ * Answer a record that lost the deterministic race to a transaction this member already holds
906
+ * (`docs/correctness.md` Theorems 1 & 9: the loser is TOLD it lost, not ignored — an unanswered
907
+ * loss is indistinguishable from an unreachable cohort at the coordinator). `conflictWith` — the
908
+ * winning rival's messageHash — is folded into the signed payload, so the claim is
909
+ * integrity-protected in transit and readable without parsing prose. NOT a validity judgement:
910
+ * {@link getTransactionPhase} never counts conflict votes toward the permanent-rejection
911
+ * threshold, and the coordinator surfaces them as a retryable loss, never a validator rejection.
912
+ */
913
+ private async handleConflictVoteNeeded(record: ClusterRecord, conflictWith: string): Promise<ClusterRecord> {
914
+ log('cluster-member:action-conflict-vote', {
915
+ messageHash: record.messageHash,
916
+ conflictWith
917
+ });
918
+ const promiseHash = await this.computePromiseHash(record);
919
+ const sig = await this.signVote(promiseHash, 'conflict', conflictWith);
920
+ const signature: Signature = { type: 'conflict', signature: sig, conflictWith };
921
+
922
+ return {
923
+ ...record,
924
+ promises: {
925
+ ...record.promises,
926
+ [this.peerId.toString()]: signature
927
+ }
928
+ };
929
+ }
930
+
840
931
  /**
841
932
  * The full promise-phase decision for a record: admit the declared membership FIRST, then (only if
842
933
  * admitted) validate its pend operations. Failing either yields a `{ valid:false, reason }` the caller
@@ -1268,7 +1359,7 @@ export class ClusterMember implements ICluster {
1268
1359
  // Gated on the sink: with no reactivity wired the preimage has no consumer, so a sink-less
1269
1360
  // node pays neither the extra `sha256` nor the extra microtask — the true zero-cost default.
1270
1361
  if (this.onCommitCertificate) {
1271
- const commitSignedPayload = this.computeSigningPayload(await this.computeCommitHash(record), 'approve');
1362
+ const commitSignedPayload = clusterVoteSigningPayload(await this.computeCommitHash(record), 'approve');
1272
1363
  this.captureCommitCert(record, commit.actionId, commitSignedPayload);
1273
1364
  }
1274
1365
  let result: CommitResult;
@@ -1393,7 +1484,7 @@ export class ClusterMember implements ICluster {
1393
1484
  // {@link invalidationActionId} the invalidation's change event also carries, so the bridge's
1394
1485
  // cert extractor resolves it. Gated on the sink — a node with no reactivity wired pays nothing.
1395
1486
  if (this.onCommitCertificate) {
1396
- const invSignedPayload = this.computeSigningPayload(await this.computeCommitHash(record), 'approve');
1487
+ const invSignedPayload = clusterVoteSigningPayload(await this.computeCommitHash(record), 'approve');
1397
1488
  this.captureCommitCert(record, invalidationActionId(request.invalidatedActionId, request.resolution.disputeId), invSignedPayload);
1398
1489
  }
1399
1490
 
@@ -1520,12 +1611,20 @@ export class ClusterMember implements ICluster {
1520
1611
  };
1521
1612
  }
1522
1613
 
1523
- private hasConflict(record: ClusterRecord): boolean {
1614
+ /**
1615
+ * Scan this member's reservation table (`activeTransactions`) for a held transaction that
1616
+ * conflicts with `record` AND wins the deterministic race against it. Returns the winner's
1617
+ * identity — `{ blockedBy: messageHash }` — rather than a bare boolean, because that identity is
1618
+ * exactly what the resulting conflict vote must name (`Signature.conflictWith`); the old boolean
1619
+ * lost it. `undefined` means no blocking conflict. Side-effectful on the way through: stale
1620
+ * entries are swept, and a held transaction that LOSES the race to `record` is cleared.
1621
+ */
1622
+ private findConflict(record: ClusterRecord): { blockedBy: string } | undefined {
1524
1623
  const now = Date.now();
1525
1624
  const staleThresholdMs = 2000; // 2 seconds - allow more time for distributed consensus
1526
1625
 
1527
1626
  const incomingBlockIds = this.getAffectedBlockIds(record.message.operations);
1528
- log('cluster-member:hasConflict-check', {
1627
+ log('cluster-member:findConflict-check', {
1529
1628
  messageHash: record.messageHash,
1530
1629
  activeCount: this.activeTransactions.size,
1531
1630
  incomingBlockIds
@@ -1537,7 +1636,7 @@ export class ClusterMember implements ICluster {
1537
1636
  }
1538
1637
 
1539
1638
  const existingBlockIds = this.getAffectedBlockIds(state.record.message.operations);
1540
- log('cluster-member:hasConflict-compare', {
1639
+ log('cluster-member:findConflict-compare', {
1541
1640
  existing: existingHash,
1542
1641
  incoming: record.messageHash,
1543
1642
  existingBlockIds,
@@ -1563,7 +1662,7 @@ export class ClusterMember implements ICluster {
1563
1662
  existing: existingHash,
1564
1663
  incoming: record.messageHash
1565
1664
  });
1566
- return true; // Reject incoming
1665
+ return { blockedBy: existingHash }; // Reject incoming, naming the winner
1567
1666
  } else {
1568
1667
  // Accept incoming, abort existing
1569
1668
  log('cluster-member:race-accept-incoming', {
@@ -1576,38 +1675,49 @@ export class ClusterMember implements ICluster {
1576
1675
  }
1577
1676
  }
1578
1677
 
1579
- return false; // No blocking conflicts
1678
+ return undefined; // No blocking conflicts
1679
+ }
1680
+
1681
+ /** Number of *approve* promise votes on a record — the count the commit rule uses. */
1682
+ private static approvalCount(record: ClusterRecord): number {
1683
+ return Object.values(record.promises).filter(s => s.type === 'approve').length;
1580
1684
  }
1581
1685
 
1582
1686
  /**
1583
1687
  * Resolve a race between two conflicting transactions. Total and deterministic, so every honest
1584
1688
  * member computes the identical winner (the Theorem 1 Case-2 premise). Order:
1585
- * 1. more promise signatures wins (progress monotonicity — see safety note below);
1586
- * 2. equal promise counts → higher aged priority wins (fairness — see {@link recordPriority});
1689
+ * 1. more *approve* promise signatures wins (progress monotonicity — see safety note below);
1690
+ * 2. equal approval counts → higher aged priority wins (fairness — see {@link recordPriority});
1587
1691
  * 3. still tied → higher message hash wins.
1588
1692
  *
1589
- * Promise count is FIRST so this comparison never displaces a transaction that is further along.
1693
+ * The count is APPROVALS, not `promises` keys. `promises` is the vote map — a reject occupies a key
1694
+ * there exactly as an approve does — so counting keys would treat a rejection as progress, letting a
1695
+ * record that can never commit outrank (and therefore block, via {@link findConflict}) a fresh rival
1696
+ * for the whole staleness window. Approvals is also the count the invariant below actually needs:
1697
+ * the commit rule is `approvedPromises >= superMajority`, which never looks at rejections.
1698
+ *
1699
+ * Approval count is FIRST so this comparison never displaces a transaction that is further along.
1590
1700
  * That restores the pre-priority safety invariant: a member commits purely on promise supermajority
1591
1701
  * (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
1592
1702
  * conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
1593
- * With promises-first, once transaction X holds a promise supermajority every conflicting rival Y has
1594
- * strictly fewer promises — Y can only match X's count by getting the intersecting quorum member to
1595
- * promise it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
1703
+ * With approvals-first, once transaction X holds a promise supermajority every conflicting rival Y has
1704
+ * strictly fewer approvals — Y can only match X's count by getting the intersecting quorum member to
1705
+ * approve it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
1596
1706
  * `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
1597
1707
  * overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
1598
1708
  * Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
1599
- * higher-priority Y with fewer promises, letting BOTH commit (split brain) — the regression fixed by
1709
+ * higher-priority Y with fewer approvals, letting BOTH commit (split brain) — the regression fixed by
1600
1710
  * ticket occ-priority-first-breaks-promise-monotonicity.
1601
1711
  *
1602
- * Priority is now a tie-break that runs only at EQUAL promise counts, which is exactly the
1712
+ * Priority is now a tie-break that runs only at EQUAL approval counts, which is exactly the
1603
1713
  * concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
1604
1714
  * on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
1605
1715
  * fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
1606
1716
  * defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
1607
1717
  * is the deferred feat-occ-priority-reservation).
1608
1718
  *
1609
- * NOTE: residual-fairness tripwire. Under promises-first an aged transaction can still lose to a fresh
1610
- * rival that has *legitimately* gathered even one more promise — that is not the pure-coin-flip
1719
+ * NOTE: residual-fairness tripwire. Under approvals-first an aged transaction can still lose to a fresh
1720
+ * rival that has *legitimately* gathered even one more approval — that is not the pure-coin-flip
1611
1721
  * starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
1612
1722
  * more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
1613
1723
  * rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
@@ -1616,7 +1726,7 @@ export class ClusterMember implements ICluster {
1616
1726
  * NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
1617
1727
  * priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
1618
1728
  * exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
1619
- * below the promise count, so it can only break equal-count ties it might have ~50% won anyway,
1729
+ * below the approval count, so it can only break equal-count ties it might have ~50% won anyway,
1620
1730
  * degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
1621
1731
  * honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
1622
1732
  *
@@ -1625,14 +1735,18 @@ export class ClusterMember implements ICluster {
1625
1735
  * (design-hot-log-tail-sharding-guidance).
1626
1736
  */
1627
1737
  private resolveRace(existing: ClusterRecord, incoming: ClusterRecord): 'keep-existing' | 'accept-incoming' {
1628
- // 1. Transaction with more promises wins — never displace a more-progressed rival (safety, see above).
1629
- const existingCount = Object.keys(existing.promises).length;
1630
- const incomingCount = Object.keys(incoming.promises).length;
1738
+ // 1. Transaction with more APPROVALS wins — never displace a more-progressed rival (safety, see
1739
+ // above). Counting `promises` keys instead would count reject votes as progress: a record holding
1740
+ // one rejection would outrank an untouched rival and reserve its blocks for the whole staleness
1741
+ // window, and the commit rule this ordering protects (`approvedPromises >= superMajority`) never
1742
+ // looks at rejections anyway.
1743
+ const existingCount = ClusterMember.approvalCount(existing);
1744
+ const incomingCount = ClusterMember.approvalCount(incoming);
1631
1745
  if (existingCount !== incomingCount) {
1632
1746
  return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
1633
1747
  }
1634
1748
 
1635
- // 2. Equal promise counts → higher aged priority wins (fairness tie-break).
1749
+ // 2. Equal approval counts → higher aged priority wins (fairness tie-break).
1636
1750
  const existingPriority = this.recordPriority(existing);
1637
1751
  const incomingPriority = this.recordPriority(incoming);
1638
1752
  if (existingPriority !== incomingPriority) {
@@ -1655,7 +1769,7 @@ export class ClusterMember implements ICluster {
1655
1769
  * NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
1656
1770
  * separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
1657
1771
  * the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
1658
- * consulted at the promise decision (hasConflict), i.e. between two still-open conflicting
1772
+ * consulted at the promise decision (findConflict), i.e. between two still-open conflicting
1659
1773
  * transactions, which is exactly the concurrent-contention case priority is meant to order.
1660
1774
  */
1661
1775
  private recordPriority(record: ClusterRecord): number {
@@ -1813,7 +1927,13 @@ export class ClusterMember implements ICluster {
1813
1927
  const state = this.activeTransactions.get(messageHash);
1814
1928
  if (!state) continue;
1815
1929
 
1816
- const phase = await this.getTransactionPhase(state.record);
1930
+ // NOTE: an expired entry already in a terminal phase is deliberately left alone here —
1931
+ // `processUpdate` clears those on the update that made them terminal, so reaching this point
1932
+ // in one means that update never arrived. It is not stranded: `findConflict`'s 2 s staleness
1933
+ // sweep drops it on the next conflicting arrival. But on a member that then goes idle the
1934
+ // entry lingers until traffic returns. If member memory ever shows entries outliving their
1935
+ // expiration, delete unconditionally here instead of exempting the terminal phases.
1936
+ const { phase } = await this.getTransactionPhase(state.record);
1817
1937
  if (phase !== TransactionPhase.Consensus && phase !== TransactionPhase.Rejected) {
1818
1938
  this.activeTransactions.delete(messageHash);
1819
1939
  }
@@ -1,45 +1,45 @@
1
- /** A component that exposes the cluster size it actually resolved to. */
2
- export interface HasEffectiveClusterSize {
3
- readonly effectiveClusterSize: number;
4
- }
5
-
6
- /**
7
- * Fail-fast coupling check for a live node's cluster-size wiring, mirroring
8
- * `assertSuperMajorityCoupling`.
9
- *
10
- * `resolveClusterPolicy` (`cluster/cluster-policy.ts`) is the single place that settles the
11
- * replication factor / target cohort breadth (`clusterSize`). Every consumer that resolves its
12
- * own cluster size — `Libp2pKeyPeerNetwork` (peer selection) and `NetworkManagerService` (ring
13
- * sizing) — must be constructed from that SAME resolved value, or the membership admission
14
- * gate's "is this declared peer set suspiciously small?" yardstick silently diverges from the
15
- * cohort width peer selection actually assembles (ticket
16
- * `bug-cluster-size-resolution-single-source`: an unconfigured node ran peer selection at 16
17
- * while consensus believed full size was 10, so a cohort that had quietly lost six members still
18
- * measured as full).
19
- *
20
- * On a live node both are constructed from `resolveClusterPolicy(options).clusterSize`, so this
21
- * check normally passes. It exists to catch *future* drift — a call site reverting to its own
22
- * default, a new consumer added without threading the resolved value — by throwing at
23
- * construction with every resolved value and its source, rather than letting the node come up
24
- * mismatched.
25
- *
26
- * @throws Error naming the resolved size and every consumer that disagrees with it.
27
- */
28
- export function assertClusterSizeCoupling(
29
- resolvedClusterSize: number,
30
- consumers: Record<string, HasEffectiveClusterSize | undefined>
31
- ): void {
32
- const mismatches = Object.entries(consumers)
33
- .filter((entry): entry is [string, HasEffectiveClusterSize] => entry[1] !== undefined)
34
- .filter(([, consumer]) => consumer.effectiveClusterSize !== resolvedClusterSize)
35
- .map(([name, consumer]) => `${name} resolved ${consumer.effectiveClusterSize}`);
36
-
37
- if (mismatches.length > 0) {
38
- throw new Error(
39
- `Cluster size mismatch at node startup: resolveClusterPolicy() produced clusterSize=${resolvedClusterSize}, ` +
40
- `but ${mismatches.join(', ')}. Every cluster-size consumer must be constructed from the SAME resolved ` +
41
- `value (see cluster/cluster-policy.ts), or the membership admission gate's yardstick can diverge from ` +
42
- `the cohort width peer selection actually assembles.`
43
- );
44
- }
45
- }
1
+ /** A component that exposes the cluster size it actually resolved to. */
2
+ export interface HasEffectiveClusterSize {
3
+ readonly effectiveClusterSize: number;
4
+ }
5
+
6
+ /**
7
+ * Fail-fast coupling check for a live node's cluster-size wiring, mirroring
8
+ * `assertSuperMajorityCoupling`.
9
+ *
10
+ * `resolveClusterPolicy` (`cluster/cluster-policy.ts`) is the single place that settles the
11
+ * replication factor / target cohort breadth (`clusterSize`). Every consumer that resolves its
12
+ * own cluster size — `Libp2pKeyPeerNetwork` (peer selection) and `NetworkManagerService` (ring
13
+ * sizing) — must be constructed from that SAME resolved value, or the membership admission
14
+ * gate's "is this declared peer set suspiciously small?" yardstick silently diverges from the
15
+ * cohort width peer selection actually assembles (ticket
16
+ * `bug-cluster-size-resolution-single-source`: an unconfigured node ran peer selection at 16
17
+ * while consensus believed full size was 10, so a cohort that had quietly lost six members still
18
+ * measured as full).
19
+ *
20
+ * On a live node both are constructed from `resolveClusterPolicy(options).clusterSize`, so this
21
+ * check normally passes. It exists to catch *future* drift — a call site reverting to its own
22
+ * default, a new consumer added without threading the resolved value — by throwing at
23
+ * construction with every resolved value and its source, rather than letting the node come up
24
+ * mismatched.
25
+ *
26
+ * @throws Error naming the resolved size and every consumer that disagrees with it.
27
+ */
28
+ export function assertClusterSizeCoupling(
29
+ resolvedClusterSize: number,
30
+ consumers: Record<string, HasEffectiveClusterSize | undefined>
31
+ ): void {
32
+ const mismatches = Object.entries(consumers)
33
+ .filter((entry): entry is [string, HasEffectiveClusterSize] => entry[1] !== undefined)
34
+ .filter(([, consumer]) => consumer.effectiveClusterSize !== resolvedClusterSize)
35
+ .map(([name, consumer]) => `${name} resolved ${consumer.effectiveClusterSize}`);
36
+
37
+ if (mismatches.length > 0) {
38
+ throw new Error(
39
+ `Cluster size mismatch at node startup: resolveClusterPolicy() produced clusterSize=${resolvedClusterSize}, ` +
40
+ `but ${mismatches.join(', ')}. Every cluster-size consumer must be constructed from the SAME resolved ` +
41
+ `value (see cluster/cluster-policy.ts), or the membership admission gate's yardstick can diverge from ` +
42
+ `the cohort width peer selection actually assembles.`
43
+ );
44
+ }
45
+ }