@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
@@ -1,5 +1,5 @@
1
1
  import { blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
2
- import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority } from "@optimystic/db-core";
2
+ import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority, clusterVoteSigningPayload, clusterVoteVerificationPayload } from "@optimystic/db-core";
3
3
  import { verifyInvalidationCertificate } from "../dispute/invalidation.js";
4
4
  import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
5
5
  import { ClusterClient } from "./client.js";
@@ -17,10 +17,12 @@ var TransactionPhase;
17
17
  (function (TransactionPhase) {
18
18
  TransactionPhase[TransactionPhase["Promising"] = 0] = "Promising";
19
19
  TransactionPhase[TransactionPhase["OurPromiseNeeded"] = 1] = "OurPromiseNeeded";
20
- TransactionPhase[TransactionPhase["OurCommitNeeded"] = 2] = "OurCommitNeeded";
21
- TransactionPhase[TransactionPhase["Consensus"] = 3] = "Consensus";
22
- TransactionPhase[TransactionPhase["Rejected"] = 4] = "Rejected";
23
- TransactionPhase[TransactionPhase["Propagating"] = 5] = "Propagating"; // Transaction is being propagated
20
+ TransactionPhase[TransactionPhase["OurConflictVoteNeeded"] = 2] = "OurConflictVoteNeeded";
21
+ TransactionPhase[TransactionPhase["OurCommitNeeded"] = 3] = "OurCommitNeeded";
22
+ TransactionPhase[TransactionPhase["Consensus"] = 4] = "Consensus";
23
+ TransactionPhase[TransactionPhase["Rejected"] = 5] = "Rejected";
24
+ TransactionPhase[TransactionPhase["ConflictSuperseded"] = 6] = "ConflictSuperseded";
25
+ TransactionPhase[TransactionPhase["Propagating"] = 7] = "Propagating"; // Transaction is being propagated
24
26
  })(TransactionPhase || (TransactionPhase = {}));
25
27
  /** Stable reject reason a member emits when a declared peer set fails the membership admission gate. */
26
28
  export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
@@ -239,78 +241,104 @@ export class ClusterMember {
239
241
  mergedCommits: Object.keys(currentRecord.commits ?? {})
240
242
  });
241
243
  }
242
- // Get the current transaction state
243
- const phase = await this.getTransactionPhase(currentRecord);
244
- log('cluster-member:phase', {
245
- messageHash: record.messageHash,
246
- phase,
247
- promises: Object.keys(currentRecord.promises ?? {}),
248
- commits: Object.keys(currentRecord.commits ?? {})
249
- });
244
+ // Drive the phase machine to a FIXPOINT rather than handling one phase per delivery. Each
245
+ // vote this member adds can put the record straight into the next phase (our promise
246
+ // completes super-majority ⇒ our commit is due; our commit completes the majority ⇒
247
+ // consensus; our reject/conflict vote makes the record terminal), and any follow-on phase
248
+ // not re-handled here would silently wait for the coordinator's next delivery — e.g. a
249
+ // member whose promise the coordinator never collected receives the commit-phase record,
250
+ // adds its promise, and must then also commit in the SAME delivery. One loop replaces the
251
+ // hand-written per-branch re-checks that used to cover only the follow-ons their authors
252
+ // thought of. Only the three vote-adding phases continue; each strictly grows the record
253
+ // (adds a promise or commit key), so the loop terminates — the cap guards a
254
+ // phase-computation bug, not a real bound.
255
+ const MaxPhaseSteps = 8;
250
256
  let shouldPersist = true;
251
- // Handle the transaction based on its state
252
- switch (phase) {
253
- case TransactionPhase.OurPromiseNeeded:
254
- log('cluster-member:action-promise', {
255
- messageHash: record.messageHash
256
- });
257
- currentRecord = await this.handlePromiseNeeded(currentRecord);
258
- log('cluster-member:action-promise-complete', {
259
- messageHash: record.messageHash,
260
- promises: Object.keys(currentRecord.promises ?? {})
261
- });
262
- break;
263
- case TransactionPhase.OurCommitNeeded:
264
- log('cluster-member:action-commit', {
265
- messageHash: record.messageHash
266
- });
267
- currentRecord = await this.handleCommitNeeded(currentRecord);
268
- log('cluster-member:action-commit-complete', {
269
- messageHash: record.messageHash,
270
- commits: Object.keys(currentRecord.commits ?? {})
271
- });
272
- // After adding our commit, check if we now have consensus and execute if so
273
- {
274
- const newPhase = await this.getTransactionPhase(currentRecord);
275
- if (newPhase === TransactionPhase.Consensus) {
276
- log('cluster-member:action-consensus-after-commit', {
277
- messageHash: record.messageHash
278
- });
279
- await this.handleConsensus(currentRecord);
280
- }
281
- }
282
- shouldPersist = false;
283
- break;
284
- case TransactionPhase.Consensus:
285
- log('cluster-member:action-consensus', {
286
- messageHash: record.messageHash
287
- });
288
- await this.handleConsensus(currentRecord);
289
- // Don't call clearTransaction here - it happens in handleConsensus
290
- shouldPersist = false;
291
- break;
292
- case TransactionPhase.Rejected:
293
- log('cluster-member:action-rejected', {
294
- messageHash: record.messageHash
295
- });
296
- // Don't call clearTransaction here - it happens in handleRejection
297
- await this.handleRejection(currentRecord);
298
- shouldPersist = false;
299
- break;
300
- case TransactionPhase.Propagating:
301
- // Transaction is complete and propagating - clean it up
302
- log('cluster-member:phase-propagating', {
303
- messageHash: record.messageHash
304
- });
305
- shouldPersist = false;
306
- break;
307
- case TransactionPhase.Promising:
308
- // Still collecting promises from peers - if we haven't added ours and there's no conflict, add it
309
- // This state shouldn't normally be reached since OurPromiseNeeded is checked first
310
- log('cluster-member:phase-promising-blocked', {
311
- messageHash: record.messageHash
312
- });
257
+ phaseLoop: for (let step = 0;; step++) {
258
+ if (step >= MaxPhaseSteps) {
259
+ log('cluster-member:phase-loop-overflow', { messageHash: record.messageHash, steps: step });
313
260
  break;
261
+ }
262
+ const phaseResult = await this.getTransactionPhase(currentRecord);
263
+ log('cluster-member:phase', {
264
+ messageHash: record.messageHash,
265
+ phase: phaseResult.phase,
266
+ step,
267
+ promises: Object.keys(currentRecord.promises ?? {}),
268
+ commits: Object.keys(currentRecord.commits ?? {})
269
+ });
270
+ switch (phaseResult.phase) {
271
+ case TransactionPhase.OurPromiseNeeded:
272
+ log('cluster-member:action-promise', {
273
+ messageHash: record.messageHash
274
+ });
275
+ currentRecord = await this.handlePromiseNeeded(currentRecord);
276
+ log('cluster-member:action-promise-complete', {
277
+ messageHash: record.messageHash,
278
+ promises: Object.keys(currentRecord.promises ?? {})
279
+ });
280
+ // Our own vote can be terminal (a reject where maxAllowedRejections is 0) or complete
281
+ // the super-majority — recompute rather than guess which.
282
+ continue;
283
+ case TransactionPhase.OurConflictVoteNeeded:
284
+ currentRecord = await this.handleConflictVoteNeeded(currentRecord, phaseResult.conflictsWith);
285
+ // Never persist a record we conflict-voted: this member holds the WINNER, and
286
+ // persisting the loser would reserve the same blocks a second time — half of what
287
+ // made the silent-abstention failure self-sustaining.
288
+ shouldPersist = false;
289
+ continue;
290
+ case TransactionPhase.OurCommitNeeded:
291
+ log('cluster-member:action-commit', {
292
+ messageHash: record.messageHash
293
+ });
294
+ currentRecord = await this.handleCommitNeeded(currentRecord);
295
+ log('cluster-member:action-commit-complete', {
296
+ messageHash: record.messageHash,
297
+ commits: Object.keys(currentRecord.commits ?? {})
298
+ });
299
+ shouldPersist = false;
300
+ // Our commit may have completed the majority — recompute; Consensus executes below.
301
+ continue;
302
+ case TransactionPhase.Consensus:
303
+ log('cluster-member:action-consensus', {
304
+ messageHash: record.messageHash
305
+ });
306
+ await this.handleConsensus(currentRecord);
307
+ shouldPersist = false;
308
+ break phaseLoop;
309
+ case TransactionPhase.Rejected:
310
+ log('cluster-member:action-rejected', {
311
+ messageHash: record.messageHash
312
+ });
313
+ await this.handleRejection(currentRecord);
314
+ shouldPersist = false;
315
+ break phaseLoop;
316
+ case TransactionPhase.ConflictSuperseded:
317
+ // Enough conflict votes that super-majority is unreachable. NOT a rejection — the
318
+ // callers retry it as a fresh transaction — so it gets its own terminal phase and the
319
+ // record is cleared rather than held (holding a provably-dead loser would reserve its
320
+ // blocks against the very retry that is supposed to win).
321
+ log('cluster-member:action-conflict-superseded', {
322
+ messageHash: record.messageHash
323
+ });
324
+ shouldPersist = false;
325
+ break phaseLoop;
326
+ case TransactionPhase.Propagating:
327
+ // Transaction is complete and propagating - clean it up
328
+ log('cluster-member:phase-propagating', {
329
+ messageHash: record.messageHash
330
+ });
331
+ shouldPersist = false;
332
+ break phaseLoop;
333
+ case TransactionPhase.Promising:
334
+ // We have already voted (approve, reject, or conflict); the record is still
335
+ // collecting promises from the rest of the cohort. Nothing to add — retain the
336
+ // record only if our vote wasn't a conflict (`shouldPersist` already reflects that).
337
+ log('cluster-member:phase-promising-waiting', {
338
+ messageHash: record.messageHash
339
+ });
340
+ break phaseLoop;
341
+ }
314
342
  }
315
343
  if (shouldPersist) {
316
344
  // Update transaction state
@@ -515,13 +543,8 @@ export class ClusterMember {
515
543
  async computeCommitHash(record) {
516
544
  return computeClusterCommitHash(record.messageHash, record.message, record.promises, recordMembershipDigest(record));
517
545
  }
518
- computeSigningPayload(hash, type, rejectReason) {
519
- const payload = hash + ':' + type + (rejectReason ? ':' + rejectReason : '');
520
- return new TextEncoder().encode(payload);
521
- }
522
- async signVote(hash, type, rejectReason) {
523
- const payload = this.computeSigningPayload(hash, type, rejectReason);
524
- const sigBytes = await this.privateKey.sign(payload);
546
+ async signVote(hash, type, extra) {
547
+ const sigBytes = await this.privateKey.sign(clusterVoteSigningPayload(hash, type, extra));
525
548
  return uint8ArrayToString(sigBytes, 'base64url');
526
549
  }
527
550
  /**
@@ -557,7 +580,7 @@ export class ClusterMember {
557
580
  }
558
581
  try {
559
582
  const pubKey = publicKeyFromRaw(keyBytes);
560
- const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
583
+ const payload = clusterVoteVerificationPayload(hash, signature);
561
584
  const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
562
585
  const ok = await pubKey.verify(payload, sigBytes);
563
586
  // Key is bound to peerId: a failed verify is a genuine bad vote from a proven identity → penalize.
@@ -580,31 +603,62 @@ export class ClusterMember {
580
603
  const ourId = this.peerId.toString();
581
604
  const superMajority = Math.ceil(peerCount * this.superMajorityThreshold);
582
605
  const maxAllowedRejections = peerCount - superMajority;
583
- // Check for rejections — rejected if too many rejections to ever reach super-majority
606
+ // Check for rejections — rejected if too many rejections to ever reach super-majority.
607
+ // ONLY `reject` votes count here: a `conflict` vote is "not now", never a validity
608
+ // judgement, so it must not push a record into the permanent `Rejected` phase.
584
609
  const rejectedPromises = Object.values(record.promises).filter(s => s.type === 'reject');
610
+ const conflictPromises = Object.values(record.promises).filter(s => s.type === 'conflict');
585
611
  const rejectedCommits = Object.values(record.commits).filter(s => s.type === 'reject');
586
612
  if (rejectedPromises.length > maxAllowedRejections || this.hasMajority(rejectedCommits.length, peerCount)) {
587
- return TransactionPhase.Rejected;
588
- }
589
- // Check if we need to promise
590
- if (!record.promises[ourId] && !this.hasConflict(record)) {
591
- return TransactionPhase.OurPromiseNeeded;
592
- }
593
- // Check if we have enough approved promises to proceed to commit
613
+ return { phase: TransactionPhase.Rejected };
614
+ }
615
+ // Conflict votes don't judge validity, but enough of them still make super-majority
616
+ // unreachable — a distinct terminal outcome (retryable as a fresh transaction) so logs and
617
+ // reputation-adjacent paths keep meaning what they say.
618
+ if (conflictPromises.length > 0 && rejectedPromises.length + conflictPromises.length > maxAllowedRejections) {
619
+ return { phase: TransactionPhase.ConflictSuperseded };
620
+ }
621
+ // Check if we need to vote. A lost race is answered with a conflict vote, not silence:
622
+ // absence used to mean both "unreachable" and "refusing in favour of a rival", and the
623
+ // coordinator could not tell the two apart. Once our conflict vote is merged into
624
+ // `promises`, this branch is skipped forever — a conflict vote is terminal for this record;
625
+ // a retry must be a fresh transaction (new messageHash), which `CoordinatorRepo.pend`
626
+ // already mints per call.
627
+ if (!record.promises[ourId]) {
628
+ const conflict = this.findConflict(record);
629
+ if (conflict) {
630
+ return { phase: TransactionPhase.OurConflictVoteNeeded, conflictsWith: conflict.blockedBy };
631
+ }
632
+ return { phase: TransactionPhase.OurPromiseNeeded };
633
+ }
634
+ // Check if we have enough approved promises to proceed to commit. Deliberately blind to what
635
+ // OUR own vote was: the rest of the cohort reaching super-majority is the commit rule
636
+ // (Theorem 1 Case 2), so a member that rejected — or conflict-voted — still signs the commit
637
+ // the cohort decided on rather than stalling it. Only the rejection/superseded thresholds
638
+ // above can stop a record here, and both are checked first.
639
+ //
640
+ // NOTE: signing the commit drops this member's reservation on the record
641
+ // (`shouldPersist = false` in the caller), and the phase fixpoint means that can now happen on
642
+ // the FIRST delivery when the record already arrives at super-majority, rather than a
643
+ // round-trip later. The safety argument is quorum intersection (Theorem 9: no rival can
644
+ // assemble its own super-majority once this one has), NOT the reservation — the reservation
645
+ // only orders *concurrently-pending* rivals. If a lost-update between commit-signing and
646
+ // consensus-apply ever shows up, hold the reservation until `handleConsensus` instead of
647
+ // releasing it here.
594
648
  const approvedPromises = Object.values(record.promises).filter(s => s.type === 'approve');
595
649
  if (approvedPromises.length >= superMajority && !record.commits[ourId]) {
596
- return TransactionPhase.OurCommitNeeded;
650
+ return { phase: TransactionPhase.OurCommitNeeded };
597
651
  }
598
652
  // Check if still collecting promises
599
653
  if (promiseCount < peerCount && approvedPromises.length < superMajority) {
600
- return TransactionPhase.Promising;
654
+ return { phase: TransactionPhase.Promising };
601
655
  }
602
656
  // Check for consensus
603
657
  const approvedCommits = Object.values(record.commits).filter(s => s.type === 'approve');
604
658
  if (this.hasMajority(approvedCommits.length, peerCount)) {
605
- return TransactionPhase.Consensus;
659
+ return { phase: TransactionPhase.Consensus };
606
660
  }
607
- return TransactionPhase.Propagating;
661
+ return { phase: TransactionPhase.Propagating };
608
662
  }
609
663
  hasMajority(count, total) {
610
664
  return count > total / 2;
@@ -636,6 +690,31 @@ export class ClusterMember {
636
690
  }
637
691
  };
638
692
  }
693
+ /**
694
+ * Answer a record that lost the deterministic race to a transaction this member already holds
695
+ * (`docs/correctness.md` Theorems 1 & 9: the loser is TOLD it lost, not ignored — an unanswered
696
+ * loss is indistinguishable from an unreachable cohort at the coordinator). `conflictWith` — the
697
+ * winning rival's messageHash — is folded into the signed payload, so the claim is
698
+ * integrity-protected in transit and readable without parsing prose. NOT a validity judgement:
699
+ * {@link getTransactionPhase} never counts conflict votes toward the permanent-rejection
700
+ * threshold, and the coordinator surfaces them as a retryable loss, never a validator rejection.
701
+ */
702
+ async handleConflictVoteNeeded(record, conflictWith) {
703
+ log('cluster-member:action-conflict-vote', {
704
+ messageHash: record.messageHash,
705
+ conflictWith
706
+ });
707
+ const promiseHash = await this.computePromiseHash(record);
708
+ const sig = await this.signVote(promiseHash, 'conflict', conflictWith);
709
+ const signature = { type: 'conflict', signature: sig, conflictWith };
710
+ return {
711
+ ...record,
712
+ promises: {
713
+ ...record.promises,
714
+ [this.peerId.toString()]: signature
715
+ }
716
+ };
717
+ }
639
718
  /**
640
719
  * The full promise-phase decision for a record: admit the declared membership FIRST, then (only if
641
720
  * admitted) validate its pend operations. Failing either yields a `{ valid:false, reason }` the caller
@@ -1051,7 +1130,7 @@ export class ClusterMember {
1051
1130
  // Gated on the sink: with no reactivity wired the preimage has no consumer, so a sink-less
1052
1131
  // node pays neither the extra `sha256` nor the extra microtask — the true zero-cost default.
1053
1132
  if (this.onCommitCertificate) {
1054
- const commitSignedPayload = this.computeSigningPayload(await this.computeCommitHash(record), 'approve');
1133
+ const commitSignedPayload = clusterVoteSigningPayload(await this.computeCommitHash(record), 'approve');
1055
1134
  this.captureCommitCert(record, commit.actionId, commitSignedPayload);
1056
1135
  }
1057
1136
  let result;
@@ -1170,7 +1249,7 @@ export class ClusterMember {
1170
1249
  // {@link invalidationActionId} the invalidation's change event also carries, so the bridge's
1171
1250
  // cert extractor resolves it. Gated on the sink — a node with no reactivity wired pays nothing.
1172
1251
  if (this.onCommitCertificate) {
1173
- const invSignedPayload = this.computeSigningPayload(await this.computeCommitHash(record), 'approve');
1252
+ const invSignedPayload = clusterVoteSigningPayload(await this.computeCommitHash(record), 'approve');
1174
1253
  this.captureCommitCert(record, invalidationActionId(request.invalidatedActionId, request.resolution.disputeId), invSignedPayload);
1175
1254
  }
1176
1255
  if (!this.onInvalidate) {
@@ -1280,11 +1359,19 @@ export class ClusterMember {
1280
1359
  resolutionTimeout: setTimeout(() => this.resolveWithPeers(record.messageHash), record.message.expiration + 5000 - Date.now()).unref()
1281
1360
  };
1282
1361
  }
1283
- hasConflict(record) {
1362
+ /**
1363
+ * Scan this member's reservation table (`activeTransactions`) for a held transaction that
1364
+ * conflicts with `record` AND wins the deterministic race against it. Returns the winner's
1365
+ * identity — `{ blockedBy: messageHash }` — rather than a bare boolean, because that identity is
1366
+ * exactly what the resulting conflict vote must name (`Signature.conflictWith`); the old boolean
1367
+ * lost it. `undefined` means no blocking conflict. Side-effectful on the way through: stale
1368
+ * entries are swept, and a held transaction that LOSES the race to `record` is cleared.
1369
+ */
1370
+ findConflict(record) {
1284
1371
  const now = Date.now();
1285
1372
  const staleThresholdMs = 2000; // 2 seconds - allow more time for distributed consensus
1286
1373
  const incomingBlockIds = this.getAffectedBlockIds(record.message.operations);
1287
- log('cluster-member:hasConflict-check', {
1374
+ log('cluster-member:findConflict-check', {
1288
1375
  messageHash: record.messageHash,
1289
1376
  activeCount: this.activeTransactions.size,
1290
1377
  incomingBlockIds
@@ -1294,7 +1381,7 @@ export class ClusterMember {
1294
1381
  continue;
1295
1382
  }
1296
1383
  const existingBlockIds = this.getAffectedBlockIds(state.record.message.operations);
1297
- log('cluster-member:hasConflict-compare', {
1384
+ log('cluster-member:findConflict-compare', {
1298
1385
  existing: existingHash,
1299
1386
  incoming: record.messageHash,
1300
1387
  existingBlockIds,
@@ -1317,7 +1404,7 @@ export class ClusterMember {
1317
1404
  existing: existingHash,
1318
1405
  incoming: record.messageHash
1319
1406
  });
1320
- return true; // Reject incoming
1407
+ return { blockedBy: existingHash }; // Reject incoming, naming the winner
1321
1408
  }
1322
1409
  else {
1323
1410
  // Accept incoming, abort existing
@@ -1330,37 +1417,47 @@ export class ClusterMember {
1330
1417
  }
1331
1418
  }
1332
1419
  }
1333
- return false; // No blocking conflicts
1420
+ return undefined; // No blocking conflicts
1421
+ }
1422
+ /** Number of *approve* promise votes on a record — the count the commit rule uses. */
1423
+ static approvalCount(record) {
1424
+ return Object.values(record.promises).filter(s => s.type === 'approve').length;
1334
1425
  }
1335
1426
  /**
1336
1427
  * Resolve a race between two conflicting transactions. Total and deterministic, so every honest
1337
1428
  * member computes the identical winner (the Theorem 1 Case-2 premise). Order:
1338
- * 1. more promise signatures wins (progress monotonicity — see safety note below);
1339
- * 2. equal promise counts → higher aged priority wins (fairness — see {@link recordPriority});
1429
+ * 1. more *approve* promise signatures wins (progress monotonicity — see safety note below);
1430
+ * 2. equal approval counts → higher aged priority wins (fairness — see {@link recordPriority});
1340
1431
  * 3. still tied → higher message hash wins.
1341
1432
  *
1342
- * Promise count is FIRST so this comparison never displaces a transaction that is further along.
1433
+ * The count is APPROVALS, not `promises` keys. `promises` is the vote map — a reject occupies a key
1434
+ * there exactly as an approve does — so counting keys would treat a rejection as progress, letting a
1435
+ * record that can never commit outrank (and therefore block, via {@link findConflict}) a fresh rival
1436
+ * for the whole staleness window. Approvals is also the count the invariant below actually needs:
1437
+ * the commit rule is `approvedPromises >= superMajority`, which never looks at rejections.
1438
+ *
1439
+ * Approval count is FIRST so this comparison never displaces a transaction that is further along.
1343
1440
  * That restores the pre-priority safety invariant: a member commits purely on promise supermajority
1344
1441
  * (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
1345
1442
  * conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
1346
- * With promises-first, once transaction X holds a promise supermajority every conflicting rival Y has
1347
- * strictly fewer promises — Y can only match X's count by getting the intersecting quorum member to
1348
- * promise it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
1443
+ * With approvals-first, once transaction X holds a promise supermajority every conflicting rival Y has
1444
+ * strictly fewer approvals — Y can only match X's count by getting the intersecting quorum member to
1445
+ * approve it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
1349
1446
  * `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
1350
1447
  * overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
1351
1448
  * Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
1352
- * higher-priority Y with fewer promises, letting BOTH commit (split brain) — the regression fixed by
1449
+ * higher-priority Y with fewer approvals, letting BOTH commit (split brain) — the regression fixed by
1353
1450
  * ticket occ-priority-first-breaks-promise-monotonicity.
1354
1451
  *
1355
- * Priority is now a tie-break that runs only at EQUAL promise counts, which is exactly the
1452
+ * Priority is now a tie-break that runs only at EQUAL approval counts, which is exactly the
1356
1453
  * concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
1357
1454
  * on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
1358
1455
  * fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
1359
1456
  * defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
1360
1457
  * is the deferred feat-occ-priority-reservation).
1361
1458
  *
1362
- * NOTE: residual-fairness tripwire. Under promises-first an aged transaction can still lose to a fresh
1363
- * rival that has *legitimately* gathered even one more promise — that is not the pure-coin-flip
1459
+ * NOTE: residual-fairness tripwire. Under approvals-first an aged transaction can still lose to a fresh
1460
+ * rival that has *legitimately* gathered even one more approval — that is not the pure-coin-flip
1364
1461
  * starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
1365
1462
  * more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
1366
1463
  * rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
@@ -1369,7 +1466,7 @@ export class ClusterMember {
1369
1466
  * NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
1370
1467
  * priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
1371
1468
  * exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
1372
- * below the promise count, so it can only break equal-count ties it might have ~50% won anyway,
1469
+ * below the approval count, so it can only break equal-count ties it might have ~50% won anyway,
1373
1470
  * degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
1374
1471
  * honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
1375
1472
  *
@@ -1378,13 +1475,17 @@ export class ClusterMember {
1378
1475
  * (design-hot-log-tail-sharding-guidance).
1379
1476
  */
1380
1477
  resolveRace(existing, incoming) {
1381
- // 1. Transaction with more promises wins — never displace a more-progressed rival (safety, see above).
1382
- const existingCount = Object.keys(existing.promises).length;
1383
- const incomingCount = Object.keys(incoming.promises).length;
1478
+ // 1. Transaction with more APPROVALS wins — never displace a more-progressed rival (safety, see
1479
+ // above). Counting `promises` keys instead would count reject votes as progress: a record holding
1480
+ // one rejection would outrank an untouched rival and reserve its blocks for the whole staleness
1481
+ // window, and the commit rule this ordering protects (`approvedPromises >= superMajority`) never
1482
+ // looks at rejections anyway.
1483
+ const existingCount = ClusterMember.approvalCount(existing);
1484
+ const incomingCount = ClusterMember.approvalCount(incoming);
1384
1485
  if (existingCount !== incomingCount) {
1385
1486
  return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
1386
1487
  }
1387
- // 2. Equal promise counts → higher aged priority wins (fairness tie-break).
1488
+ // 2. Equal approval counts → higher aged priority wins (fairness tie-break).
1388
1489
  const existingPriority = this.recordPriority(existing);
1389
1490
  const incomingPriority = this.recordPriority(incoming);
1390
1491
  if (existingPriority !== incomingPriority) {
@@ -1405,7 +1506,7 @@ export class ClusterMember {
1405
1506
  * NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
1406
1507
  * separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
1407
1508
  * the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
1408
- * consulted at the promise decision (hasConflict), i.e. between two still-open conflicting
1509
+ * consulted at the promise decision (findConflict), i.e. between two still-open conflicting
1409
1510
  * transactions, which is exactly the concurrent-contention case priority is meant to order.
1410
1511
  */
1411
1512
  recordPriority(record) {
@@ -1554,7 +1655,13 @@ export class ClusterMember {
1554
1655
  const state = this.activeTransactions.get(messageHash);
1555
1656
  if (!state)
1556
1657
  continue;
1557
- const phase = await this.getTransactionPhase(state.record);
1658
+ // NOTE: an expired entry already in a terminal phase is deliberately left alone here —
1659
+ // `processUpdate` clears those on the update that made them terminal, so reaching this point
1660
+ // in one means that update never arrived. It is not stranded: `findConflict`'s 2 s staleness
1661
+ // sweep drops it on the next conflicting arrival. But on a member that then goes idle the
1662
+ // entry lingers until traffic returns. If member memory ever shows entries outliving their
1663
+ // expiration, delete unconditionally here instead of exempting the terminal phases.
1664
+ const { phase } = await this.getTransactionPhase(state.record);
1558
1665
  if (phase !== TransactionPhase.Consensus && phase !== TransactionPhase.Rejected) {
1559
1666
  this.activeTransactions.delete(messageHash);
1560
1667
  }