@optimystic/db-p2p 1.0.0-beta.3 → 1.1.0

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 (234) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +73 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +70 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +16 -1
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +73 -72
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts +4 -6
  10. package/dist/src/cluster/client.d.ts.map +1 -1
  11. package/dist/src/cluster/client.js +8 -13
  12. package/dist/src/cluster/client.js.map +1 -1
  13. package/dist/src/cluster/cluster-policy.d.ts +2 -1
  14. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  15. package/dist/src/cluster/cluster-policy.js.map +1 -1
  16. package/dist/src/cluster/cluster-repo.d.ts +155 -9
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +446 -58
  19. package/dist/src/cluster/cluster-repo.js.map +1 -1
  20. package/dist/src/cluster/commit-proof.js +2 -2
  21. package/dist/src/cluster/race-resolution.d.ts +51 -7
  22. package/dist/src/cluster/race-resolution.d.ts.map +1 -1
  23. package/dist/src/cluster/race-resolution.js +71 -7
  24. package/dist/src/cluster/race-resolution.js.map +1 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
  26. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  27. package/dist/src/cluster/rebalance-monitor.js +120 -26
  28. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  29. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  30. package/dist/src/cluster/spread-on-churn.js +34 -47
  31. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  32. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  33. package/dist/src/cohort-topic/host.js +8 -6
  34. package/dist/src/cohort-topic/host.js.map +1 -1
  35. package/dist/src/cohort-topic/membership-source.js +1 -1
  36. package/dist/src/cohort-topic/membership-source.js.map +1 -1
  37. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
  38. package/dist/src/cohort-topic/peer-codec.js +5 -2
  39. package/dist/src/cohort-topic/peer-codec.js.map +1 -1
  40. package/dist/src/cohort-topic/stream-util.d.ts +24 -3
  41. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  42. package/dist/src/cohort-topic/stream-util.js +36 -3
  43. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  44. package/dist/src/cohort-topic/topic-router.d.ts +9 -0
  45. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
  46. package/dist/src/cohort-topic/topic-router.js +12 -2
  47. package/dist/src/cohort-topic/topic-router.js.map +1 -1
  48. package/dist/src/dispute/client.d.ts.map +1 -1
  49. package/dist/src/dispute/client.js +15 -3
  50. package/dist/src/dispute/client.js.map +1 -1
  51. package/dist/src/index.d.ts +5 -0
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +5 -0
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/libp2p-key-network.d.ts +78 -17
  56. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  57. package/dist/src/libp2p-key-network.js +234 -147
  58. package/dist/src/libp2p-key-network.js.map +1 -1
  59. package/dist/src/libp2p-node-base.d.ts +35 -1
  60. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  61. package/dist/src/libp2p-node-base.js +151 -26
  62. package/dist/src/libp2p-node-base.js.map +1 -1
  63. package/dist/src/libp2p-node-rn.d.ts +2 -2
  64. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  65. package/dist/src/libp2p-node-rn.js.map +1 -1
  66. package/dist/src/libp2p-node.d.ts +2 -2
  67. package/dist/src/libp2p-node.d.ts.map +1 -1
  68. package/dist/src/libp2p-node.js.map +1 -1
  69. package/dist/src/logger.d.ts.map +1 -1
  70. package/dist/src/logger.js +6 -1
  71. package/dist/src/logger.js.map +1 -1
  72. package/dist/src/matchmaking/query-transport.d.ts +3 -4
  73. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  74. package/dist/src/matchmaking/query-transport.js +9 -9
  75. package/dist/src/matchmaking/query-transport.js.map +1 -1
  76. package/dist/src/network/network-manager-service.d.ts +10 -5
  77. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  78. package/dist/src/network/network-manager-service.js +11 -2
  79. package/dist/src/network/network-manager-service.js.map +1 -1
  80. package/dist/src/network/relay-reservation.d.ts +170 -0
  81. package/dist/src/network/relay-reservation.d.ts.map +1 -0
  82. package/dist/src/network/relay-reservation.js +527 -0
  83. package/dist/src/network/relay-reservation.js.map +1 -0
  84. package/dist/src/pend-validation.d.ts +3 -2
  85. package/dist/src/pend-validation.d.ts.map +1 -1
  86. package/dist/src/pend-validation.js +3 -2
  87. package/dist/src/pend-validation.js.map +1 -1
  88. package/dist/src/protocol-client.d.ts +8 -1
  89. package/dist/src/protocol-client.d.ts.map +1 -1
  90. package/dist/src/protocol-client.js +11 -0
  91. package/dist/src/protocol-client.js.map +1 -1
  92. package/dist/src/reactivity/forwarder-host.d.ts +2 -1
  93. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
  94. package/dist/src/reactivity/forwarder-host.js +2 -1
  95. package/dist/src/reactivity/forwarder-host.js.map +1 -1
  96. package/dist/src/reactivity/origination-manager.d.ts +3 -3
  97. package/dist/src/reactivity/origination-manager.js +3 -3
  98. package/dist/src/reactivity/recover-transport.d.ts +22 -14
  99. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  100. package/dist/src/reactivity/recover-transport.js +32 -19
  101. package/dist/src/reactivity/recover-transport.js.map +1 -1
  102. package/dist/src/reactivity/subscription-manager.d.ts +2 -2
  103. package/dist/src/reactivity/topic-bytes.d.ts +6 -7
  104. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
  105. package/dist/src/reactivity/topic-bytes.js +8 -9
  106. package/dist/src/reactivity/topic-bytes.js.map +1 -1
  107. package/dist/src/repo/client.d.ts +5 -2
  108. package/dist/src/repo/client.d.ts.map +1 -1
  109. package/dist/src/repo/client.js +49 -40
  110. package/dist/src/repo/client.js.map +1 -1
  111. package/dist/src/repo/cluster-coordinator.d.ts +96 -11
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +164 -25
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +240 -122
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +627 -473
  118. package/dist/src/repo/coordinator-repo.js.map +1 -1
  119. package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
  120. package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
  121. package/dist/src/repo/i-under-replication-ledger.js +2 -0
  122. package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
  123. package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
  124. package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
  125. package/dist/src/repo/kv-under-replication-ledger.js +287 -0
  126. package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
  127. package/dist/src/repo/responsibility.d.ts +37 -0
  128. package/dist/src/repo/responsibility.d.ts.map +1 -0
  129. package/dist/src/repo/responsibility.js +37 -0
  130. package/dist/src/repo/responsibility.js.map +1 -0
  131. package/dist/src/repo/service.d.ts +44 -15
  132. package/dist/src/repo/service.d.ts.map +1 -1
  133. package/dist/src/repo/service.js +64 -27
  134. package/dist/src/repo/service.js.map +1 -1
  135. package/dist/src/repo/stuck-reservation.d.ts +127 -0
  136. package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
  137. package/dist/src/repo/stuck-reservation.js +179 -0
  138. package/dist/src/repo/stuck-reservation.js.map +1 -0
  139. package/dist/src/repo/under-replication-drain.d.ts +208 -0
  140. package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
  141. package/dist/src/repo/under-replication-drain.js +461 -0
  142. package/dist/src/repo/under-replication-drain.js.map +1 -0
  143. package/dist/src/rn.d.ts +5 -0
  144. package/dist/src/rn.d.ts.map +1 -1
  145. package/dist/src/rn.js +5 -0
  146. package/dist/src/rn.js.map +1 -1
  147. package/dist/src/storage/block-latch.d.ts +3 -2
  148. package/dist/src/storage/block-latch.d.ts.map +1 -1
  149. package/dist/src/storage/block-latch.js +7 -4
  150. package/dist/src/storage/block-latch.js.map +1 -1
  151. package/dist/src/storage/block-storage.d.ts +45 -3
  152. package/dist/src/storage/block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/block-storage.js +156 -9
  154. package/dist/src/storage/block-storage.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +57 -5
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js.map +1 -1
  158. package/dist/src/storage/pending-claim.d.ts +167 -0
  159. package/dist/src/storage/pending-claim.d.ts.map +1 -0
  160. package/dist/src/storage/pending-claim.js +156 -0
  161. package/dist/src/storage/pending-claim.js.map +1 -0
  162. package/dist/src/storage/raw-store-codec.d.ts +13 -0
  163. package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +24 -3
  165. package/dist/src/storage/raw-store-codec.js.map +1 -1
  166. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
  167. package/dist/src/storage/restoration-coordinator.js +2 -1
  168. package/dist/src/storage/restoration-coordinator.js.map +1 -1
  169. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
  170. package/dist/src/storage/ring-shift-coordinator.js +3 -3
  171. package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
  172. package/dist/src/storage/storage-repo.d.ts +122 -5
  173. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  174. package/dist/src/storage/storage-repo.js +245 -68
  175. package/dist/src/storage/storage-repo.js.map +1 -1
  176. package/dist/src/storage/struct.d.ts +58 -0
  177. package/dist/src/storage/struct.d.ts.map +1 -1
  178. package/dist/src/testing/mesh-harness.d.ts +77 -11
  179. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  180. package/dist/src/testing/mesh-harness.js +226 -100
  181. package/dist/src/testing/mesh-harness.js.map +1 -1
  182. package/package.json +2 -2
  183. package/readme.md +27 -3
  184. package/src/cluster/block-transfer-service.ts +132 -4
  185. package/src/cluster/block-transfer.ts +79 -84
  186. package/src/cluster/client.ts +115 -119
  187. package/src/cluster/cluster-policy.ts +2 -1
  188. package/src/cluster/cluster-repo.ts +493 -65
  189. package/src/cluster/commit-proof.ts +2 -2
  190. package/src/cluster/race-resolution.ts +76 -7
  191. package/src/cluster/rebalance-monitor.ts +677 -539
  192. package/src/cluster/spread-on-churn.ts +34 -54
  193. package/src/cohort-topic/host.ts +8 -6
  194. package/src/cohort-topic/membership-source.ts +1 -1
  195. package/src/cohort-topic/peer-codec.ts +34 -31
  196. package/src/cohort-topic/stream-util.ts +39 -4
  197. package/src/cohort-topic/topic-router.ts +12 -2
  198. package/src/dispute/client.ts +17 -7
  199. package/src/index.ts +64 -59
  200. package/src/libp2p-key-network.ts +1377 -1260
  201. package/src/libp2p-node-base.ts +186 -27
  202. package/src/libp2p-node-rn.ts +31 -30
  203. package/src/libp2p-node.ts +37 -36
  204. package/src/logger.ts +6 -1
  205. package/src/matchmaking/query-transport.ts +9 -9
  206. package/src/network/network-manager-service.ts +16 -5
  207. package/src/network/relay-reservation.ts +636 -0
  208. package/src/pend-validation.ts +3 -2
  209. package/src/protocol-client.ts +13 -1
  210. package/src/reactivity/forwarder-host.ts +2 -1
  211. package/src/reactivity/origination-manager.ts +3 -3
  212. package/src/reactivity/recover-transport.ts +39 -22
  213. package/src/reactivity/subscription-manager.ts +2 -2
  214. package/src/reactivity/topic-bytes.ts +35 -37
  215. package/src/repo/client.ts +172 -173
  216. package/src/repo/cluster-coordinator.ts +183 -25
  217. package/src/repo/coordinator-repo.ts +656 -506
  218. package/src/repo/i-under-replication-ledger.ts +89 -0
  219. package/src/repo/kv-under-replication-ledger.ts +302 -0
  220. package/src/repo/responsibility.ts +48 -0
  221. package/src/repo/service.ts +76 -39
  222. package/src/repo/stuck-reservation.ts +224 -0
  223. package/src/repo/under-replication-drain.ts +582 -0
  224. package/src/rn.ts +74 -69
  225. package/src/storage/block-latch.ts +7 -4
  226. package/src/storage/block-storage.ts +161 -10
  227. package/src/storage/i-block-storage.ts +60 -5
  228. package/src/storage/pending-claim.ts +200 -0
  229. package/src/storage/raw-store-codec.ts +26 -3
  230. package/src/storage/restoration-coordinator.ts +212 -211
  231. package/src/storage/ring-shift-coordinator.ts +271 -271
  232. package/src/storage/storage-repo.ts +280 -72
  233. package/src/storage/struct.ts +58 -0
  234. package/src/testing/mesh-harness.ts +293 -112
@@ -1,4 +1,4 @@
1
- import { blockIdsForTransforms, isOwnRevision, isConflictFailure, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
1
+ import { blockIdsForTransforms, transformForBlockId, isOwnRevision, isConflictFailure, DEFAULT_SUPER_MAJORITY_THRESHOLD, localDurability } from "@optimystic/db-core";
2
2
  import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clusterVoteSigningPayload, clusterVoteVerificationPayload } from "@optimystic/db-core";
3
3
  import { verifyInvalidationCertificate } from "../dispute/invalidation.js";
4
4
  import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
@@ -10,6 +10,8 @@ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } fr
10
10
  import { createLogger } from '../logger.js';
11
11
  import { PenaltyReason } from "../reputation/types.js";
12
12
  import { isMissingBaseRevisionFailure } from "../storage/storage-repo.js";
13
+ import { isReservationAgainst, reservationRequestFor, cohortCanMissAPend } from "../storage/pending-claim.js";
14
+ import { StuckReservationTracker } from "../repo/stuck-reservation.js";
13
15
  import { checkPendValidation } from "../pend-validation.js";
14
16
  import { getAffectedBlockIds } from "./record-operations.js";
15
17
  import { operationsConflict, resolveRace } from "./race-resolution.js";
@@ -28,6 +30,19 @@ var TransactionPhase;
28
30
  TransactionPhase[TransactionPhase["ConflictSuperseded"] = 6] = "ConflictSuperseded";
29
31
  TransactionPhase[TransactionPhase["Propagating"] = 7] = "Propagating"; // Transaction is being propagated
30
32
  })(TransactionPhase || (TransactionPhase = {}));
33
+ /** A refusal that judges the record invalid — the permanent kind, and every kind but the pending-conflict one. */
34
+ function invalidVerdict(reason) {
35
+ return { valid: false, kind: 'invalid', reason };
36
+ }
37
+ /**
38
+ * Widen a boolean-plus-prose check into a {@link PromiseVerdict}. Every check written this way judges
39
+ * validity, so its refusal is the permanent kind; only the pending-conflict branch of
40
+ * {@link ClusterMember.validatePendOperations} produces the transient one, and it builds its verdict
41
+ * directly.
42
+ */
43
+ function verdictOf(result) {
44
+ return result.valid ? { valid: true } : invalidVerdict(result.reason);
45
+ }
31
46
  /** Stable reject reason a member emits when a declared peer set fails the membership admission gate. */
32
47
  export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
33
48
  /**
@@ -37,6 +52,13 @@ export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
37
52
  * so the rejection itself is integrity-protected.
38
53
  */
39
54
  export const CONTENT_DIGEST_MISMATCH = 'content-digest-mismatch';
55
+ /**
56
+ * Stable reject reason a member emits when a commit declares, for a block, a base revision
57
+ * (`CommitRequest.blockDigests[id].baseRev`) other than the one the same action's pend carried to
58
+ * this member (`PendRequest.baseRevs`, kept with the pending record) — see
59
+ * `ClusterMember.validateCommitBaseDeclarations`. Signed like its sibling above.
60
+ */
61
+ export const BASE_DECLARATION_DISAGREES = 'base-declaration-disagrees';
40
62
  /**
41
63
  * The two stable reject reasons a validator-configured member emits from the shared
42
64
  * {@link checkPendValidation}: `PEND_NOT_VALIDATABLE` for a pend carrying no `validation` payload
@@ -46,7 +68,7 @@ export const CONTENT_DIGEST_MISMATCH = 'content-digest-mismatch';
46
68
  */
47
69
  export { PEND_NOT_VALIDATABLE, VALIDATOR_FAULT } from "../pend-validation.js";
48
70
  export function clusterMember(components) {
49
- return new ClusterMember(components.storageRepo, components.peerNetwork, components.peerId, components.privateKey, components.protocolPrefix, components.partitionDetector, components.fretService, components.validator, components.reputation, components.consensusConfig, components.stateStore, components.reconcileBlock, components.onCommitCertificate, components.onInvalidate, components.recomputeArbitratorSet, components.deriveExpectedCluster, components.now);
71
+ return new ClusterMember(components.storageRepo, components.peerNetwork, components.peerId, components.privateKey, components.protocolPrefix, components.partitionDetector, components.fretService, components.validator, components.reputation, components.consensusConfig, components.stateStore, components.reconcileBlock, components.onCommitCertificate, components.onInvalidate, components.recomputeArbitratorSet, components.deriveExpectedCluster, components.now, components.onCommittedHolders);
50
72
  }
51
73
  // How long to keep executed transaction records (10 minutes)
52
74
  const ExecutedTransactionTtlMs = 10 * 60 * 1000;
@@ -90,6 +112,7 @@ export class ClusterMember {
90
112
  onInvalidate;
91
113
  recomputeArbitratorSet;
92
114
  deriveExpectedCluster;
115
+ onCommittedHolders;
93
116
  // Track active transactions by their message hash
94
117
  activeTransactions = new Map();
95
118
  // Track executed consensus transactions to prevent duplicate execution (messageHash -> executedAt timestamp)
@@ -111,6 +134,12 @@ export class ClusterMember {
111
134
  // applyConsensusOperation swallowed a refusal whose real cause was a RIVAL action holding the
112
135
  // requested revision. Pruned alongside executedTransactions (same TTL).
113
136
  executedCommitResults = new Map();
137
+ // The messageHashes whose retained commit refusal above has the BEHIND shape (missing pend or
138
+ // missing base — this member held no usable revision), as opposed to the ahead shape, which must
139
+ // never be reconciled downward. Recorded from the apply branch that produced the refusal rather
140
+ // than read back from its prose. Only these are eligible for reconcileRefusedCommit. Same TTL,
141
+ // pruning and rollback as executedCommitResults.
142
+ behindCommitRefusals = new Set();
114
143
  // Conflict-shaped pend refusals this member's storage produced at consensus-apply, keyed by the
115
144
  // refused action's id rather than by messageHash. The messageHash-keyed map above cannot serve the
116
145
  // commit-promise guard: a commit is a DIFFERENT message with a different hash, so a member holding
@@ -124,6 +153,14 @@ export class ClusterMember {
124
153
  // inside the sink); this map only spares redundant work when the same invalidation reaches
125
154
  // consensus twice (rebroadcast / sync) under different message hashes. (-> appliedAt timestamp)
126
155
  appliedInvalidations = new Map();
156
+ /**
157
+ * Per block, what this member's own `held` votes have added up to — the member-side instance of
158
+ * the counter `CoordinatorRepo.noteStuckReservation` keeps from the coordinator's vantage. Fed by
159
+ * every `held` verdict {@link validatePendOperations} casts, so a reservation only THIS member
160
+ * holds (one the coordinator's storage cannot corroborate) is still named, once, where the record
161
+ * lives. Forgotten for a block the moment a vote finds it no longer reserved.
162
+ */
163
+ stuckReservations = new StuckReservationTracker(1000);
127
164
  // Queue of transactions to clean up
128
165
  cleanupQueue = [];
129
166
  // Serialize concurrent updates for the same transaction
@@ -159,7 +196,7 @@ export class ClusterMember {
159
196
  now;
160
197
  constructor(storageRepo, peerNetwork, peerId, privateKey, protocolPrefix,
161
198
  // Reserved for partition-healing (backlog ticket 6.5-partition-healing); held but not yet consumed.
162
- _partitionDetector, fretService, validator, reputation, consensusConfig, stateStore, reconcileBlock, onCommitCertificate, onInvalidate, recomputeArbitratorSet, deriveExpectedCluster, now) {
199
+ _partitionDetector, fretService, validator, reputation, consensusConfig, stateStore, reconcileBlock, onCommitCertificate, onInvalidate, recomputeArbitratorSet, deriveExpectedCluster, now, onCommittedHolders) {
163
200
  this.storageRepo = storageRepo;
164
201
  this.peerNetwork = peerNetwork;
165
202
  this.peerId = peerId;
@@ -174,6 +211,7 @@ export class ClusterMember {
174
211
  this.onInvalidate = onInvalidate;
175
212
  this.recomputeArbitratorSet = recomputeArbitratorSet;
176
213
  this.deriveExpectedCluster = deriveExpectedCluster;
214
+ this.onCommittedHolders = onCommittedHolders;
177
215
  this.now = now ?? (() => Date.now());
178
216
  this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD;
179
217
  this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
@@ -224,6 +262,7 @@ export class ClusterMember {
224
262
  this.cleanupQueue.length = 0;
225
263
  this.executedPendResults.clear();
226
264
  this.executedCommitResults.clear();
265
+ this.behindCommitRefusals.clear();
227
266
  this.refusedPendActions.clear();
228
267
  }
229
268
  /**
@@ -377,6 +416,15 @@ export class ClusterMember {
377
416
  });
378
417
  // Our own vote can be terminal (a reject where maxAllowedRejections is 0) or complete
379
418
  // the super-majority — recompute rather than guess which.
419
+ //
420
+ // Deliberately NOT the conflict vote's `shouldPersist = false`, for a `held` vote or
421
+ // any other. A conflict vote clears the record because this member holds the WINNER
422
+ // in `activeTransactions` and persisting the loser would reserve the same blocks a
423
+ // second time. A `held` vote has no such twin: the rival lives in durable storage,
424
+ // not in that table, so this record's entry is the only one and dropping it would
425
+ // only make this member forget a transaction the rest of the cohort may still
426
+ // carry to super-majority. Where one `held` vote IS terminal (a small cohort), the
427
+ // recomputed phase is `ConflictSuperseded`, which clears the record anyway.
380
428
  continue;
381
429
  case TransactionPhase.OurConflictVoteNeeded:
382
430
  currentRecord = await this.handleConflictVoteNeeded(currentRecord, phaseResult.conflictsWith);
@@ -429,7 +477,7 @@ export class ClusterMember {
429
477
  shouldPersist = false;
430
478
  break phaseLoop;
431
479
  case TransactionPhase.Promising:
432
- // We have already voted (approve, reject, or conflict); the record is still
480
+ // We have already voted (approve, reject, conflict, or held); the record is still
433
481
  // collecting promises from the rest of the cohort. Nothing to add — retain the
434
482
  // record only if our vote wasn't a conflict (`shouldPersist` already reflects that).
435
483
  log('cluster-member:phase-promising-waiting', {
@@ -565,8 +613,9 @@ export class ClusterMember {
565
613
  }
566
614
  /**
567
615
  * Compares existing vs incoming signatures for the same peers.
568
- * If a peer's vote type changed (approve↔reject), that's equivocation:
569
- * report a penalty and keep the first-seen signature.
616
+ * ANY change of vote type is equivocation — the comparison is over `Signature['type']`, so it
617
+ * covers every kind without enumerating them (a new kind joins the check by existing): report a
618
+ * penalty and keep the first-seen signature.
570
619
  * New peers are accepted normally.
571
620
  */
572
621
  detectEquivocation(existing, incoming, phase, messageHash) {
@@ -742,18 +791,20 @@ export class ClusterMember {
742
791
  const superMajority = Math.ceil(peerCount * this.superMajorityThreshold);
743
792
  const maxAllowedRejections = peerCount - superMajority;
744
793
  // Check for rejections — rejected if too many rejections to ever reach super-majority.
745
- // ONLY `reject` votes count here: a `conflict` vote is "not now", never a validity
746
- // judgement, so it must not push a record into the permanent `Rejected` phase.
794
+ // ONLY `reject` votes count here: `conflict` and `held` both mean "not now", never a validity
795
+ // judgement, so neither may push a record into the permanent `Rejected` phase.
747
796
  const rejectedPromises = Object.values(record.promises).filter(s => s.type === 'reject');
748
- const conflictPromises = Object.values(record.promises).filter(s => s.type === 'conflict');
797
+ const retryableRefusals = Object.values(record.promises).filter(s => s.type === 'conflict' || s.type === 'held');
749
798
  const rejectedCommits = Object.values(record.commits).filter(s => s.type === 'reject');
750
799
  if (rejectedPromises.length > maxAllowedRejections || this.hasMajority(rejectedCommits.length, peerCount)) {
751
800
  return { phase: TransactionPhase.Rejected };
752
801
  }
753
- // Conflict votes don't judge validity, but enough of them still make super-majority
802
+ // Retryable refusals don't judge validity, but enough of them still make super-majority
754
803
  // unreachable — a distinct terminal outcome (retryable as a fresh transaction) so logs and
755
- // reputation-adjacent paths keep meaning what they say.
756
- if (conflictPromises.length > 0 && rejectedPromises.length + conflictPromises.length > maxAllowedRejections) {
804
+ // reputation-adjacent paths keep meaning what they say. `held` joins `conflict` here for the
805
+ // same reason `conflict` clears its record: a member that keeps reserving the blocks of a
806
+ // transaction that provably cannot win holds them against the very retry meant to win.
807
+ if (retryableRefusals.length > 0 && rejectedPromises.length + retryableRefusals.length > maxAllowedRejections) {
757
808
  return { phase: TransactionPhase.ConflictSuperseded };
758
809
  }
759
810
  // Check if we need to vote. A lost race is answered with a conflict vote, not silence:
@@ -816,20 +867,17 @@ export class ClusterMember {
816
867
  // peer set is a legitimate cluster it belongs to, and refuses (reject vote) rather than rubber-stamping
817
868
  // a set the coordinator chose (e.g. a self-shrunk minority-partition set). On admission failure we skip
818
869
  // pend validation entirely and emit the membership rejection.
819
- const validationResult = await this.evaluatePromise(record);
820
- const promiseHash = await this.computePromiseHash(record);
821
- const type = validationResult.valid ? 'approve' : 'reject';
822
- const rejectReason = validationResult.valid ? undefined : validationResult.reason;
823
- const sig = await this.signVote(promiseHash, type, rejectReason);
824
- const signature = validationResult.valid
825
- ? { type: 'approve', signature: sig }
826
- : { type: 'reject', signature: sig, rejectReason };
827
- if (!validationResult.valid) {
828
- log('cluster-member:validation-rejected', {
870
+ const verdict = await this.evaluatePromise(record);
871
+ if (!verdict.valid) {
872
+ // Two tags, because the two refusals are two different events for an operator: one says the
873
+ // cohort judged a write invalid, the other says a write queued behind a live reservation.
874
+ log(verdict.kind === 'held' ? 'cluster-member:validation-held' : 'cluster-member:validation-rejected', {
829
875
  messageHash: record.messageHash,
830
- reason: validationResult.reason
876
+ reason: verdict.reason
831
877
  });
832
878
  }
879
+ const promiseHash = await this.computePromiseHash(record);
880
+ const signature = await this.signPromiseVerdict(promiseHash, verdict);
833
881
  return {
834
882
  ...record,
835
883
  promises: {
@@ -838,6 +886,22 @@ export class ClusterMember {
838
886
  }
839
887
  };
840
888
  }
889
+ /**
890
+ * Turn a {@link PromiseVerdict} into this member's signed promise vote — one vote kind per refusal
891
+ * kind. The single place the mapping lives, so a new refusal kind cannot reach the wire as the
892
+ * wrong vote: the coordinator's thresholds read only `Signature.type`, and until `held` existed the
893
+ * transient refusal had nowhere to go but `reject`.
894
+ */
895
+ async signPromiseVerdict(promiseHash, verdict) {
896
+ if (verdict.valid) {
897
+ return { type: 'approve', signature: await this.signVote(promiseHash, 'approve') };
898
+ }
899
+ if (verdict.kind === 'held') {
900
+ return { type: 'held', signature: await this.signVote(promiseHash, 'held', verdict.heldBy), heldBy: verdict.heldBy };
901
+ }
902
+ const rejectReason = verdict.reason;
903
+ return { type: 'reject', signature: await this.signVote(promiseHash, 'reject', rejectReason), rejectReason };
904
+ }
841
905
  /**
842
906
  * Answer a record that lost the deterministic race to a transaction this member already holds
843
907
  * (`docs/correctness.md` Theorems 1 & 9: the loser is TOLD it lost, not ignored — an unanswered
@@ -865,8 +929,10 @@ export class ClusterMember {
865
929
  }
866
930
  /**
867
931
  * The full promise-phase decision for a record: admit the declared membership FIRST, then (only if
868
- * admitted) validate its pend operations, then its commit operations. Failing any yields a
869
- * `{ valid:false, reason }` the caller turns into a `reject` vote. Keeping the three separate keeps
932
+ * admitted) validate its pend operations, then its commit operations. Failing any yields a refusal
933
+ * the caller turns into a vote — a `reject` for every validity judgement here, and a `held` for the
934
+ * one transient refusal {@link validatePendOperations} can make (see {@link PromiseVerdict}).
935
+ * Keeping the checks separate keeps
870
936
  * the reason strings distinct — a `membership-not-admitted` reject is a different signal (feeds the
871
937
  * dispute path) than a stale-revision / custom-validator reject, which is different again from a
872
938
  * `content-digest-mismatch` (see {@link validateCommitOperations}). A record carries pend OR commit
@@ -875,7 +941,7 @@ export class ClusterMember {
875
941
  async evaluatePromise(record) {
876
942
  const admission = await this.admitMembership(record);
877
943
  if (!admission.admit) {
878
- return { valid: false, reason: admission.reason ?? MEMBERSHIP_NOT_ADMITTED };
944
+ return invalidVerdict(admission.reason ?? MEMBERSHIP_NOT_ADMITTED);
879
945
  }
880
946
  const pendValidation = await this.validatePendOperations(record);
881
947
  if (!pendValidation.valid) {
@@ -887,7 +953,7 @@ export class ClusterMember {
887
953
  // declared one).
888
954
  const commitRevValidation = await this.validateCommitRevisions(record);
889
955
  if (!commitRevValidation.valid) {
890
- return commitRevValidation;
956
+ return verdictOf(commitRevValidation);
891
957
  }
892
958
  // Then our own refusal history: a commit whose pend THIS member refused, where local state
893
959
  // still corroborates the refusal. Runs after the revision check because that one is sharper
@@ -895,9 +961,16 @@ export class ClusterMember {
895
961
  // applied here; this arm covers the window where it has not.
896
962
  const refusedPendValidation = await this.validateCommitAgainstRefusedPend(record);
897
963
  if (!refusedPendValidation.valid) {
898
- return refusedPendValidation;
964
+ return verdictOf(refusedPendValidation);
965
+ }
966
+ // The declared base before the declared content: it needs no materialization, and when the two
967
+ // disagree the digest check would be previewing operations computed against a different base
968
+ // than the one declared — its verdict names the symptom, this one names the cause.
969
+ const baseDeclarationValidation = await this.validateCommitBaseDeclarations(record);
970
+ if (!baseDeclarationValidation.valid) {
971
+ return verdictOf(baseDeclarationValidation);
899
972
  }
900
- return await this.validateCommitOperations(record);
973
+ return verdictOf(await this.validateCommitOperations(record));
901
974
  }
902
975
  /**
903
976
  * Membership admission gate. Decides whether the coordinator-declared peer set (`record.peers`, call it
@@ -1185,9 +1258,19 @@ export class ClusterMember {
1185
1258
  }
1186
1259
  /**
1187
1260
  * Validates pend operations in a cluster record using the transaction validator.
1188
- * Also checks for stale revisions, and for blocks held by a different unresolved pending
1261
+ * Also checks for stale revisions, and for blocks RESERVED by a different unresolved pending
1189
1262
  * action, to prevent consensus on operations that storage would refuse at apply.
1190
1263
  * Returns success if no validator is configured (backwards compatibility).
1264
+ *
1265
+ * Every refusal here is a validity judgement EXCEPT the pending-conflict one, which is transient by
1266
+ * construction and returns the `held` kind — see {@link PromiseVerdict}. Transient means the
1267
+ * reservation it answers is one its holder is still going to commit or cancel: a record claiming
1268
+ * the slot this pend wants, or a later one. A record claiming a slot the collection has already
1269
+ * moved past is NOT a reservation against this pend and is not refused on — see
1270
+ * {@link reservingRivals} — because its holder is never going to remove it: the holder either
1271
+ * committed at that slot on the rest of the cohort (this member missed the commit) or lost the
1272
+ * slot, and in both cases the record would otherwise refuse every later write to the block, from
1273
+ * every writer, for as long as this member lives.
1191
1274
  */
1192
1275
  async validatePendOperations(record) {
1193
1276
  // Find pend operations in the message
@@ -1214,7 +1297,7 @@ export class ClusterMember {
1214
1297
  blockId,
1215
1298
  reason: blockResult.unavailable
1216
1299
  });
1217
- return { valid: false, reason: `block ${blockId} unavailable (${blockResult.unavailable}): cannot verify revision` };
1300
+ return invalidVerdict(`block ${blockId} unavailable (${blockResult.unavailable}): cannot verify revision`);
1218
1301
  }
1219
1302
  const latest = blockResult?.state?.latest;
1220
1303
  if (latest !== undefined && latest.rev >= pendRequest.rev) {
@@ -1237,18 +1320,18 @@ export class ClusterMember {
1237
1320
  // is NOT a StaleFailure producer, so StaleFailure.staleAt does not apply; the
1238
1321
  // coordinator's own local re-read (CoordinatorRepo.classifyStaleRejection)
1239
1322
  // supplies that number when it can confirm the revision itself.
1240
- return { valid: false, reason: `stale revision: block ${blockId} at rev ${latest.rev}, requested rev ${pendRequest.rev}` };
1323
+ return invalidVerdict(`stale revision: block ${blockId} at rev ${latest.rev}, requested rev ${pendRequest.rev}`);
1241
1324
  }
1242
1325
  }
1243
1326
  }
1244
- // Reject a pend whose blocks are held by a DIFFERENT unresolved pending action. This is
1245
- // the durable reservation the in-memory table (`findConflict` / `activeTransactions`)
1327
+ // Refuse a pend whose blocks are RESERVED by a DIFFERENT unresolved pending action. This
1328
+ // is the durable reservation the in-memory table (`findConflict` / `activeTransactions`)
1246
1329
  // cannot provide: that table clears the moment the rival's PEND record reaches
1247
1330
  // consensus, but the rival's storage pending record — written at pend-apply, removed at
1248
1331
  // commit or cancel — spans exactly the pend→commit window in which `latest.rev` has not
1249
1332
  // yet advanced. Storage's own pend would refuse this request at consensus-apply for the
1250
- // same reason (`StorageRepo.pend`'s listPendingTransactions scan); voting reject here
1251
- // moves that verdict into the phase where the cohort aggregates it, so the loser is
1333
+ // same reason (`StorageRepo.pend`'s pending-claim scan, under the same rule); voting
1334
+ // here moves that verdict into the phase where the cohort aggregates it, so the loser is
1252
1335
  // refused with a real answer instead of burning a consensus round it cannot win. A
1253
1336
  // member that has not yet applied the rival's pend has no record and simply abstains
1254
1337
  // from this reason; the apply-time verdict catches that residual — retained locally
@@ -1257,19 +1340,53 @@ export class ClusterMember {
1257
1340
  // member. Self is excluded so a redelivered pend
1258
1341
  // for this same action stays approvable. An unavailable block carries no `pendings` and
1259
1342
  // abstains (the rev branch above already fail-closes when a revision claim is at stake).
1260
- // Reason stays plain prose: it is fed to computeSigningPayload and carried as
1261
- // Signature.rejectReason, exactly like the stale-revision reason above.
1343
+ //
1344
+ // This is the ONE refusal in this method that is not a validity judgement: a RESERVING
1345
+ // rival's record is removed the moment it commits or cancels, so the very same pend
1346
+ // succeeds on retry. It therefore returns the `held` kind, which becomes a `held` vote
1347
+ // the coordinator counts toward neither approvals nor rejections. `heldBy` carries the
1348
+ // first rival as signed structured data; the prose reason names the same one, and stays
1349
+ // prose because it is fed to computeSigningPayload exactly like the reasons above.
1350
+ //
1351
+ // Which rivals reserve is decided by the slot each record claims against what the
1352
+ // incoming writer built on, not by the record's presence (`reservingRivals`): a record
1353
+ // whose slot is at or below the base this pend declares for the block — or, for a pend
1354
+ // naming no base, below the revision it requests — belongs to a commit this member missed
1355
+ // or a race its holder lost, and would never clear on its own; refusing on it wedged the
1356
+ // block for every writer (ticket `a-member-that-missed-a-commit-refuses-every-later-write`).
1357
+ // Approving over it is safe because the incoming writer's operations were computed
1358
+ // against a version of the block that already holds that record's change. This member
1359
+ // comes current when the approved pend's own commit applies here — through
1360
+ // `internalCommit`, or through the behind-reconcile its fork guard triggers. In a cohort
1361
+ // that can reach its promise bar without one member (`cohortCanMissAPend`: four members up
1362
+ // at the default threshold), a record claiming a slot PAST the declared base still
1363
+ // reserves even when the requested revision has moved beyond it: that is a writer that
1364
+ // read the block without the record's change — served by a member that never held the
1365
+ // rival's pend — and admitting it would lose the change. See `isReservationAgainst`.
1262
1366
  for (const blockId of blockIds) {
1263
- const rivals = (blockResults[blockId]?.state?.pendings ?? []).filter(actionId => actionId !== pendRequest.actionId);
1264
- if (rivals.length > 0) {
1265
- log('cluster-member:validation-pending-conflict', {
1266
- messageHash: record.messageHash,
1267
- blockId,
1268
- actionId: pendRequest.actionId,
1269
- rivals
1270
- });
1271
- return { valid: false, reason: `pending conflict: block ${blockId} held by unresolved action(s) ${rivals.join(', ')}` };
1367
+ const rivalIds = (blockResults[blockId]?.state?.pendings ?? []).filter(actionId => actionId !== pendRequest.actionId);
1368
+ const rivals = rivalIds.length === 0 ? [] : await this.reservingRivals(record, blockId, rivalIds, pendRequest);
1369
+ if (rivals.length === 0) {
1370
+ // Not reserved (any more): whatever episode this member was counting on the block
1371
+ // has ended, so a later wedge gets its own count.
1372
+ this.stuckReservations.forget([blockId]);
1373
+ continue;
1272
1374
  }
1375
+ const heldBy = rivals[0];
1376
+ log('cluster-member:validation-pending-conflict', {
1377
+ messageHash: record.messageHash,
1378
+ blockId,
1379
+ actionId: pendRequest.actionId,
1380
+ requestedRev: pendRequest.rev,
1381
+ rivals
1382
+ });
1383
+ this.nameStuckReservation(blockId, rivals, pendRequest.actionId);
1384
+ return {
1385
+ valid: false,
1386
+ kind: 'held',
1387
+ heldBy,
1388
+ reason: `pending conflict: block ${blockId} held by unresolved action(s) ${rivals.join(', ')}`
1389
+ };
1273
1390
  }
1274
1391
  // Re-check the transaction when a validator is configured. The unvalidatable-pend
1275
1392
  // policy and the throwing-validator catch live in the shared `checkPendValidation`,
@@ -1290,12 +1407,94 @@ export class ClusterMember {
1290
1407
  error: event.error
1291
1408
  }));
1292
1409
  if (!validation.valid) {
1293
- return { valid: false, reason: validation.reason };
1410
+ return invalidVerdict(validation.reason);
1294
1411
  }
1295
1412
  }
1296
1413
  }
1297
1414
  return { valid: true };
1298
1415
  }
1416
+ /**
1417
+ * Of the rival pending records `get` listed on `blockId`, the ones that RESERVE the block against
1418
+ * `pendRequest` — see `isReservationAgainst` for the rule, fed the pend's revision and the base it
1419
+ * declares for this block (`reservationRequestFor`, the same reading storage applies at apply).
1420
+ * Asks storage's {@link IPendingClaimReader} for the slot each record claims; a repo without that
1421
+ * capability (a plain `IRepo` mock) or a read that fails degrades to "every rival reserves", which
1422
+ * is the refusal this member cast before claims were recorded — never to silently admitting one. A
1423
+ * rival that `get` listed but that is gone by the time the claims are read has resolved in
1424
+ * between, and is not a rival any more.
1425
+ */
1426
+ async reservingRivals(record, blockId, rivalIds, pendRequest) {
1427
+ const reader = this.storageRepo;
1428
+ if (typeof reader.listPendingClaims !== 'function') {
1429
+ return rivalIds;
1430
+ }
1431
+ let claims;
1432
+ try {
1433
+ claims = await reader.listPendingClaims(blockId);
1434
+ }
1435
+ catch (err) {
1436
+ log('cluster-member:pending-claims-read-error', { messageHash: record.messageHash, blockId, error: err.message });
1437
+ return rivalIds;
1438
+ }
1439
+ const reservation = this.reservationRequestOf(record, pendRequest, blockId);
1440
+ const claimOf = new Map(claims.map(claim => [claim.actionId, claim]));
1441
+ const reserving = [];
1442
+ for (const actionId of rivalIds) {
1443
+ const claim = claimOf.get(actionId);
1444
+ if (claim === undefined)
1445
+ continue;
1446
+ if (isReservationAgainst(claim, reservation)) {
1447
+ reserving.push(actionId);
1448
+ }
1449
+ else {
1450
+ log('cluster-member:validation-pending-superseded', {
1451
+ messageHash: record.messageHash,
1452
+ blockId,
1453
+ actionId: pendRequest.actionId,
1454
+ requestedRev: pendRequest.rev,
1455
+ baseRev: reservation.baseRev,
1456
+ rival: actionId,
1457
+ claimedRev: claim.rev
1458
+ });
1459
+ }
1460
+ }
1461
+ return reserving;
1462
+ }
1463
+ /** `pendRequest`'s {@link ReservationRequest} for `blockId`, logging a base the rule cannot read —
1464
+ * never a refusal: `baseRevs` is untrusted wire data, and a malformed entry only drops that block
1465
+ * back to the revision rule. The base is read only when `record`'s cohort can reach its promise
1466
+ * super-majority without one of its members (`cohortCanMissAPend`); in a cohort that needs every
1467
+ * member, a stray record on one member (a cancel that never reached it) would otherwise refuse
1468
+ * every later writer for good, and no member can have missed the pend the base arm guards against. */
1469
+ reservationRequestOf(record, pendRequest, blockId) {
1470
+ if (!cohortCanMissAPend(Object.keys(record.peers).length, this.superMajorityThreshold)) {
1471
+ return { rev: pendRequest.rev };
1472
+ }
1473
+ const { request, ignoredBase } = reservationRequestFor(pendRequest, blockId, transformForBlockId(pendRequest.transforms, blockId));
1474
+ if (ignoredBase !== undefined) {
1475
+ log('cluster-member:pend-base-ignored', {
1476
+ messageHash: record.messageHash,
1477
+ blockId,
1478
+ actionId: pendRequest.actionId,
1479
+ requestedRev: pendRequest.rev,
1480
+ base: ignoredBase
1481
+ });
1482
+ }
1483
+ return request;
1484
+ }
1485
+ /**
1486
+ * Feed this member's own `held` vote to its {@link stuckReservations} counter and say, once per
1487
+ * episode and in words, when a block is wedged behind a reservation that is not going to clear —
1488
+ * the member-side twin of `CoordinatorRepo.noteStuckReservation`, for the reservations that
1489
+ * node cannot corroborate from its own storage. `peerId` is carried because this logger is not
1490
+ * suffixed with one and several members can share a process.
1491
+ */
1492
+ nameStuckReservation(blockId, holders, refusedActionId) {
1493
+ const { named } = this.stuckReservations.note(new Map([[blockId, holders]]), refusedActionId);
1494
+ for (const episode of named) {
1495
+ log('cluster-member:stuck-reservation', { peerId: this.peerId.toString(), ...episode });
1496
+ }
1497
+ }
1299
1498
  /**
1300
1499
  * Promise-round check that a commit record's requested revision is not already committed HERE
1301
1500
  * under a different action. This is the member-side arm that keeps a DEAD rival's re-broadcast
@@ -1509,6 +1708,113 @@ export class ClusterMember {
1509
1708
  }
1510
1709
  return undefined;
1511
1710
  }
1711
+ /**
1712
+ * Promise-round check that a commit declares, for each block, the base revision the same action's
1713
+ * PEND carried to this member. The author says the base twice — `PendRequest.baseRevs[id]`, kept
1714
+ * with this member's pending record as `PendingClaim.baseRev`, and
1715
+ * `CommitRequest.blockDigests[id].baseRev` — and an honest author says the same thing both times
1716
+ * (`Tracker` pins the base at staging; the pend and the digest both read that pin). When they
1717
+ * differ, this member's record holds operations computed against a base other than the one the
1718
+ * commit is about to be applied as, so vote reject with {@link BASE_DECLARATION_DISAGREES}.
1719
+ * `StorageRepo.internalCommit` refuses the same shape at apply (`guardCommitBase`); refusing here
1720
+ * too puts a signed verdict on the record one round earlier, and when the cohort's records agree
1721
+ * with each other but not with the commit (a writer whose commit contradicts its own pend) the
1722
+ * whole cohort refuses at the vote, where the apply-time refusal alone would have every member
1723
+ * refuse after consensus and reconcile against a revision nobody holds.
1724
+ *
1725
+ * Needs no preview, so it runs on a repo that can read one record's claim
1726
+ * ({@link IPendingClaimReader}`.pendingClaimOf`) whether or not it can materialize. Abstains — votes
1727
+ * as it would without the check — whenever it cannot compare: the commit declares nothing, an entry
1728
+ * is surplus to `blockIds` or carries no numeric `baseRev` (untrusted wire data, same posture as the
1729
+ * digest check), this member holds no record for the action on the block, the record carries no
1730
+ * base (an inserted or deleted block, a base-less sender, or a record written before bases were
1731
+ * kept), the read fails — or this member does not hold the declared base as its latest.
1732
+ *
1733
+ * That last abstain is what keeps an HONEST retry off this reject. The one disagreement an honest
1734
+ * writer can meet here is a stale record from an earlier attempt of a retried action (same action
1735
+ * id) meeting the retry's commit, when the retry's pend — which would have overwritten the record
1736
+ * — never reached this member. Such a member is typically behind: it missed a pend, and the retry
1737
+ * declares a base the rest of the cohort moved to. At three members one reject sinks the commit
1738
+ * record (the default super-majority allows none), where abstaining lets the others commit and
1739
+ * leaves this member to `guardCommitBase`, which refuses to apply the stale record and reconciles.
1740
+ * When this member's latest IS the declared base, the stale operations are exactly what it would
1741
+ * apply over it, and the digest check below rejects the same shape whenever they materialize
1742
+ * differently; rejecting here names the cause instead.
1743
+ */
1744
+ async validateCommitBaseDeclarations(record) {
1745
+ const reader = this.storageRepo;
1746
+ if (typeof reader.pendingClaimOf !== 'function') {
1747
+ return { valid: true };
1748
+ }
1749
+ for (const operation of record.message.operations) {
1750
+ if (!('commit' in operation)) {
1751
+ continue;
1752
+ }
1753
+ const commit = operation.commit;
1754
+ if (!commit.blockDigests) {
1755
+ continue;
1756
+ }
1757
+ const committedIds = new Set(commit.blockIds);
1758
+ for (const [blockId, declared] of Object.entries(commit.blockDigests)) {
1759
+ const declaredBaseRev = declared?.baseRev;
1760
+ if (!committedIds.has(blockId) || typeof declaredBaseRev !== 'number') {
1761
+ continue;
1762
+ }
1763
+ let storedBaseRev;
1764
+ try {
1765
+ storedBaseRev = (await reader.pendingClaimOf(blockId, commit.actionId))?.baseRev;
1766
+ }
1767
+ catch (err) {
1768
+ log('cluster-member:base-declaration-read-error', {
1769
+ messageHash: record.messageHash,
1770
+ blockId,
1771
+ error: err instanceof Error ? err.message : String(err)
1772
+ });
1773
+ continue; // a local read fault is an abstain, never a verdict
1774
+ }
1775
+ if (storedBaseRev === undefined || storedBaseRev === declaredBaseRev) {
1776
+ continue;
1777
+ }
1778
+ const latestRev = await this.latestRevOf(record, blockId);
1779
+ if (latestRev !== declaredBaseRev) {
1780
+ log('cluster-member:base-declaration-disagrees-abstained', {
1781
+ messageHash: record.messageHash,
1782
+ blockId,
1783
+ actionId: commit.actionId,
1784
+ storedBaseRev,
1785
+ declaredBaseRev,
1786
+ latestRev
1787
+ });
1788
+ continue;
1789
+ }
1790
+ log('cluster-member:base-declaration-disagrees', {
1791
+ messageHash: record.messageHash,
1792
+ blockId,
1793
+ actionId: commit.actionId,
1794
+ rev: commit.rev,
1795
+ storedBaseRev,
1796
+ declaredBaseRev
1797
+ });
1798
+ return { valid: false, reason: BASE_DECLARATION_DISAGREES };
1799
+ }
1800
+ }
1801
+ return { valid: true };
1802
+ }
1803
+ /** This member's own committed revision of `blockId` (raw storage, no cluster recursion), or
1804
+ * `undefined` when it holds none or the read fails — a fault reads as "cannot judge". */
1805
+ async latestRevOf(record, blockId) {
1806
+ try {
1807
+ return (await this.storageRepo.get({ blockIds: [blockId] }))[blockId]?.state?.latest?.rev;
1808
+ }
1809
+ catch (err) {
1810
+ log('cluster-member:base-declaration-read-error', {
1811
+ messageHash: record.messageHash,
1812
+ blockId,
1813
+ error: err instanceof Error ? err.message : String(err)
1814
+ });
1815
+ return undefined;
1816
+ }
1817
+ }
1512
1818
  /**
1513
1819
  * Promise-round check of a commit record's declared content digests
1514
1820
  * (`CommitRequest.blockDigests`) against what this member's OWN pended copy of each transform
@@ -1703,6 +2009,7 @@ export class ClusterMember {
1703
2009
  this.executedTransactions.delete(record.messageHash);
1704
2010
  this.executedPendResults.delete(record.messageHash);
1705
2011
  this.executedCommitResults.delete(record.messageHash);
2012
+ this.behindCommitRefusals.delete(record.messageHash);
1706
2013
  for (const operation of record.message.operations) {
1707
2014
  if ('pend' in operation)
1708
2015
  this.refusedPendActions.delete(operation.pend.actionId);
@@ -1850,7 +2157,7 @@ export class ClusterMember {
1850
2157
  membershipVersion: record.membershipVersion
1851
2158
  });
1852
2159
  }
1853
- const applied = await this.applyCommitToStorage(record, commit, proof);
2160
+ const { applied, behind } = await this.applyCommitToStorage(record, commit, proof);
1854
2161
  // Retain the POST-RECONCILE durable verdict (see getExecutedCommitResult and
1855
2162
  // withOwnApplyOutcome): what this member's storage holds NOW, after any behind-reconcile the
1856
2163
  // apply triggered — not what the first apply attempt said. A member that pulled the
@@ -1859,7 +2166,7 @@ export class ClusterMember {
1859
2166
  // verdicts — its own member's through getExecutedCommitResult, every other member's off the
1860
2167
  // response record — and the two readers must see the same answer, hence one verdict,
1861
2168
  // computed once, retained here for both.
1862
- this.executedCommitResults.set(messageHash, await this.durableCommitVerdict(commit, applied));
2169
+ this.retainCommitVerdict(record, commit, await this.durableCommitVerdict(commit, applied), behind);
1863
2170
  return;
1864
2171
  }
1865
2172
  if ('invalidate' in operation) {
@@ -1870,10 +2177,12 @@ export class ClusterMember {
1870
2177
  /**
1871
2178
  * Apply one consensus commit to local storage, tolerating every divergence shape the way the
1872
2179
  * doc comment on {@link applyConsensusOperation} describes and reconciling the behind ones.
1873
- * Returns storage's own result — or, for the thrown missing-pend shape, which produces no
2180
+ * `applied` is storage's own result — or, for the thrown missing-pend shape, which produces no
1874
2181
  * `CommitResult` at all, a refusal built from the throw — so {@link durableCommitVerdict} works
1875
- * from one uniform shape. Genuine faults (a bare-reason returned failure, an unrecognized throw)
1876
- * propagate so {@link handleConsensus} rolls back the executed marker and rethrows.
2182
+ * from one uniform shape. `behind` is true exactly when one of the two behind branches ran, which
2183
+ * is what makes a refusal eligible for {@link reconcileRefusedCommit}. Genuine faults (a
2184
+ * bare-reason returned failure, an unrecognized throw) propagate so {@link handleConsensus} rolls
2185
+ * back the executed marker and rethrows.
1877
2186
  */
1878
2187
  async applyCommitToStorage(record, commit, proof) {
1879
2188
  const messageHash = record.messageHash;
@@ -1896,12 +2205,12 @@ export class ClusterMember {
1896
2205
  // cohort peer so the block is no longer under-replicated. Best-effort:
1897
2206
  // failures are logged inside, never thrown (a throw would reset the stream).
1898
2207
  await this.reconcileDivergentCommit(record, commit);
1899
- return { success: false, reason: err.message };
2208
+ return { applied: { success: false, reason: err.message }, behind: true };
1900
2209
  }
1901
2210
  throw err;
1902
2211
  }
1903
2212
  if (result.success) {
1904
- return result;
2213
+ return { applied: result, behind: false };
1905
2214
  }
1906
2215
  // success:false is a StaleFailure. `missing` ⇒ ahead/stale divergence
1907
2216
  // (we already hold ≥ this rev): tolerate, do NOT reconcile downward. A
@@ -1931,7 +2240,7 @@ export class ClusterMember {
1931
2240
  reason: result.reason,
1932
2241
  hasMissing: true
1933
2242
  });
1934
- return result;
2243
+ return { applied: result, behind: false };
1935
2244
  }
1936
2245
  // This member holds no materializable base for one of the blocks, so
1937
2246
  // `StorageRepo.commit` REFUSED rather than record a revision it could never serve.
@@ -1947,10 +2256,62 @@ export class ClusterMember {
1947
2256
  reason: result.reason
1948
2257
  });
1949
2258
  await this.reconcileDivergentCommit(record, commit);
1950
- return result;
2259
+ return { applied: result, behind: true };
1951
2260
  }
1952
2261
  throw new Error(`Consensus commit for action ${commit.actionId} failed: ${result.reason ?? 'unknown reason'}`);
1953
2262
  }
2263
+ /**
2264
+ * Retain `verdict` as this member's durable verdict for `record`'s commit (see
2265
+ * {@link executedCommitResults}), remember whether a refusal was the behind shape, and — once the
2266
+ * verdict is a success — report the committed holders. Shared by the consensus apply and
2267
+ * {@link reconcileRefusedCommit} so both leave the same state behind.
2268
+ */
2269
+ retainCommitVerdict(record, commit, verdict, behind) {
2270
+ this.executedCommitResults.set(record.messageHash, verdict);
2271
+ if (verdict.success) {
2272
+ this.behindCommitRefusals.delete(record.messageHash);
2273
+ this.reportCommittedHolders(record, commit);
2274
+ }
2275
+ else if (behind) {
2276
+ this.behindCommitRefusals.add(record.messageHash);
2277
+ }
2278
+ }
2279
+ /**
2280
+ * One more reconcile for a commit this member already applied at consensus but could not hold:
2281
+ * its retained verdict is a BEHIND-shaped refusal (missing pend or missing base), and the
2282
+ * reconcile that ran during the apply found no cohort peer holding the revision yet. The
2283
+ * coordinating node calls this for its own member once the remote members have applied and at
2284
+ * least one reported holding the revision. Its own delivery goes first (see
2285
+ * `ClusterCoordinator.broadcastMergedRecord`), so that first reconcile ran before anyone else
2286
+ * held anything. The remote holder's copy carries the cohort's commit proof, so the existing
2287
+ * certified single-holder path adopts it; no new trust is involved.
2288
+ *
2289
+ * Does nothing unless the retained verdict for `record.messageHash` is a behind refusal; in
2290
+ * particular an ahead-shaped refusal (`missing`) is never reconciled downward. Recomputes and
2291
+ * re-retains the durable verdict afterwards, so `getExecutedCommitResult` then answers a success
2292
+ * when the revision landed. Bounded by the same per-block reconcile timeout and never throws.
2293
+ */
2294
+ async reconcileRefusedCommit(record) {
2295
+ const messageHash = record.messageHash;
2296
+ const refused = this.executedCommitResults.get(messageHash);
2297
+ if (refused === undefined || refused.success || !this.behindCommitRefusals.has(messageHash)) {
2298
+ return;
2299
+ }
2300
+ const operation = record.message.operations.find((op) => 'commit' in op);
2301
+ if (operation === undefined) {
2302
+ return;
2303
+ }
2304
+ const commit = operation.commit;
2305
+ log('cluster-member:consensus-commit-reconcile-again', { messageHash, actionId: commit.actionId, rev: commit.rev });
2306
+ await this.reconcileDivergentCommit(record, commit);
2307
+ const verdict = await this.durableCommitVerdict(commit, refused);
2308
+ // The apply's retention may have been pruned or rolled back while the reconcile ran; never
2309
+ // resurrect it.
2310
+ if (this.executedCommitResults.get(messageHash) !== refused) {
2311
+ return;
2312
+ }
2313
+ this.retainCommitVerdict(record, commit, verdict, true);
2314
+ }
1954
2315
  /**
1955
2316
  * The durable verdict for a consensus commit: `applied` itself when storage landed it (a
1956
2317
  * success from `StorageRepo.commit` means every block is committed, or already was, under this
@@ -1980,7 +2341,10 @@ export class ClusterMember {
1980
2341
  return applied;
1981
2342
  }
1982
2343
  log('cluster-member:consensus-commit-durable-after-reconcile', { actionId: commit.actionId, rev: commit.rev });
1983
- return { success: true };
2344
+ // A member's own verdict about its own storage: `local`. Read by the coordinator for its
2345
+ // `success` flag only (`cohortCommitOutcomes`, `localCommitResult`) — it never becomes the
2346
+ // answer handed to the writer; `CoordinatorRepo.commit` computes the cohort's own on every exit.
2347
+ return { success: true, durability: localDurability() };
1984
2348
  }
1985
2349
  /**
1986
2350
  * True when local storage records `commit.rev` of `blockId` under `commit.actionId`. Read from
@@ -2094,6 +2458,29 @@ export class ClusterMember {
2094
2458
  log('cluster-member:commit-cert-sink-error', { actionId, error: err.message });
2095
2459
  }
2096
2460
  }
2461
+ /**
2462
+ * Tell the {@link CommittedHoldersSink} which cohort members hold a commit this member just
2463
+ * durably applied. A member learns nothing about the others' storage at apply time, so the
2464
+ * evidence is the record's approving commit signers: each is a cohort member that signed to apply
2465
+ * this commit. A signer that then failed to apply is wrongly recorded, which only spares it a
2466
+ * rebalance push; its own reconcile, read-repair, and — on the coordinating node — the
2467
+ * coordinator's durability-checked report (which lands after this one and overrides it) still
2468
+ * reach it.
2469
+ */
2470
+ reportCommittedHolders(record, commit) {
2471
+ if (!this.onCommittedHolders) {
2472
+ return;
2473
+ }
2474
+ const holders = Object.entries(record.commits)
2475
+ .filter(([peerId, vote]) => vote.type === 'approve' && peerId in record.peers)
2476
+ .map(([peerId]) => peerId);
2477
+ try {
2478
+ this.onCommittedHolders({ blockIds: commit.blockIds, holders });
2479
+ }
2480
+ catch (err) {
2481
+ log('cluster-member:committed-holders-sink-error', { actionId: commit.actionId, error: err.message });
2482
+ }
2483
+ }
2097
2484
  /**
2098
2485
  * After tolerating a "behind" commit divergence, pull the committed revision of
2099
2486
  * each block from a cohort peer that holds it and restore it locally. Best-effort:
@@ -2291,6 +2678,7 @@ export class ClusterMember {
2291
2678
  this.executedTransactions.delete(messageHash);
2292
2679
  this.executedPendResults.delete(messageHash);
2293
2680
  this.executedCommitResults.delete(messageHash);
2681
+ this.behindCommitRefusals.delete(messageHash);
2294
2682
  }
2295
2683
  }
2296
2684
  // Prune actionId-keyed pend refusals on the same TTL as the verdicts they were derived from.