@optimystic/db-p2p 0.24.2 → 0.25.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 (249) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,4 +1,4 @@
1
- import { blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
1
+ import { blockIdsForTransforms, isOwnRevision, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
2
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";
@@ -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 { checkPendValidation } from "../pend-validation.js";
14
+ import { buildBlockCommitProof } from "./commit-proof.js";
13
15
  import { RECONCILE_TIMEOUT_MS } from "./reconcile-block.js";
14
16
  const log = createLogger('cluster-member');
15
17
  /** State of a transaction in the cluster */
@@ -26,6 +28,21 @@ var TransactionPhase;
26
28
  })(TransactionPhase || (TransactionPhase = {}));
27
29
  /** Stable reject reason a member emits when a declared peer set fails the membership admission gate. */
28
30
  export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
31
+ /**
32
+ * Stable reject reason a member emits when its own materialization of a commit's block disagrees
33
+ * with the content digest the transaction author declared (`CommitRequest.blockDigests`). Rides in
34
+ * the reject vote's `rejectReason`, which `clusterVoteSigningPayload` folds into the signed bytes —
35
+ * so the rejection itself is integrity-protected.
36
+ */
37
+ export const CONTENT_DIGEST_MISMATCH = 'content-digest-mismatch';
38
+ /**
39
+ * The two stable reject reasons a validator-configured member emits from the shared
40
+ * {@link checkPendValidation}: `PEND_NOT_VALIDATABLE` for a pend carrying no `validation` payload
41
+ * under `ClusterConsensusConfig.unvalidatablePendPolicy: 'reject'`, and `VALIDATOR_FAULT` for a
42
+ * checker that threw. Defined in `pend-validation.ts` (which the storage tier runs too, so both
43
+ * tiers refuse with the same prefixes) and re-exported here next to its siblings above.
44
+ */
45
+ export { PEND_NOT_VALIDATABLE, VALIDATOR_FAULT } from "../pend-validation.js";
29
46
  export function clusterMember(components) {
30
47
  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);
31
48
  }
@@ -68,6 +85,19 @@ export class ClusterMember {
68
85
  activeTransactions = new Map();
69
86
  // Track executed consensus transactions to prevent duplicate execution (messageHash -> executedAt timestamp)
70
87
  executedTransactions = new Map();
88
+ // Local storage's verdict for a pend operation applied during consensus (messageHash -> PendResult).
89
+ // Retained so the coordinator can return storage's real answer instead of fabricating success —
90
+ // a pend that every member refused (rival pending action, or the revision already taken) must
91
+ // reach the writer as a conflict, not a win. Pruned alongside executedTransactions (same TTL).
92
+ executedPendResults = new Map();
93
+ // Local storage's verdict for a COMMIT operation applied during consensus (messageHash ->
94
+ // CommitResult). Retained so the coordinator can detect when the ahead-divergence tolerance in
95
+ // applyConsensusOperation swallowed a refusal whose real cause was a RIVAL action holding the
96
+ // requested revision — the commit-tier acknowledgement hole: a commit that assembled consensus
97
+ // inside every member's signed-but-not-yet-applied window is refused by every member's storage
98
+ // at apply, and without this verdict the coordinator fabricates a success no member durably
99
+ // stored. Pruned alongside executedTransactions (same TTL).
100
+ executedCommitResults = new Map();
71
101
  // Fast in-memory dedup for applied invalidations, keyed `${invalidatedActionId}:${disputeId}`.
72
102
  // The durable source of truth is the invalidation log entry (Log.findInvalidation, re-checked
73
103
  // inside the sink); this map only spares redundant work when the same invalidation reaches
@@ -101,6 +131,9 @@ export class ClusterMember {
101
131
  /** Operator-asserted smallest genuine cohort size, or undefined when unknown. */
102
132
  assumedClusterSize;
103
133
  allowUnvalidatedSmallCluster;
134
+ /** What a validator-configured member does with a pend carrying no `validation` payload — see
135
+ * {@link ClusterConsensusConfig.unvalidatablePendPolicy}. Read once, like the gate parameters. */
136
+ unvalidatablePendPolicy;
104
137
  constructor(storageRepo, peerNetwork, peerId, privateKey, protocolPrefix,
105
138
  // Reserved for partition-healing (backlog ticket 6.5-partition-healing); held but not yet consumed.
106
139
  _partitionDetector, fretService, validator, reputation, consensusConfig, stateStore, reconcileBlock, onCommitCertificate, onInvalidate, recomputeArbitratorSet, deriveExpectedCluster) {
@@ -124,6 +157,7 @@ export class ClusterMember {
124
157
  this.membershipAdmissionFraction = consensusConfig?.membershipAdmissionFraction ?? 0.75;
125
158
  this.assumedClusterSize = consensusConfig?.assumedClusterSize;
126
159
  this.allowUnvalidatedSmallCluster = consensusConfig?.allowUnvalidatedSmallCluster ?? false;
160
+ this.unvalidatablePendPolicy = consensusConfig?.unvalidatablePendPolicy ?? 'accept';
127
161
  // State the resolved gate parameters once, so an operator diagnosing a membership rejection can see
128
162
  // what this node actually resolved. A fact, not a warning: `assumedClusterSize < clusterSize` is the
129
163
  // normal default state, so warning on it would fire for every node and be ignored.
@@ -164,6 +198,8 @@ export class ClusterMember {
164
198
  }
165
199
  this.activeTransactions.clear();
166
200
  this.cleanupQueue.length = 0;
201
+ this.executedPendResults.clear();
202
+ this.executedCommitResults.clear();
167
203
  }
168
204
  /**
169
205
  * Checks if a transaction's operations were already executed during consensus.
@@ -172,6 +208,31 @@ export class ClusterMember {
172
208
  wasTransactionExecuted(messageHash) {
173
209
  return this.executedTransactions.has(messageHash);
174
210
  }
211
+ /**
212
+ * Local storage's verdict for the pend operation this member applied at consensus for
213
+ * `messageHash`, when one was retained. The coordinator reads this so the answer a writer gets
214
+ * is the answer storage gave — the cluster path must not fabricate a success the single-node
215
+ * path (`CoordinatorRepo.pend`'s `peerCount <= 1` short-circuit) would never produce. Absent for
216
+ * transactions carrying no pend operation, for transactions applied before this member restarted
217
+ * (the map is in-memory only; the coordinator then falls back to its fabricated-success shape,
218
+ * with the promise-phase pending check in {@link validatePendOperations} narrowing that window),
219
+ * and after the executed-transaction TTL prunes it.
220
+ */
221
+ getExecutedPendResult(messageHash) {
222
+ return this.executedPendResults.get(messageHash);
223
+ }
224
+ /**
225
+ * Commit-shaped sibling of {@link getExecutedPendResult}: local storage's verdict for a commit
226
+ * operation applied during consensus, when this member retained one. `CoordinatorRepo.commit`
227
+ * consults it after a locally-executed commit-consensus — a retained refusal whose cause a local
228
+ * re-read confirms as a rival holding the requested revision is returned to the writer as a
229
+ * retryable conflict instead of the fabricated success the ahead-divergence tolerance would
230
+ * otherwise imply. Same availability caveats as the pend accessor: in-memory only, absent for
231
+ * pre-restart applies, pruned on the executed-transaction TTL.
232
+ */
233
+ getExecutedCommitResult(messageHash) {
234
+ return this.executedCommitResults.get(messageHash);
235
+ }
175
236
  /**
176
237
  * Handles an incoming cluster update, managing the two-phase commit process
177
238
  * and coordinating with the local storage repo
@@ -642,9 +703,19 @@ export class ClusterMember {
642
703
  // the FIRST delivery when the record already arrives at super-majority, rather than a
643
704
  // round-trip later. The safety argument is quorum intersection (Theorem 9: no rival can
644
705
  // 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.
706
+ // only orders *concurrently-pending* rivals.
707
+ //
708
+ // The lost update this predicted WAS observed (a pend admitted between a rival's
709
+ // pend-consensus and commit-consensus, then refused by every member's storage at apply and
710
+ // still reported to the writer as a success). Holding the reservation until `handleConsensus`
711
+ // would not have closed it — the loser was approved before the winner's apply had even
712
+ // reached most members — so the cure went elsewhere: `validatePendOperations` now rejects a
713
+ // pend whose blocks are held by a different unresolved STORAGE pending record (the durable
714
+ // reservation that spans pend-apply → commit/cancel), and the coordinator returns storage's
715
+ // retained apply verdict instead of fabricating success (`getExecutedPendResult`). Residual:
716
+ // a member that has not yet applied the rival's pend abstains from that vote, and only the
717
+ // coordinating node's own verdict is threaded back — see the handoff notes on
718
+ // `CoordinatorRepo.pend`.
648
719
  const approvedPromises = Object.values(record.promises).filter(s => s.type === 'approve');
649
720
  if (approvedPromises.length >= superMajority && !record.commits[ourId]) {
650
721
  return { phase: TransactionPhase.OurCommitNeeded };
@@ -717,17 +788,31 @@ export class ClusterMember {
717
788
  }
718
789
  /**
719
790
  * The full promise-phase decision for a record: admit the declared membership FIRST, then (only if
720
- * admitted) validate its pend operations. Failing either yields a `{ valid:false, reason }` the caller
721
- * turns into a `reject` vote. Splitting membership from pend validation keeps the reason strings
722
- * distinct — a `membership-not-admitted` reject is a different signal (feeds the dispute path) than a
723
- * stale-revision / custom-validator reject.
791
+ * admitted) validate its pend operations, then its commit operations. Failing any yields a
792
+ * `{ valid:false, reason }` the caller turns into a `reject` vote. Keeping the three separate keeps
793
+ * the reason strings distinct — a `membership-not-admitted` reject is a different signal (feeds the
794
+ * dispute path) than a stale-revision / custom-validator reject, which is different again from a
795
+ * `content-digest-mismatch` (see {@link validateCommitOperations}). A record carries pend OR commit
796
+ * operations, so in practice exactly one of the latter two has anything to inspect.
724
797
  */
725
798
  async evaluatePromise(record) {
726
799
  const admission = await this.admitMembership(record);
727
800
  if (!admission.admit) {
728
801
  return { valid: false, reason: admission.reason ?? MEMBERSHIP_NOT_ADMITTED };
729
802
  }
730
- return await this.validatePendOperations(record);
803
+ const pendValidation = await this.validatePendOperations(record);
804
+ if (!pendValidation.valid) {
805
+ return pendValidation;
806
+ }
807
+ // Revision staleness before content digests: a commit whose revision a rival already took can
808
+ // never win, and the sharper stale reject also skips the per-block digest previews (the digest
809
+ // check would abstain on such a block anyway — its local base rev no longer matches the
810
+ // declared one).
811
+ const commitRevValidation = await this.validateCommitRevisions(record);
812
+ if (!commitRevValidation.valid) {
813
+ return commitRevValidation;
814
+ }
815
+ return await this.validateCommitOperations(record);
731
816
  }
732
817
  /**
733
818
  * Membership admission gate. Decides whether the coordinator-declared peer set (`record.peers`, call it
@@ -868,18 +953,44 @@ export class ClusterMember {
868
953
  }
869
954
  /**
870
955
  * Derive this member's own view of the record's block cluster via the injected capability, or
871
- * `undefined` when it cannot (no capability, no coordinating block id, or a derivation error all of
872
- * which the gate treats as "not confident"). Derived from the record's coordinating block, the same key
873
- * the coordinator used to select the cluster.
956
+ * `undefined` when it cannot (no capability, no coordinating block id, a coordinating block not bound
957
+ * to the record's own operations, or a derivation error all of which the gate treats as "not
958
+ * confident"). Derived from the record's coordinating block, the same key the coordinator used to
959
+ * select the cluster.
960
+ *
961
+ * Read off `record.message`, NOT a top-level record field: `messageHash` covers the message only, so
962
+ * only the in-message copy is tamper-evident to a relaying peer. (There is no top-level copy any more —
963
+ * see {@link ClusterRecord.message}.)
874
964
  */
875
965
  async deriveExpectedClusterView(record) {
876
966
  if (!this.deriveExpectedCluster) {
877
967
  return undefined;
878
968
  }
879
- const blockId = record.coordinatingBlockIds?.[0];
969
+ const blockId = record.message.coordinatingBlockIds?.[0];
880
970
  if (blockId === undefined) {
881
971
  return undefined;
882
972
  }
973
+ // Hashing the field makes it tamper-evident to RELAYS, but the coordinator is the party this gate
974
+ // exists to check and it picks the field before it computes the hash. Unbound, a Byzantine
975
+ // coordinator declares a shrunken cohort `D` and names a coordinating block whose real cohort
976
+ // resembles `D`: every member then derives that block's cohort, finds kEst = |D|, symmetric
977
+ // difference 0, and admits — the gate fully defeated. Binding the coordinating block to a block the
978
+ // record's OWN operations touch removes that free choice. `getAffectedBlockIds` is the same block
979
+ // extraction conflict detection already runs on this message — one definition, so the set a
980
+ // coordinating id must come from cannot drift from the set the record is judged to touch.
981
+ if (!this.getAffectedBlockIds(record.message.operations).includes(blockId)) {
982
+ log('cluster-member:coordinating-block-unbound', {
983
+ messageHash: record.messageHash,
984
+ coordinatingBlockId: blockId
985
+ });
986
+ // Fail closed into the branch that already exists rather than throwing: a hard reject would
987
+ // change `validateRecord`'s failure surface, and "not confident" already refuses any downsize.
988
+ return undefined;
989
+ }
990
+ // Which block a member derived its cohort view from is the single most useful fact when an
991
+ // admission decision has to be explained after the fact — and the only externally visible sign
992
+ // that the confident predicates ran at all rather than the fallback floor.
993
+ log('cluster-member:derive-expected-cluster', { messageHash: record.messageHash, blockId });
883
994
  try {
884
995
  // NOTE: derives (findCluster) once per inbound record on the promise path — one routing lookup
885
996
  // per vote. If this shows up as hot, cache the derived view per (blockId, short TTL): it is a
@@ -906,7 +1017,8 @@ export class ClusterMember {
906
1017
  }
907
1018
  /**
908
1019
  * Validates pend operations in a cluster record using the transaction validator.
909
- * Also checks for stale revisions to prevent consensus on operations that would fail.
1020
+ * Also checks for stale revisions, and for blocks held by a different unresolved pending
1021
+ * action, to prevent consensus on operations that storage would refuse at apply.
910
1022
  * Returns success if no validator is configured (backwards compatibility).
911
1023
  */
912
1024
  async validatePendOperations(record) {
@@ -914,11 +1026,12 @@ export class ClusterMember {
914
1026
  for (const operation of record.message.operations) {
915
1027
  if ('pend' in operation) {
916
1028
  const pendRequest = operation.pend;
1029
+ const blockIds = blockIdsForTransforms(pendRequest.transforms);
1030
+ // One state read serves both checks below: `latest` for staleness, `pendings` for the
1031
+ // unresolved-rival check.
1032
+ const blockResults = await this.storageRepo.get({ blockIds });
917
1033
  // Check for stale revisions before allowing consensus
918
1034
  if (pendRequest.rev !== undefined) {
919
- const blockIds = blockIdsForTransforms(pendRequest.transforms);
920
- // Get block states to check latest revisions
921
- const blockResults = await this.storageRepo.get({ blockIds });
922
1035
  for (const blockId of blockIds) {
923
1036
  const blockResult = blockResults[blockId];
924
1037
  if (blockResult?.unavailable !== undefined) {
@@ -935,13 +1048,19 @@ export class ClusterMember {
935
1048
  });
936
1049
  return { valid: false, reason: `block ${blockId} unavailable (${blockResult.unavailable}): cannot verify revision` };
937
1050
  }
938
- const latestRev = blockResult?.state?.latest?.rev;
939
- if (latestRev !== undefined && latestRev >= pendRequest.rev) {
1051
+ const latest = blockResult?.state?.latest;
1052
+ if (latest !== undefined && latest.rev >= pendRequest.rev) {
1053
+ // Self is excluded so a redelivered pend for this same action stays
1054
+ // approvable — the same exclusion the pending-rival check below documents,
1055
+ // and the same rule storage applies (see {@link isOwnRevision}).
1056
+ if (isOwnRevision(latest, pendRequest.rev, pendRequest.actionId)) {
1057
+ continue;
1058
+ }
940
1059
  log('cluster-member:validation-stale-revision', {
941
1060
  messageHash: record.messageHash,
942
1061
  blockId,
943
1062
  requestedRev: pendRequest.rev,
944
- latestRev
1063
+ latestRev: latest.rev
945
1064
  });
946
1065
  // Deliberately prose-only: this reason is fed to computeSigningPayload, signed,
947
1066
  // and carried as Signature.rejectReason, so adding a structured revision here
@@ -950,17 +1069,294 @@ export class ClusterMember {
950
1069
  // is NOT a StaleFailure producer, so StaleFailure.staleAt does not apply; the
951
1070
  // coordinator's own local re-read (CoordinatorRepo.classifyStaleRejection)
952
1071
  // supplies that number when it can confirm the revision itself.
953
- return { valid: false, reason: `stale revision: block ${blockId} at rev ${latestRev}, requested rev ${pendRequest.rev}` };
1072
+ return { valid: false, reason: `stale revision: block ${blockId} at rev ${latest.rev}, requested rev ${pendRequest.rev}` };
954
1073
  }
955
1074
  }
956
1075
  }
957
- // Run custom validator if configured
958
- if (this.validator && pendRequest.transaction && pendRequest.operationsHash) {
959
- const result = await this.validator.validate(pendRequest.transaction, pendRequest.operationsHash);
960
- if (!result.valid) {
961
- return { valid: false, reason: result.reason };
1076
+ // Reject a pend whose blocks are held by a DIFFERENT unresolved pending action. This is
1077
+ // the durable reservation the in-memory table (`findConflict` / `activeTransactions`)
1078
+ // cannot provide: that table clears the moment the rival's PEND record reaches
1079
+ // consensus, but the rival's storage pending record — written at pend-apply, removed at
1080
+ // commit or cancel spans exactly the pend→commit window in which `latest.rev` has not
1081
+ // yet advanced. Storage's own pend would refuse this request at consensus-apply for the
1082
+ // same reason (`StorageRepo.pend`'s listPendingTransactions scan); voting reject here
1083
+ // moves that verdict into the phase where the cohort aggregates it, so the loser is
1084
+ // refused with a real answer instead of burning a consensus round it cannot win. A
1085
+ // member that has not yet applied the rival's pend has no record and simply abstains
1086
+ // from this reason; the coordinator returning the retained apply verdict
1087
+ // (getExecutedPendResult) catches that residual. Self is excluded so a redelivered pend
1088
+ // for this same action stays approvable. An unavailable block carries no `pendings` and
1089
+ // abstains (the rev branch above already fail-closes when a revision claim is at stake).
1090
+ // Reason stays plain prose: it is fed to computeSigningPayload and carried as
1091
+ // Signature.rejectReason, exactly like the stale-revision reason above.
1092
+ for (const blockId of blockIds) {
1093
+ const rivals = (blockResults[blockId]?.state?.pendings ?? []).filter(actionId => actionId !== pendRequest.actionId);
1094
+ if (rivals.length > 0) {
1095
+ log('cluster-member:validation-pending-conflict', {
1096
+ messageHash: record.messageHash,
1097
+ blockId,
1098
+ actionId: pendRequest.actionId,
1099
+ rivals
1100
+ });
1101
+ return { valid: false, reason: `pending conflict: block ${blockId} held by unresolved action(s) ${rivals.join(', ')}` };
962
1102
  }
963
1103
  }
1104
+ // Re-check the transaction when a validator is configured. The unvalidatable-pend
1105
+ // policy and the throwing-validator catch live in the shared `checkPendValidation`,
1106
+ // which the storage tier runs too, so a member cannot vote approve on a shape its own
1107
+ // storage would refuse at apply. Its reasons are fed to computeSigningPayload and
1108
+ // carried as Signature.rejectReason, exactly like the stale-revision reason above, so
1109
+ // a fail-closed refusal here is signed evidence rather than a lost vote.
1110
+ const validator = this.validator;
1111
+ const validation = await checkPendValidation(pendRequest, validator && (({ transaction, operationsHash }) => validator.validate(transaction, operationsHash)), this.unvalidatablePendPolicy, event => event.kind === 'unvalidatable'
1112
+ // An operator can grep this line to see how much traffic goes unchecked.
1113
+ ? log('cluster-member:pend-unvalidatable', {
1114
+ messageHash: record.messageHash,
1115
+ actionId: pendRequest.actionId,
1116
+ policy: event.policy
1117
+ })
1118
+ : log('cluster-member:validator-fault', {
1119
+ messageHash: record.messageHash,
1120
+ error: event.error
1121
+ }));
1122
+ if (!validation.valid) {
1123
+ return { valid: false, reason: validation.reason };
1124
+ }
1125
+ }
1126
+ }
1127
+ return { valid: true };
1128
+ }
1129
+ /**
1130
+ * Promise-round check that a commit record's requested revision is not already committed HERE
1131
+ * under a different action. This is the member-side arm that keeps a DEAD rival's re-broadcast
1132
+ * commit from assembling consensus: after a race winner commits and members clear its record
1133
+ * from the reservation table, a loser's re-driven commit meets no conflict votes — without this
1134
+ * check every caught-up member would abstain (the content-digest check below abstains for
1135
+ * update-only transforms whose base moved) and the loser could reach commit-consensus for a
1136
+ * write no member will ever durably store (its apply is refused stale and tolerated as 'ahead'
1137
+ * divergence — see `applyConsensusOperation`).
1138
+ *
1139
+ * Same "must run on the promise round" rule as {@link validateCommitOperations}: the
1140
+ * commit-round vote is deliberately blind, so promise votes are the only ones that carry
1141
+ * "I checked this". The four-way rule, per committed block:
1142
+ * - no local `latest`, or `latest.rev < commit.rev` → abstain (approve). Preserves the
1143
+ * lagging-member tolerance (`coordinator-repo-commit-divergence.spec.ts`): a member behind
1144
+ * the commit cannot judge it.
1145
+ * - `latest.rev === commit.rev` with the SAME action → abstain (approve). Idempotent
1146
+ * redelivery of an already-durable commit; rejecting would make the writer rebase and
1147
+ * re-append an action that already landed — a duplicate entry. (Storage's `alreadyDone`
1148
+ * partition returns success for this shape at apply.)
1149
+ * - `latest.rev === commit.rev` with a DIFFERENT action → reject: a rival took the revision.
1150
+ * - `latest.rev > commit.rev` → consult the {@link IRevisionActionReader} capability for who
1151
+ * holds `commit.rev`: a different action → reject; the same action → abstain (already
1152
+ * durable, history simply moved on); no record / capability absent / read fault → abstain.
1153
+ *
1154
+ * Never throws out of the vote path: any read fault is an abstain (mirroring the digest check's
1155
+ * preview-error arm), because a member that fails to vote at all is worse than one that
1156
+ * abstains. Reason stays plain prose — it is fed to computeSigningPayload and carried as
1157
+ * Signature.rejectReason, exactly like the stale-revision pend reject.
1158
+ *
1159
+ * Residual (see the commit-tier handoff): a member that signed the winner's commit but has not
1160
+ * yet APPLIED it sits in a window where it holds neither the winner's record (reservation
1161
+ * dropped at commit-sign, `shouldPersist = false`) nor the winner's revision (storage still
1162
+ * behind) — it abstains here. A capability-less member, or one with history truncated below
1163
+ * `latest`, abstains at `latest.rev > commit.rev` too. A rival's commit can therefore still
1164
+ * pass the promise round if EVERY member is simultaneously in one of those states — on a fast
1165
+ * cohort that window is the COMMON case, not the corner. The backstop is downstream of
1166
+ * consensus: every member's apply then refuses the rival as stale, the coordinating node's own
1167
+ * member retains that refusal (`getExecutedCommitResult`), and `CoordinatorRepo.commit`
1168
+ * confirms the rival against local storage and answers the writer with a retryable conflict
1169
+ * instead of a fabricated success. `ConflictRaceLostError` conversion and classified
1170
+ * rejections close the re-drive route the same way.
1171
+ */
1172
+ async validateCommitRevisions(record) {
1173
+ for (const operation of record.message.operations) {
1174
+ if (!('commit' in operation)) {
1175
+ continue;
1176
+ }
1177
+ const commit = operation.commit;
1178
+ let blockResults;
1179
+ try {
1180
+ // The member's raw storage repo (no cluster recursion) — the same seam
1181
+ // validatePendOperations reads on every pend vote.
1182
+ blockResults = await this.storageRepo.get({ blockIds: commit.blockIds });
1183
+ }
1184
+ catch (err) {
1185
+ log('cluster-member:commit-staleness-read-error', {
1186
+ messageHash: record.messageHash,
1187
+ actionId: commit.actionId,
1188
+ error: err instanceof Error ? err.message : String(err)
1189
+ });
1190
+ continue; // a local read fault is an abstain, never an escape out of the vote path
1191
+ }
1192
+ for (const blockId of commit.blockIds) {
1193
+ const latest = blockResults[blockId]?.state?.latest;
1194
+ if (!latest || latest.rev < commit.rev) {
1195
+ continue; // behind (or block never seen): cannot judge — abstain
1196
+ }
1197
+ if (isOwnRevision(latest, commit.rev, commit.actionId)) {
1198
+ continue; // idempotent redelivery of an already-durable commit — MUST NOT reject
1199
+ }
1200
+ if (latest.rev === commit.rev) {
1201
+ log('cluster-member:validation-stale-commit', {
1202
+ messageHash: record.messageHash,
1203
+ blockId,
1204
+ actionId: commit.actionId,
1205
+ rev: commit.rev,
1206
+ committedBy: latest.actionId
1207
+ });
1208
+ return { valid: false, reason: `stale commit: block ${blockId} rev ${commit.rev} committed by a different action` };
1209
+ }
1210
+ // latest.rev > commit.rev: latest can no longer name who took commit.rev — ask the
1211
+ // revision index. Structural probe, same pattern as previewCommitDigest below: a repo
1212
+ // without the capability abstains.
1213
+ const reader = this.storageRepo;
1214
+ if (typeof reader.getRevisionAction !== 'function') {
1215
+ continue;
1216
+ }
1217
+ let takenBy;
1218
+ try {
1219
+ takenBy = await reader.getRevisionAction(blockId, commit.rev);
1220
+ }
1221
+ catch (err) {
1222
+ log('cluster-member:commit-staleness-revision-read-error', {
1223
+ messageHash: record.messageHash,
1224
+ blockId,
1225
+ rev: commit.rev,
1226
+ error: err instanceof Error ? err.message : String(err)
1227
+ });
1228
+ continue; // read fault → abstain
1229
+ }
1230
+ if (takenBy !== undefined && takenBy !== commit.actionId) {
1231
+ log('cluster-member:validation-stale-commit', {
1232
+ messageHash: record.messageHash,
1233
+ blockId,
1234
+ actionId: commit.actionId,
1235
+ rev: commit.rev,
1236
+ committedBy: takenBy,
1237
+ latestRev: latest.rev
1238
+ });
1239
+ return { valid: false, reason: `stale commit: block ${blockId} rev ${commit.rev} committed by a different action` };
1240
+ }
1241
+ // takenBy === commit.actionId (already durable, history moved on) or undefined
1242
+ // (truncated history — unknown): abstain either way.
1243
+ }
1244
+ }
1245
+ return { valid: true };
1246
+ }
1247
+ /**
1248
+ * Promise-round check of a commit record's declared content digests
1249
+ * (`CommitRequest.blockDigests`) against what this member's OWN pended copy of each transform
1250
+ * would materialize (`StorageRepo.previewCommitDigest`). This is what makes a promise approval on
1251
+ * a commit record MEAN something about content: before this hook, the promise round validated
1252
+ * nothing for commits (`validatePendOperations` only inspects pend operations).
1253
+ *
1254
+ * MUST run on the promise round, not the commit round: the commit-round vote is cast deliberately
1255
+ * blind — `getTransactionPhase` signs the commit whenever promise approvals reach super-majority,
1256
+ * regardless of this member's own promise vote — so promise approvals are the only votes that
1257
+ * carry "I checked this". Do not move it.
1258
+ *
1259
+ * Checkable/abstain rule, keyed on the member's OWN pended transform (the payload the client
1260
+ * authored, delivered at pend — a hostile declarer cannot force or dodge a check by mis-declaring
1261
+ * `baseRev`):
1262
+ * - transform carries an `insert` → base-independent, ALWAYS check (declared `baseRev` ignored);
1263
+ * - `updates` only → check iff this member's local base rev equals the declared `baseRev`
1264
+ * (StorageRepo.commit accepts any `latest.rev < request.rev`, so a lagging member applying an
1265
+ * update-only transform to an older base legitimately materializes different bytes);
1266
+ * - `delete` only / no base / unmaterializable base → materializes nothing to compare, abstain;
1267
+ * - no pending transform for the action (this member never saw the pend) → abstain.
1268
+ * "Abstain" = contribute no content attestation: approve exactly as before this check existed.
1269
+ *
1270
+ * Residual: a false digest survives only when the declarer lies AND enough of the cohort is
1271
+ * simultaneously unable to check (lagging on update-only blocks, missed pends) that no honest
1272
+ * checker is left — any single caught-up honest member rejects. Strictly stronger than before,
1273
+ * when commit signatures bound no content at all. Verifying/persisting a durable content proof is
1274
+ * later work (persist-block-commit-proof).
1275
+ */
1276
+ async validateCommitOperations(record) {
1277
+ // Capability probe: `storageRepo` is typed IRepo, and only a repo that owns the local
1278
+ // materialization can preview one. A repo without the capability abstains everywhere (also
1279
+ // keeps mock-repo harnesses and non-storage compositions on the legacy approve path).
1280
+ // NOTE: probing structurally means a decorating/caching repo later inserted at this seam
1281
+ // silently disables the whole check with no signal. `ICommitDigestPreviewer` exists so such a
1282
+ // decorator has a named contract to forward; if a non-forwarding wrapper is ever wired here,
1283
+ // promote this to a typed component field rather than widening the probe.
1284
+ const repo = this.storageRepo;
1285
+ if (typeof repo.previewCommitDigest !== 'function') {
1286
+ return { valid: true };
1287
+ }
1288
+ for (const operation of record.message.operations) {
1289
+ if (!('commit' in operation)) {
1290
+ continue;
1291
+ }
1292
+ const commit = operation.commit;
1293
+ // An upgraded member receiving a commit with no declarations abstains everywhere.
1294
+ if (!commit.blockDigests) {
1295
+ continue;
1296
+ }
1297
+ // One Set per commit operation: the surplus-entry filter below is a membership test per
1298
+ // declared id, and `blockIds` is a per-coordinator batch that can be wide.
1299
+ const committedIds = new Set(commit.blockIds);
1300
+ // NOTE: previews run one block at a time, so a commit declaring N blocks adds N sequential
1301
+ // preview round-trips (each 1-3 block-storage reads plus a structuredClone of the base and
1302
+ // the transform) to this member's promise-round latency. Unmeasured, and sequencing buys the
1303
+ // short-circuit on the first mismatch. If wide commits ever show up as promise latency,
1304
+ // fan the previews out with Promise.all and reduce the results, rather than sampling a
1305
+ // subset of the declared ids — a skipped id is an unchecked id.
1306
+ for (const [blockId, declared] of Object.entries(commit.blockDigests)) {
1307
+ // Surplus (or hostile) entry for a block this commit does not even cover: ignore it —
1308
+ // never throw out of the vote path, and never reject on content nobody is committing.
1309
+ if (!committedIds.has(blockId)) {
1310
+ continue;
1311
+ }
1312
+ // `blockDigests` is untrusted wire data with no ingress schema behind it, so the entry
1313
+ // need not be the shape the type promises. A malformed entry is treated as an omitted
1314
+ // one (abstain) rather than a mismatch: rejecting on it would let a garbled request
1315
+ // look like forged content, and reading through it would throw a TypeError out of the
1316
+ // vote path — this member would then fail to vote at all instead of voting reject.
1317
+ if (typeof declared?.digest !== 'string') {
1318
+ continue;
1319
+ }
1320
+ let preview;
1321
+ try {
1322
+ preview = await repo.previewCommitDigest(blockId, commit.actionId, commit.rev);
1323
+ }
1324
+ catch (err) {
1325
+ log('cluster-member:content-digest-preview-error', {
1326
+ messageHash: record.messageHash,
1327
+ blockId,
1328
+ error: err instanceof Error ? err.message : String(err)
1329
+ });
1330
+ continue; // a local preview fault is an abstain, never a content judgement
1331
+ }
1332
+ // No pend seen here, or the transform materializes nothing to compare (tombstone,
1333
+ // updates with no base, unmaterializable base) → abstain.
1334
+ if (preview === undefined || preview.digest === undefined) {
1335
+ continue;
1336
+ }
1337
+ // `typeof === 'number'` rather than `!== undefined` for the same untrusted-shape reason
1338
+ // as the digest guard above: a non-numeric declared baseRev can never equal a local
1339
+ // one, so it degrades to an abstain instead of comparing junk.
1340
+ const checkable = preview.baseIndependent
1341
+ || (typeof declared.baseRev === 'number' && preview.baseRev === declared.baseRev);
1342
+ if (!checkable) {
1343
+ continue;
1344
+ }
1345
+ if (preview.digest !== declared.digest) {
1346
+ log('cluster-member:content-digest-mismatch', {
1347
+ messageHash: record.messageHash,
1348
+ blockId,
1349
+ actionId: commit.actionId,
1350
+ rev: commit.rev,
1351
+ declaredDigest: declared.digest,
1352
+ declaredBaseRev: declared.baseRev,
1353
+ previewDigest: preview.digest,
1354
+ previewBaseRev: preview.baseRev,
1355
+ baseIndependent: preview.baseIndependent
1356
+ });
1357
+ // One vote per record: a single mismatching block rejects the whole record.
1358
+ return { valid: false, reason: CONTENT_DIGEST_MISMATCH };
1359
+ }
964
1360
  }
965
1361
  }
966
1362
  return { valid: true };
@@ -1033,7 +1429,11 @@ export class ClusterMember {
1033
1429
  // the real cause. The durable marker was never written (it lands only after
1034
1430
  // apply succeeds, below), so there is nothing to roll back. Recoverable local
1035
1431
  // divergence is absorbed inside applyConsensusOperation and never reaches here.
1432
+ // A retained pend verdict rolls back with the marker: it belongs to an apply
1433
+ // that is now considered not-executed, and a re-run will retain a fresh one.
1036
1434
  this.executedTransactions.delete(record.messageHash);
1435
+ this.executedPendResults.delete(record.messageHash);
1436
+ this.executedCommitResults.delete(record.messageHash);
1037
1437
  throw err;
1038
1438
  }
1039
1439
  // Persist the durable marker only now that apply has actually succeeded. Writing it
@@ -1103,6 +1503,13 @@ export class ClusterMember {
1103
1503
  }
1104
1504
  if ('pend' in operation) {
1105
1505
  const result = await this.storageRepo.pend(operation.pend);
1506
+ // Retain the verdict either way so the coordinator can hand the writer storage's real
1507
+ // answer (see getExecutedPendResult). A refusal here is NOT local divergence the way a
1508
+ // commit refusal is: pend-consensus confers no durability — a refusal carrying `pending`
1509
+ // (a rival's unresolved action holds the blocks) or `missing` (the requested revision is
1510
+ // already committed) is the optimistic-concurrency verdict, and swallowing it acknowledged
1511
+ // writes that no member stored.
1512
+ this.executedPendResults.set(messageHash, result);
1106
1513
  if (!result.success) {
1107
1514
  log('cluster-member:consensus-pend-diverged', {
1108
1515
  messageHash,
@@ -1133,9 +1540,23 @@ export class ClusterMember {
1133
1540
  const commitSignedPayload = clusterVoteSigningPayload(await this.computeCommitHash(record), 'approve');
1134
1541
  this.captureCommitCert(record, commit.actionId, commitSignedPayload);
1135
1542
  }
1543
+ // Project the consensus record into a durable BlockCommitProof and hand it down the commit
1544
+ // path — StorageRepo persists it only where the local materialization matches the declared
1545
+ // digest (see persistProofIfContentMatches). A cheap projection (no hashing/signing).
1546
+ // `undefined` for a v1 / unversioned record: its hashes bind no peer set, so it is never
1547
+ // certifiable — logged so an operator can see why a cohort retains no proofs. The cast is the
1548
+ // named ICommitProofPersister contract; a plain IRepo mock ignores the extra argument.
1549
+ const proof = buildBlockCommitProof(record);
1550
+ if (proof === undefined) {
1551
+ log('cluster-member:commit-proof-skipped', {
1552
+ messageHash,
1553
+ actionId: commit.actionId,
1554
+ membershipVersion: record.membershipVersion
1555
+ });
1556
+ }
1136
1557
  let result;
1137
1558
  try {
1138
- result = await this.storageRepo.commit(commit);
1559
+ result = await this.storageRepo.commit(commit, undefined, proof);
1139
1560
  }
1140
1561
  catch (err) {
1141
1562
  // `StorageRepo.commit` throws (rather than returning success:false) when
@@ -1156,12 +1577,34 @@ export class ClusterMember {
1156
1577
  }
1157
1578
  throw err;
1158
1579
  }
1580
+ // Retain the verdict either way (see getExecutedCommitResult): a success confirms local
1581
+ // durability, and an ahead-shaped refusal is the only evidence the coordinator has that
1582
+ // the tolerance below swallowed a rival's win at the requested revision. The
1583
+ // missing-pending throw path above retains nothing — no CommitResult exists there, and
1584
+ // the coordinator's fabricated-success fallback plus cohort reconcile is the right shape
1585
+ // for a member that is genuinely behind.
1586
+ this.executedCommitResults.set(messageHash, result);
1159
1587
  if (!result.success) {
1160
1588
  // success:false is a StaleFailure. `missing` ⇒ ahead/stale divergence
1161
1589
  // (we already hold ≥ this rev): tolerate, do NOT reconcile downward. A
1162
1590
  // missing-base reason ⇒ behind divergence, reconcile (below). Any other bare
1163
1591
  // `reason` with no `missing` ⇒ a genuine internalCommit fault: propagate so
1164
1592
  // handleConsensus rolls back the executed marker and rethrows.
1593
+ //
1594
+ // NOTE: this 'ahead' tolerance is what turns a rival's commit that somehow reaches
1595
+ // consensus into a reported success no member durably stored (consensus without
1596
+ // durability — the commit-tier acknowledgement hole). It must stay: a member
1597
+ // genuinely ahead of a redelivered/lagging commit is the common, correct case. The
1598
+ // guards live UPSTREAM: `validateCommitRevisions` rejects the rival at the promise
1599
+ // round, `CoordinatorRepo.commit` returns lost races as retryable conflicts instead
1600
+ // of re-driving them, and the verdict retained just above (getExecutedCommitResult)
1601
+ // lets the coordinating node convert its OWN member's rival-confirmed refusal into a
1602
+ // conflict answer — that last guard is what closes the signed-but-not-yet-applied
1603
+ // window, where two commits for one revision both assemble consensus because signing
1604
+ // drops each member's reservation before applying advances its storage. If
1605
+ // consensus-without-durability is ever observed again, look at those guards' abstain
1606
+ // residuals (non-coordinating members' verdicts are not threaded back; capability-less
1607
+ // or history-truncated storage abstains), not at this branch.
1165
1608
  if (result.missing?.length) {
1166
1609
  log('cluster-member:consensus-commit-diverged', {
1167
1610
  messageHash,
@@ -1496,7 +1939,7 @@ export class ClusterMember {
1496
1939
  }
1497
1940
  /**
1498
1941
  * Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
1499
- * The multi-collection path carries it on `pend.transaction.priority`; the single-collection
1942
+ * The multi-collection path carries it on `pend.validation.transaction.priority`; the single-collection
1500
1943
  * (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
1501
1944
  * legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
1502
1945
  * (backward compatible: such transactions simply never age). Both carriers live inside the signed
@@ -1512,7 +1955,11 @@ export class ClusterMember {
1512
1955
  recordPriority(record) {
1513
1956
  for (const op of record.message.operations) {
1514
1957
  if ('pend' in op) {
1515
- return clampPriority(op.pend.transaction?.priority ?? op.pend.priority);
1958
+ // Every hop optional: `validation` arrives off the wire inside a signed message whose
1959
+ // hash binds its bytes, not its shape, so a malformed pair must yield priority 0 (what
1960
+ // clampPriority already does for a missing or Byzantine number) rather than throw out
1961
+ // of the vote path — the lost vote this fail-closed pass exists to prevent.
1962
+ return clampPriority(op.pend.validation?.transaction?.priority ?? op.pend.priority);
1516
1963
  }
1517
1964
  }
1518
1965
  return 0;
@@ -1553,6 +2000,13 @@ export class ClusterMember {
1553
2000
  }
1554
2001
  return undefined;
1555
2002
  }
2003
+ /**
2004
+ * Every block id the message's own operations name. Two consumers, deliberately sharing one
2005
+ * definition: conflict detection (which writes must serialize against each other) and the membership
2006
+ * admission gate's binding check (the set a legitimate `coordinatingBlockIds[0]` must come from —
2007
+ * {@link ClusterMember.deriveExpectedClusterView}). If the two ever disagreed, a coordinator could
2008
+ * name a block the record is not judged to touch.
2009
+ */
1556
2010
  getAffectedBlockIds(operations) {
1557
2011
  const blockIds = new Set();
1558
2012
  for (const operation of operations) {
@@ -1636,6 +2090,8 @@ export class ClusterMember {
1636
2090
  for (const [messageHash, executedAt] of Array.from(this.executedTransactions.entries())) {
1637
2091
  if (executedAt < expirationThreshold) {
1638
2092
  this.executedTransactions.delete(messageHash);
2093
+ this.executedPendResults.delete(messageHash);
2094
+ this.executedCommitResults.delete(messageHash);
1639
2095
  }
1640
2096
  }
1641
2097
  // Prune old applied-invalidation dedup markers on the same TTL.