@optimystic/db-p2p 1.0.0-beta.3 → 1.0.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 +126 -6
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +378 -50
  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 +77 -5
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +131 -20
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +238 -122
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +625 -472
  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 +422 -56
  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 +145 -19
  217. package/src/repo/coordinator-repo.ts +652 -505
  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,7 +1,7 @@
1
- import type { IRepo, ClusterRecord, ClusterPeers, Signature, RepoMessage, ITransactionValidator, ClusterConsensusConfig, UnvalidatablePendPolicy, CommitResult, PendResult, BlockId, ActionId, ActionRev, CommitRequest, CommitCert, InvalidateRequest, MemberApplyOutcome } from "@optimystic/db-core";
1
+ import type { IRepo, ClusterRecord, ClusterPeers, Signature, RepoMessage, ITransactionValidator, ClusterConsensusConfig, UnvalidatablePendPolicy, CommitResult, PendResult, PendRequest, BlockId, ActionId, ActionRev, CommitRequest, CommitCert, InvalidateRequest, MemberApplyOutcome } from "@optimystic/db-core";
2
2
  import type { ICluster } from "@optimystic/db-core";
3
3
  import type { IPeerNetwork } from "@optimystic/db-core";
4
- import { blockIdsForTransforms, isOwnRevision, isConflictFailure, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
4
+ import { blockIdsForTransforms, transformForBlockId, isOwnRevision, isConflictFailure, DEFAULT_SUPER_MAJORITY_THRESHOLD, localDurability } from "@optimystic/db-core";
5
5
  import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clusterVoteSigningPayload, clusterVoteVerificationPayload } from "@optimystic/db-core";
6
6
  import { verifyInvalidationCertificate, type ArbitratorSetRecompute } from "../dispute/invalidation.js";
7
7
  import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
@@ -17,12 +17,15 @@ import type { FretService } from "p2p-fret";
17
17
  import type { IPeerReputation } from "../reputation/types.js";
18
18
  import { PenaltyReason } from "../reputation/types.js";
19
19
  import type { ITransactionStateStore } from "./i-transaction-state-store.js";
20
- import { isMissingBaseRevisionFailure, type CommitDigestPreview, type ICommitDigestPreviewer, type ICommitProofPersister, type IRevisionActionReader } from "../storage/storage-repo.js";
20
+ import { isMissingBaseRevisionFailure, type CommitDigestPreview, type ICommitDigestPreviewer, type ICommitProofPersister, type IRevisionActionReader, type IPendingClaimReader } from "../storage/storage-repo.js";
21
+ import { isReservationAgainst, reservationRequestFor, cohortCanMissAPend, type PendingClaim, type ReservationRequest } from "../storage/pending-claim.js";
22
+ import { StuckReservationTracker } from "../repo/stuck-reservation.js";
21
23
  import { checkPendValidation } from "../pend-validation.js";
22
24
  import { getAffectedBlockIds } from "./record-operations.js";
23
25
  import { operationsConflict, resolveRace } from "./race-resolution.js";
24
26
  import { buildBlockCommitProof, mintSoloCommitProof, type BlockCommitProof } from "./commit-proof.js";
25
27
  import { RECONCILE_TIMEOUT_MS } from "./reconcile-block.js";
28
+ import type { CommittedHolders } from "./rebalance-monitor.js";
26
29
 
27
30
  const log = createLogger('cluster-member')
28
31
 
@@ -68,6 +71,45 @@ type VerifyOutcome =
68
71
  | { valid: true }
69
72
  | { valid: false; penalize: boolean };
70
73
 
74
+ /**
75
+ * This member's promise-round decision on a record: approve, or one of two *different kinds* of no.
76
+ *
77
+ * The distinction is the whole reason the type exists. `invalid` is a validity judgement — the record
78
+ * is wrong and will be wrong on every retry — and becomes a `reject` vote the coordinator counts
79
+ * toward the permanent-rejection threshold. `held` is "not right now": the pend's blocks are reserved
80
+ * by a different unresolved action in this member's storage, a condition that disappears the moment
81
+ * that rival commits or cancels, and it becomes a `held` vote counted toward neither approvals nor
82
+ * rejections.
83
+ *
84
+ * A boolean plus prose cannot carry that difference, and collapsing the two was a real defect rather
85
+ * than a tidiness question: on a cohort of three or fewer members `maxAllowedRejections` is zero at
86
+ * the default threshold, so one member saying "someone else is holding this right now" became a
87
+ * permanent `ValidatorRejectionError` for the whole transaction.
88
+ *
89
+ * `reason` stays optional on the `invalid` arm because it is signed: it is folded into the vote
90
+ * payload verbatim, and an absent reason and an empty one must keep producing the bytes they do today.
91
+ */
92
+ type PromiseVerdict =
93
+ | { valid: true }
94
+ | { valid: false; kind: 'invalid'; reason?: string }
95
+ /** `heldBy` is the rival's ACTION id (storage's pending list), never a messageHash — see {@link Signature}. */
96
+ | { valid: false; kind: 'held'; reason: string; heldBy: ActionId };
97
+
98
+ /** A refusal that judges the record invalid — the permanent kind, and every kind but the pending-conflict one. */
99
+ function invalidVerdict(reason?: string): PromiseVerdict {
100
+ return { valid: false, kind: 'invalid', reason };
101
+ }
102
+
103
+ /**
104
+ * Widen a boolean-plus-prose check into a {@link PromiseVerdict}. Every check written this way judges
105
+ * validity, so its refusal is the permanent kind; only the pending-conflict branch of
106
+ * {@link ClusterMember.validatePendOperations} produces the transient one, and it builds its verdict
107
+ * directly.
108
+ */
109
+ function verdictOf(result: { valid: boolean; reason?: string }): PromiseVerdict {
110
+ return result.valid ? { valid: true } : invalidVerdict(result.reason);
111
+ }
112
+
71
113
  /**
72
114
  * Actively reconciles a block this member committed without having seen the matching
73
115
  * pend (cohort drift between the independent pend and commit cluster-transactions).
@@ -91,6 +133,15 @@ export type ReconcileBlockCallback = (blockId: BlockId, committed: ActionRev, co
91
133
  */
92
134
  export type CommitCertificateSink = (actionId: ActionId, cert: CommitCert) => void;
93
135
 
136
+ /**
137
+ * Sink for who is known to hold a commit this node's own storage durably holds — fed to the rebalance
138
+ * monitor so it does not push freshly committed blocks back to the members that stored them (see
139
+ * `RebalanceMonitor.recordCommittedHolders`). Fired by the member after applying a consensus commit,
140
+ * and by `CoordinatorRepo.commit` when it acknowledges one. Optional; a throwing sink is isolated +
141
+ * logged (it must never break consensus or the writer's answer).
142
+ */
143
+ export type CommittedHoldersSink = (committed: CommittedHolders) => void;
144
+
94
145
  /**
95
146
  * Applies a consensus-ordered {@link InvalidateRequest} to local storage — the deterministic
96
147
  * reversal every member runs once consensus on the invalidation is reached. The implementation
@@ -179,6 +230,14 @@ export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
179
230
  */
180
231
  export const CONTENT_DIGEST_MISMATCH = 'content-digest-mismatch';
181
232
 
233
+ /**
234
+ * Stable reject reason a member emits when a commit declares, for a block, a base revision
235
+ * (`CommitRequest.blockDigests[id].baseRev`) other than the one the same action's pend carried to
236
+ * this member (`PendRequest.baseRevs`, kept with the pending record) — see
237
+ * `ClusterMember.validateCommitBaseDeclarations`. Signed like its sibling above.
238
+ */
239
+ export const BASE_DECLARATION_DISAGREES = 'base-declaration-disagrees';
240
+
182
241
  /**
183
242
  * The two stable reject reasons a validator-configured member emits from the shared
184
243
  * {@link checkPendValidation}: `PEND_NOT_VALIDATABLE` for a pend carrying no `validation` payload
@@ -205,6 +264,8 @@ interface ClusterMemberComponents {
205
264
  reconcileBlock?: ReconcileBlockCallback;
206
265
  /** Receives the consensus commit cert per committed action; see {@link CommitCertificateSink}. */
207
266
  onCommitCertificate?: CommitCertificateSink;
267
+ /** Receives who holds each consensus commit this member durably applied; see {@link CommittedHoldersSink}. */
268
+ onCommittedHolders?: CommittedHoldersSink;
208
269
  /** Applies a consensus-ordered invalidation to local storage; see {@link InvalidationApplySink}. */
209
270
  onInvalidate?: InvalidationApplySink;
210
271
  /** Layer-2 arbitrator-set recompute for invalidation verification; see {@link RecomputeArbitratorSetCapability}. */
@@ -244,7 +305,8 @@ export function clusterMember(components: ClusterMemberComponents): ClusterMembe
244
305
  components.onInvalidate,
245
306
  components.recomputeArbitratorSet,
246
307
  components.deriveExpectedCluster,
247
- components.now
308
+ components.now,
309
+ components.onCommittedHolders
248
310
  );
249
311
  }
250
312
 
@@ -314,6 +376,14 @@ export class ClusterMember implements ICluster {
314
376
  // inside the sink); this map only spares redundant work when the same invalidation reaches
315
377
  // consensus twice (rebroadcast / sync) under different message hashes. (-> appliedAt timestamp)
316
378
  private appliedInvalidations: Map<string, number> = new Map();
379
+ /**
380
+ * Per block, what this member's own `held` votes have added up to — the member-side instance of
381
+ * the counter `CoordinatorRepo.noteStuckReservation` keeps from the coordinator's vantage. Fed by
382
+ * every `held` verdict {@link validatePendOperations} casts, so a reservation only THIS member
383
+ * holds (one the coordinator's storage cannot corroborate) is still named, once, where the record
384
+ * lives. Forgotten for a block the moment a vote finds it no longer reserved.
385
+ */
386
+ private readonly stuckReservations = new StuckReservationTracker(1000);
317
387
  // Queue of transactions to clean up
318
388
  private cleanupQueue: string[] = [];
319
389
  // Serialize concurrent updates for the same transaction
@@ -368,7 +438,8 @@ export class ClusterMember implements ICluster {
368
438
  private readonly onInvalidate?: InvalidationApplySink,
369
439
  private readonly recomputeArbitratorSet?: RecomputeArbitratorSetCapability,
370
440
  private readonly deriveExpectedCluster?: DeriveExpectedClusterCallback,
371
- now?: () => number
441
+ now?: () => number,
442
+ private readonly onCommittedHolders?: CommittedHoldersSink
372
443
  ) {
373
444
  this.now = now ?? ((): number => Date.now());
374
445
  this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD;
@@ -588,6 +659,15 @@ export class ClusterMember implements ICluster {
588
659
  });
589
660
  // Our own vote can be terminal (a reject where maxAllowedRejections is 0) or complete
590
661
  // the super-majority — recompute rather than guess which.
662
+ //
663
+ // Deliberately NOT the conflict vote's `shouldPersist = false`, for a `held` vote or
664
+ // any other. A conflict vote clears the record because this member holds the WINNER
665
+ // in `activeTransactions` and persisting the loser would reserve the same blocks a
666
+ // second time. A `held` vote has no such twin: the rival lives in durable storage,
667
+ // not in that table, so this record's entry is the only one and dropping it would
668
+ // only make this member forget a transaction the rest of the cohort may still
669
+ // carry to super-majority. Where one `held` vote IS terminal (a small cohort), the
670
+ // recomputed phase is `ConflictSuperseded`, which clears the record anyway.
591
671
  continue;
592
672
  case TransactionPhase.OurConflictVoteNeeded:
593
673
  currentRecord = await this.handleConflictVoteNeeded(currentRecord, phaseResult.conflictsWith);
@@ -640,7 +720,7 @@ export class ClusterMember implements ICluster {
640
720
  shouldPersist = false;
641
721
  break phaseLoop;
642
722
  case TransactionPhase.Promising:
643
- // We have already voted (approve, reject, or conflict); the record is still
723
+ // We have already voted (approve, reject, conflict, or held); the record is still
644
724
  // collecting promises from the rest of the cohort. Nothing to add — retain the
645
725
  // record only if our vote wasn't a conflict (`shouldPersist` already reflects that).
646
726
  log('cluster-member:phase-promising-waiting', {
@@ -788,8 +868,9 @@ export class ClusterMember implements ICluster {
788
868
 
789
869
  /**
790
870
  * Compares existing vs incoming signatures for the same peers.
791
- * If a peer's vote type changed (approve↔reject), that's equivocation:
792
- * report a penalty and keep the first-seen signature.
871
+ * ANY change of vote type is equivocation — the comparison is over `Signature['type']`, so it
872
+ * covers every kind without enumerating them (a new kind joins the check by existing): report a
873
+ * penalty and keep the first-seen signature.
793
874
  * New peers are accepted normally.
794
875
  */
795
876
  private detectEquivocation(
@@ -991,19 +1072,21 @@ export class ClusterMember implements ICluster {
991
1072
  const maxAllowedRejections = peerCount - superMajority;
992
1073
 
993
1074
  // Check for rejections — rejected if too many rejections to ever reach super-majority.
994
- // ONLY `reject` votes count here: a `conflict` vote is "not now", never a validity
995
- // judgement, so it must not push a record into the permanent `Rejected` phase.
1075
+ // ONLY `reject` votes count here: `conflict` and `held` both mean "not now", never a validity
1076
+ // judgement, so neither may push a record into the permanent `Rejected` phase.
996
1077
  const rejectedPromises = Object.values(record.promises).filter(s => s.type === 'reject');
997
- const conflictPromises = Object.values(record.promises).filter(s => s.type === 'conflict');
1078
+ const retryableRefusals = Object.values(record.promises).filter(s => s.type === 'conflict' || s.type === 'held');
998
1079
  const rejectedCommits = Object.values(record.commits).filter(s => s.type === 'reject');
999
1080
  if (rejectedPromises.length > maxAllowedRejections || this.hasMajority(rejectedCommits.length, peerCount)) {
1000
1081
  return { phase: TransactionPhase.Rejected };
1001
1082
  }
1002
1083
 
1003
- // Conflict votes don't judge validity, but enough of them still make super-majority
1084
+ // Retryable refusals don't judge validity, but enough of them still make super-majority
1004
1085
  // unreachable — a distinct terminal outcome (retryable as a fresh transaction) so logs and
1005
- // reputation-adjacent paths keep meaning what they say.
1006
- if (conflictPromises.length > 0 && rejectedPromises.length + conflictPromises.length > maxAllowedRejections) {
1086
+ // reputation-adjacent paths keep meaning what they say. `held` joins `conflict` here for the
1087
+ // same reason `conflict` clears its record: a member that keeps reserving the blocks of a
1088
+ // transaction that provably cannot win holds them against the very retry meant to win.
1089
+ if (retryableRefusals.length > 0 && rejectedPromises.length + retryableRefusals.length > maxAllowedRejections) {
1007
1090
  return { phase: TransactionPhase.ConflictSuperseded };
1008
1091
  }
1009
1092
 
@@ -1073,24 +1156,20 @@ export class ClusterMember implements ICluster {
1073
1156
  // peer set is a legitimate cluster it belongs to, and refuses (reject vote) rather than rubber-stamping
1074
1157
  // a set the coordinator chose (e.g. a self-shrunk minority-partition set). On admission failure we skip
1075
1158
  // pend validation entirely and emit the membership rejection.
1076
- const validationResult = await this.evaluatePromise(record);
1077
-
1078
- const promiseHash = await this.computePromiseHash(record);
1079
- const type = validationResult.valid ? 'approve' as const : 'reject' as const;
1080
- const rejectReason = validationResult.valid ? undefined : validationResult.reason;
1081
- const sig = await this.signVote(promiseHash, type, rejectReason);
1082
-
1083
- const signature: Signature = validationResult.valid
1084
- ? { type: 'approve', signature: sig }
1085
- : { type: 'reject', signature: sig, rejectReason };
1159
+ const verdict = await this.evaluatePromise(record);
1086
1160
 
1087
- if (!validationResult.valid) {
1088
- log('cluster-member:validation-rejected', {
1161
+ if (!verdict.valid) {
1162
+ // Two tags, because the two refusals are two different events for an operator: one says the
1163
+ // cohort judged a write invalid, the other says a write queued behind a live reservation.
1164
+ log(verdict.kind === 'held' ? 'cluster-member:validation-held' : 'cluster-member:validation-rejected', {
1089
1165
  messageHash: record.messageHash,
1090
- reason: validationResult.reason
1166
+ reason: verdict.reason
1091
1167
  });
1092
1168
  }
1093
1169
 
1170
+ const promiseHash = await this.computePromiseHash(record);
1171
+ const signature = await this.signPromiseVerdict(promiseHash, verdict);
1172
+
1094
1173
  return {
1095
1174
  ...record,
1096
1175
  promises: {
@@ -1100,6 +1179,23 @@ export class ClusterMember implements ICluster {
1100
1179
  };
1101
1180
  }
1102
1181
 
1182
+ /**
1183
+ * Turn a {@link PromiseVerdict} into this member's signed promise vote — one vote kind per refusal
1184
+ * kind. The single place the mapping lives, so a new refusal kind cannot reach the wire as the
1185
+ * wrong vote: the coordinator's thresholds read only `Signature.type`, and until `held` existed the
1186
+ * transient refusal had nowhere to go but `reject`.
1187
+ */
1188
+ private async signPromiseVerdict(promiseHash: string, verdict: PromiseVerdict): Promise<Signature> {
1189
+ if (verdict.valid) {
1190
+ return { type: 'approve', signature: await this.signVote(promiseHash, 'approve') };
1191
+ }
1192
+ if (verdict.kind === 'held') {
1193
+ return { type: 'held', signature: await this.signVote(promiseHash, 'held', verdict.heldBy), heldBy: verdict.heldBy };
1194
+ }
1195
+ const rejectReason = verdict.reason;
1196
+ return { type: 'reject', signature: await this.signVote(promiseHash, 'reject', rejectReason), rejectReason };
1197
+ }
1198
+
1103
1199
  /**
1104
1200
  * Answer a record that lost the deterministic race to a transaction this member already holds
1105
1201
  * (`docs/correctness.md` Theorems 1 & 9: the loser is TOLD it lost, not ignored — an unanswered
@@ -1129,17 +1225,19 @@ export class ClusterMember implements ICluster {
1129
1225
 
1130
1226
  /**
1131
1227
  * The full promise-phase decision for a record: admit the declared membership FIRST, then (only if
1132
- * admitted) validate its pend operations, then its commit operations. Failing any yields a
1133
- * `{ valid:false, reason }` the caller turns into a `reject` vote. Keeping the three separate keeps
1228
+ * admitted) validate its pend operations, then its commit operations. Failing any yields a refusal
1229
+ * the caller turns into a vote — a `reject` for every validity judgement here, and a `held` for the
1230
+ * one transient refusal {@link validatePendOperations} can make (see {@link PromiseVerdict}).
1231
+ * Keeping the checks separate keeps
1134
1232
  * the reason strings distinct — a `membership-not-admitted` reject is a different signal (feeds the
1135
1233
  * dispute path) than a stale-revision / custom-validator reject, which is different again from a
1136
1234
  * `content-digest-mismatch` (see {@link validateCommitOperations}). A record carries pend OR commit
1137
1235
  * operations, so in practice exactly one of the latter two has anything to inspect.
1138
1236
  */
1139
- private async evaluatePromise(record: ClusterRecord): Promise<{ valid: boolean; reason?: string }> {
1237
+ private async evaluatePromise(record: ClusterRecord): Promise<PromiseVerdict> {
1140
1238
  const admission = await this.admitMembership(record);
1141
1239
  if (!admission.admit) {
1142
- return { valid: false, reason: admission.reason ?? MEMBERSHIP_NOT_ADMITTED };
1240
+ return invalidVerdict(admission.reason ?? MEMBERSHIP_NOT_ADMITTED);
1143
1241
  }
1144
1242
  const pendValidation = await this.validatePendOperations(record);
1145
1243
  if (!pendValidation.valid) {
@@ -1151,7 +1249,7 @@ export class ClusterMember implements ICluster {
1151
1249
  // declared one).
1152
1250
  const commitRevValidation = await this.validateCommitRevisions(record);
1153
1251
  if (!commitRevValidation.valid) {
1154
- return commitRevValidation;
1252
+ return verdictOf(commitRevValidation);
1155
1253
  }
1156
1254
  // Then our own refusal history: a commit whose pend THIS member refused, where local state
1157
1255
  // still corroborates the refusal. Runs after the revision check because that one is sharper
@@ -1159,9 +1257,16 @@ export class ClusterMember implements ICluster {
1159
1257
  // applied here; this arm covers the window where it has not.
1160
1258
  const refusedPendValidation = await this.validateCommitAgainstRefusedPend(record);
1161
1259
  if (!refusedPendValidation.valid) {
1162
- return refusedPendValidation;
1260
+ return verdictOf(refusedPendValidation);
1261
+ }
1262
+ // The declared base before the declared content: it needs no materialization, and when the two
1263
+ // disagree the digest check would be previewing operations computed against a different base
1264
+ // than the one declared — its verdict names the symptom, this one names the cause.
1265
+ const baseDeclarationValidation = await this.validateCommitBaseDeclarations(record);
1266
+ if (!baseDeclarationValidation.valid) {
1267
+ return verdictOf(baseDeclarationValidation);
1163
1268
  }
1164
- return await this.validateCommitOperations(record);
1269
+ return verdictOf(await this.validateCommitOperations(record));
1165
1270
  }
1166
1271
 
1167
1272
  /**
@@ -1459,11 +1564,21 @@ export class ClusterMember implements ICluster {
1459
1564
 
1460
1565
  /**
1461
1566
  * Validates pend operations in a cluster record using the transaction validator.
1462
- * Also checks for stale revisions, and for blocks held by a different unresolved pending
1567
+ * Also checks for stale revisions, and for blocks RESERVED by a different unresolved pending
1463
1568
  * action, to prevent consensus on operations that storage would refuse at apply.
1464
1569
  * Returns success if no validator is configured (backwards compatibility).
1570
+ *
1571
+ * Every refusal here is a validity judgement EXCEPT the pending-conflict one, which is transient by
1572
+ * construction and returns the `held` kind — see {@link PromiseVerdict}. Transient means the
1573
+ * reservation it answers is one its holder is still going to commit or cancel: a record claiming
1574
+ * the slot this pend wants, or a later one. A record claiming a slot the collection has already
1575
+ * moved past is NOT a reservation against this pend and is not refused on — see
1576
+ * {@link reservingRivals} — because its holder is never going to remove it: the holder either
1577
+ * committed at that slot on the rest of the cohort (this member missed the commit) or lost the
1578
+ * slot, and in both cases the record would otherwise refuse every later write to the block, from
1579
+ * every writer, for as long as this member lives.
1465
1580
  */
1466
- private async validatePendOperations(record: ClusterRecord): Promise<{ valid: boolean; reason?: string }> {
1581
+ private async validatePendOperations(record: ClusterRecord): Promise<PromiseVerdict> {
1467
1582
  // Find pend operations in the message
1468
1583
  for (const operation of record.message.operations) {
1469
1584
  if ('pend' in operation) {
@@ -1489,7 +1604,7 @@ export class ClusterMember implements ICluster {
1489
1604
  blockId,
1490
1605
  reason: blockResult.unavailable
1491
1606
  });
1492
- return { valid: false, reason: `block ${blockId} unavailable (${blockResult.unavailable}): cannot verify revision` };
1607
+ return invalidVerdict(`block ${blockId} unavailable (${blockResult.unavailable}): cannot verify revision`);
1493
1608
  }
1494
1609
  const latest = blockResult?.state?.latest;
1495
1610
  if (latest !== undefined && latest.rev >= pendRequest.rev) {
@@ -1512,19 +1627,19 @@ export class ClusterMember implements ICluster {
1512
1627
  // is NOT a StaleFailure producer, so StaleFailure.staleAt does not apply; the
1513
1628
  // coordinator's own local re-read (CoordinatorRepo.classifyStaleRejection)
1514
1629
  // supplies that number when it can confirm the revision itself.
1515
- return { valid: false, reason: `stale revision: block ${blockId} at rev ${latest.rev}, requested rev ${pendRequest.rev}` };
1630
+ return invalidVerdict(`stale revision: block ${blockId} at rev ${latest.rev}, requested rev ${pendRequest.rev}`);
1516
1631
  }
1517
1632
  }
1518
1633
  }
1519
1634
 
1520
- // Reject a pend whose blocks are held by a DIFFERENT unresolved pending action. This is
1521
- // the durable reservation the in-memory table (`findConflict` / `activeTransactions`)
1635
+ // Refuse a pend whose blocks are RESERVED by a DIFFERENT unresolved pending action. This
1636
+ // is the durable reservation the in-memory table (`findConflict` / `activeTransactions`)
1522
1637
  // cannot provide: that table clears the moment the rival's PEND record reaches
1523
1638
  // consensus, but the rival's storage pending record — written at pend-apply, removed at
1524
1639
  // commit or cancel — spans exactly the pend→commit window in which `latest.rev` has not
1525
1640
  // yet advanced. Storage's own pend would refuse this request at consensus-apply for the
1526
- // same reason (`StorageRepo.pend`'s listPendingTransactions scan); voting reject here
1527
- // moves that verdict into the phase where the cohort aggregates it, so the loser is
1641
+ // same reason (`StorageRepo.pend`'s pending-claim scan, under the same rule); voting
1642
+ // here moves that verdict into the phase where the cohort aggregates it, so the loser is
1528
1643
  // refused with a real answer instead of burning a consensus round it cannot win. A
1529
1644
  // member that has not yet applied the rival's pend has no record and simply abstains
1530
1645
  // from this reason; the apply-time verdict catches that residual — retained locally
@@ -1533,19 +1648,53 @@ export class ClusterMember implements ICluster {
1533
1648
  // member. Self is excluded so a redelivered pend
1534
1649
  // for this same action stays approvable. An unavailable block carries no `pendings` and
1535
1650
  // abstains (the rev branch above already fail-closes when a revision claim is at stake).
1536
- // Reason stays plain prose: it is fed to computeSigningPayload and carried as
1537
- // Signature.rejectReason, exactly like the stale-revision reason above.
1651
+ //
1652
+ // This is the ONE refusal in this method that is not a validity judgement: a RESERVING
1653
+ // rival's record is removed the moment it commits or cancels, so the very same pend
1654
+ // succeeds on retry. It therefore returns the `held` kind, which becomes a `held` vote
1655
+ // the coordinator counts toward neither approvals nor rejections. `heldBy` carries the
1656
+ // first rival as signed structured data; the prose reason names the same one, and stays
1657
+ // prose because it is fed to computeSigningPayload exactly like the reasons above.
1658
+ //
1659
+ // Which rivals reserve is decided by the slot each record claims against what the
1660
+ // incoming writer built on, not by the record's presence (`reservingRivals`): a record
1661
+ // whose slot is at or below the base this pend declares for the block — or, for a pend
1662
+ // naming no base, below the revision it requests — belongs to a commit this member missed
1663
+ // or a race its holder lost, and would never clear on its own; refusing on it wedged the
1664
+ // block for every writer (ticket `a-member-that-missed-a-commit-refuses-every-later-write`).
1665
+ // Approving over it is safe because the incoming writer's operations were computed
1666
+ // against a version of the block that already holds that record's change. This member
1667
+ // comes current when the approved pend's own commit applies here — through
1668
+ // `internalCommit`, or through the behind-reconcile its fork guard triggers. In a cohort
1669
+ // that can reach its promise bar without one member (`cohortCanMissAPend`: four members up
1670
+ // at the default threshold), a record claiming a slot PAST the declared base still
1671
+ // reserves even when the requested revision has moved beyond it: that is a writer that
1672
+ // read the block without the record's change — served by a member that never held the
1673
+ // rival's pend — and admitting it would lose the change. See `isReservationAgainst`.
1538
1674
  for (const blockId of blockIds) {
1539
- const rivals = (blockResults[blockId]?.state?.pendings ?? []).filter(actionId => actionId !== pendRequest.actionId);
1540
- if (rivals.length > 0) {
1541
- log('cluster-member:validation-pending-conflict', {
1542
- messageHash: record.messageHash,
1543
- blockId,
1544
- actionId: pendRequest.actionId,
1545
- rivals
1546
- });
1547
- return { valid: false, reason: `pending conflict: block ${blockId} held by unresolved action(s) ${rivals.join(', ')}` };
1675
+ const rivalIds = (blockResults[blockId]?.state?.pendings ?? []).filter(actionId => actionId !== pendRequest.actionId);
1676
+ const rivals = rivalIds.length === 0 ? [] : await this.reservingRivals(record, blockId, rivalIds, pendRequest);
1677
+ if (rivals.length === 0) {
1678
+ // Not reserved (any more): whatever episode this member was counting on the block
1679
+ // has ended, so a later wedge gets its own count.
1680
+ this.stuckReservations.forget([blockId]);
1681
+ continue;
1548
1682
  }
1683
+ const heldBy = rivals[0]!;
1684
+ log('cluster-member:validation-pending-conflict', {
1685
+ messageHash: record.messageHash,
1686
+ blockId,
1687
+ actionId: pendRequest.actionId,
1688
+ requestedRev: pendRequest.rev,
1689
+ rivals
1690
+ });
1691
+ this.nameStuckReservation(blockId, rivals, pendRequest.actionId);
1692
+ return {
1693
+ valid: false,
1694
+ kind: 'held',
1695
+ heldBy,
1696
+ reason: `pending conflict: block ${blockId} held by unresolved action(s) ${rivals.join(', ')}`
1697
+ };
1549
1698
  }
1550
1699
 
1551
1700
  // Re-check the transaction when a validator is configured. The unvalidatable-pend
@@ -1572,7 +1721,7 @@ export class ClusterMember implements ICluster {
1572
1721
  })
1573
1722
  );
1574
1723
  if (!validation.valid) {
1575
- return { valid: false, reason: validation.reason };
1724
+ return invalidVerdict(validation.reason);
1576
1725
  }
1577
1726
  }
1578
1727
  }
@@ -1580,6 +1729,88 @@ export class ClusterMember implements ICluster {
1580
1729
  return { valid: true };
1581
1730
  }
1582
1731
 
1732
+ /**
1733
+ * Of the rival pending records `get` listed on `blockId`, the ones that RESERVE the block against
1734
+ * `pendRequest` — see `isReservationAgainst` for the rule, fed the pend's revision and the base it
1735
+ * declares for this block (`reservationRequestFor`, the same reading storage applies at apply).
1736
+ * Asks storage's {@link IPendingClaimReader} for the slot each record claims; a repo without that
1737
+ * capability (a plain `IRepo` mock) or a read that fails degrades to "every rival reserves", which
1738
+ * is the refusal this member cast before claims were recorded — never to silently admitting one. A
1739
+ * rival that `get` listed but that is gone by the time the claims are read has resolved in
1740
+ * between, and is not a rival any more.
1741
+ */
1742
+ private async reservingRivals(record: ClusterRecord, blockId: BlockId, rivalIds: ActionId[], pendRequest: PendRequest): Promise<ActionId[]> {
1743
+ const reader = this.storageRepo as IRepo & Partial<IPendingClaimReader>;
1744
+ if (typeof reader.listPendingClaims !== 'function') {
1745
+ return rivalIds;
1746
+ }
1747
+ let claims: PendingClaim[];
1748
+ try {
1749
+ claims = await reader.listPendingClaims(blockId);
1750
+ } catch (err) {
1751
+ log('cluster-member:pending-claims-read-error', { messageHash: record.messageHash, blockId, error: (err as Error).message });
1752
+ return rivalIds;
1753
+ }
1754
+ const reservation = this.reservationRequestOf(record, pendRequest, blockId);
1755
+ const claimOf = new Map(claims.map(claim => [claim.actionId, claim]));
1756
+ const reserving: ActionId[] = [];
1757
+ for (const actionId of rivalIds) {
1758
+ const claim = claimOf.get(actionId);
1759
+ if (claim === undefined) continue;
1760
+ if (isReservationAgainst(claim, reservation)) {
1761
+ reserving.push(actionId);
1762
+ } else {
1763
+ log('cluster-member:validation-pending-superseded', {
1764
+ messageHash: record.messageHash,
1765
+ blockId,
1766
+ actionId: pendRequest.actionId,
1767
+ requestedRev: pendRequest.rev,
1768
+ baseRev: reservation.baseRev,
1769
+ rival: actionId,
1770
+ claimedRev: claim.rev
1771
+ });
1772
+ }
1773
+ }
1774
+ return reserving;
1775
+ }
1776
+
1777
+ /** `pendRequest`'s {@link ReservationRequest} for `blockId`, logging a base the rule cannot read —
1778
+ * never a refusal: `baseRevs` is untrusted wire data, and a malformed entry only drops that block
1779
+ * back to the revision rule. The base is read only when `record`'s cohort can reach its promise
1780
+ * super-majority without one of its members (`cohortCanMissAPend`); in a cohort that needs every
1781
+ * member, a stray record on one member (a cancel that never reached it) would otherwise refuse
1782
+ * every later writer for good, and no member can have missed the pend the base arm guards against. */
1783
+ private reservationRequestOf(record: ClusterRecord, pendRequest: PendRequest, blockId: BlockId): ReservationRequest {
1784
+ if (!cohortCanMissAPend(Object.keys(record.peers).length, this.superMajorityThreshold)) {
1785
+ return { rev: pendRequest.rev };
1786
+ }
1787
+ const { request, ignoredBase } = reservationRequestFor(pendRequest, blockId, transformForBlockId(pendRequest.transforms, blockId));
1788
+ if (ignoredBase !== undefined) {
1789
+ log('cluster-member:pend-base-ignored', {
1790
+ messageHash: record.messageHash,
1791
+ blockId,
1792
+ actionId: pendRequest.actionId,
1793
+ requestedRev: pendRequest.rev,
1794
+ base: ignoredBase
1795
+ });
1796
+ }
1797
+ return request;
1798
+ }
1799
+
1800
+ /**
1801
+ * Feed this member's own `held` vote to its {@link stuckReservations} counter and say, once per
1802
+ * episode and in words, when a block is wedged behind a reservation that is not going to clear —
1803
+ * the member-side twin of `CoordinatorRepo.noteStuckReservation`, for the reservations that
1804
+ * node cannot corroborate from its own storage. `peerId` is carried because this logger is not
1805
+ * suffixed with one and several members can share a process.
1806
+ */
1807
+ private nameStuckReservation(blockId: BlockId, holders: readonly ActionId[], refusedActionId: ActionId): void {
1808
+ const { named } = this.stuckReservations.note(new Map([[blockId, holders]]), refusedActionId);
1809
+ for (const episode of named) {
1810
+ log('cluster-member:stuck-reservation', { peerId: this.peerId.toString(), ...episode });
1811
+ }
1812
+ }
1813
+
1583
1814
  /**
1584
1815
  * Promise-round check that a commit record's requested revision is not already committed HERE
1585
1816
  * under a different action. This is the member-side arm that keeps a DEAD rival's re-broadcast
@@ -1793,6 +2024,113 @@ export class ClusterMember implements ICluster {
1793
2024
  return undefined;
1794
2025
  }
1795
2026
 
2027
+ /**
2028
+ * Promise-round check that a commit declares, for each block, the base revision the same action's
2029
+ * PEND carried to this member. The author says the base twice — `PendRequest.baseRevs[id]`, kept
2030
+ * with this member's pending record as `PendingClaim.baseRev`, and
2031
+ * `CommitRequest.blockDigests[id].baseRev` — and an honest author says the same thing both times
2032
+ * (`Tracker` pins the base at staging; the pend and the digest both read that pin). When they
2033
+ * differ, this member's record holds operations computed against a base other than the one the
2034
+ * commit is about to be applied as, so vote reject with {@link BASE_DECLARATION_DISAGREES}.
2035
+ * `StorageRepo.internalCommit` refuses the same shape at apply (`guardCommitBase`); refusing here
2036
+ * too puts a signed verdict on the record one round earlier, and when the cohort's records agree
2037
+ * with each other but not with the commit (a writer whose commit contradicts its own pend) the
2038
+ * whole cohort refuses at the vote, where the apply-time refusal alone would have every member
2039
+ * refuse after consensus and reconcile against a revision nobody holds.
2040
+ *
2041
+ * Needs no preview, so it runs on a repo that can read one record's claim
2042
+ * ({@link IPendingClaimReader}`.pendingClaimOf`) whether or not it can materialize. Abstains — votes
2043
+ * as it would without the check — whenever it cannot compare: the commit declares nothing, an entry
2044
+ * is surplus to `blockIds` or carries no numeric `baseRev` (untrusted wire data, same posture as the
2045
+ * digest check), this member holds no record for the action on the block, the record carries no
2046
+ * base (an inserted or deleted block, a base-less sender, or a record written before bases were
2047
+ * kept), the read fails — or this member does not hold the declared base as its latest.
2048
+ *
2049
+ * That last abstain is what keeps an HONEST retry off this reject. The one disagreement an honest
2050
+ * writer can meet here is a stale record from an earlier attempt of a retried action (same action
2051
+ * id) meeting the retry's commit, when the retry's pend — which would have overwritten the record
2052
+ * — never reached this member. Such a member is typically behind: it missed a pend, and the retry
2053
+ * declares a base the rest of the cohort moved to. At three members one reject sinks the commit
2054
+ * record (the default super-majority allows none), where abstaining lets the others commit and
2055
+ * leaves this member to `guardCommitBase`, which refuses to apply the stale record and reconciles.
2056
+ * When this member's latest IS the declared base, the stale operations are exactly what it would
2057
+ * apply over it, and the digest check below rejects the same shape whenever they materialize
2058
+ * differently; rejecting here names the cause instead.
2059
+ */
2060
+ private async validateCommitBaseDeclarations(record: ClusterRecord): Promise<{ valid: boolean; reason?: string }> {
2061
+ const reader = this.storageRepo as IRepo & Partial<IPendingClaimReader>;
2062
+ if (typeof reader.pendingClaimOf !== 'function') {
2063
+ return { valid: true };
2064
+ }
2065
+ for (const operation of record.message.operations) {
2066
+ if (!('commit' in operation)) {
2067
+ continue;
2068
+ }
2069
+ const commit = operation.commit;
2070
+ if (!commit.blockDigests) {
2071
+ continue;
2072
+ }
2073
+ const committedIds = new Set<string>(commit.blockIds);
2074
+ for (const [blockId, declared] of Object.entries(commit.blockDigests)) {
2075
+ const declaredBaseRev: unknown = declared?.baseRev;
2076
+ if (!committedIds.has(blockId) || typeof declaredBaseRev !== 'number') {
2077
+ continue;
2078
+ }
2079
+ let storedBaseRev: number | undefined;
2080
+ try {
2081
+ storedBaseRev = (await reader.pendingClaimOf(blockId as BlockId, commit.actionId))?.baseRev;
2082
+ } catch (err) {
2083
+ log('cluster-member:base-declaration-read-error', {
2084
+ messageHash: record.messageHash,
2085
+ blockId,
2086
+ error: err instanceof Error ? err.message : String(err)
2087
+ });
2088
+ continue; // a local read fault is an abstain, never a verdict
2089
+ }
2090
+ if (storedBaseRev === undefined || storedBaseRev === declaredBaseRev) {
2091
+ continue;
2092
+ }
2093
+ const latestRev = await this.latestRevOf(record, blockId as BlockId);
2094
+ if (latestRev !== declaredBaseRev) {
2095
+ log('cluster-member:base-declaration-disagrees-abstained', {
2096
+ messageHash: record.messageHash,
2097
+ blockId,
2098
+ actionId: commit.actionId,
2099
+ storedBaseRev,
2100
+ declaredBaseRev,
2101
+ latestRev
2102
+ });
2103
+ continue;
2104
+ }
2105
+ log('cluster-member:base-declaration-disagrees', {
2106
+ messageHash: record.messageHash,
2107
+ blockId,
2108
+ actionId: commit.actionId,
2109
+ rev: commit.rev,
2110
+ storedBaseRev,
2111
+ declaredBaseRev
2112
+ });
2113
+ return { valid: false, reason: BASE_DECLARATION_DISAGREES };
2114
+ }
2115
+ }
2116
+ return { valid: true };
2117
+ }
2118
+
2119
+ /** This member's own committed revision of `blockId` (raw storage, no cluster recursion), or
2120
+ * `undefined` when it holds none or the read fails — a fault reads as "cannot judge". */
2121
+ private async latestRevOf(record: ClusterRecord, blockId: BlockId): Promise<number | undefined> {
2122
+ try {
2123
+ return (await this.storageRepo.get({ blockIds: [blockId] }))[blockId]?.state?.latest?.rev;
2124
+ } catch (err) {
2125
+ log('cluster-member:base-declaration-read-error', {
2126
+ messageHash: record.messageHash,
2127
+ blockId,
2128
+ error: err instanceof Error ? err.message : String(err)
2129
+ });
2130
+ return undefined;
2131
+ }
2132
+ }
2133
+
1796
2134
  /**
1797
2135
  * Promise-round check of a commit record's declared content digests
1798
2136
  * (`CommitRequest.blockDigests`) against what this member's OWN pended copy of each transform
@@ -2147,7 +2485,9 @@ export class ClusterMember implements ICluster {
2147
2485
  // verdicts — its own member's through getExecutedCommitResult, every other member's off the
2148
2486
  // response record — and the two readers must see the same answer, hence one verdict,
2149
2487
  // computed once, retained here for both.
2150
- this.executedCommitResults.set(messageHash, await this.durableCommitVerdict(commit, applied));
2488
+ const verdict = await this.durableCommitVerdict(commit, applied);
2489
+ this.executedCommitResults.set(messageHash, verdict);
2490
+ if (verdict.success) this.reportCommittedHolders(record, commit);
2151
2491
  return;
2152
2492
  }
2153
2493
  if ('invalidate' in operation) {
@@ -2268,7 +2608,10 @@ export class ClusterMember implements ICluster {
2268
2608
  return applied;
2269
2609
  }
2270
2610
  log('cluster-member:consensus-commit-durable-after-reconcile', { actionId: commit.actionId, rev: commit.rev });
2271
- return { success: true };
2611
+ // A member's own verdict about its own storage: `local`. Read by the coordinator for its
2612
+ // `success` flag only (`cohortCommitOutcomes`, `localCommitResult`) — it never becomes the
2613
+ // answer handed to the writer; `CoordinatorRepo.commit` computes the cohort's own on every exit.
2614
+ return { success: true, durability: localDurability() };
2272
2615
  }
2273
2616
 
2274
2617
  /**
@@ -2392,6 +2735,29 @@ export class ClusterMember implements ICluster {
2392
2735
  }
2393
2736
  }
2394
2737
 
2738
+ /**
2739
+ * Tell the {@link CommittedHoldersSink} which cohort members hold a commit this member just
2740
+ * durably applied. A member learns nothing about the others' storage at apply time, so the
2741
+ * evidence is the record's approving commit signers: each is a cohort member that signed to apply
2742
+ * this commit. A signer that then failed to apply is wrongly recorded, which only spares it a
2743
+ * rebalance push; its own reconcile, read-repair, and — on the coordinating node — the
2744
+ * coordinator's durability-checked report (which lands after this one and overrides it) still
2745
+ * reach it.
2746
+ */
2747
+ private reportCommittedHolders(record: ClusterRecord, commit: CommitRequest): void {
2748
+ if (!this.onCommittedHolders) {
2749
+ return;
2750
+ }
2751
+ const holders = Object.entries(record.commits)
2752
+ .filter(([peerId, vote]) => vote.type === 'approve' && peerId in record.peers)
2753
+ .map(([peerId]) => peerId);
2754
+ try {
2755
+ this.onCommittedHolders({ blockIds: commit.blockIds, holders });
2756
+ } catch (err) {
2757
+ log('cluster-member:committed-holders-sink-error', { actionId: commit.actionId, error: (err as Error).message });
2758
+ }
2759
+ }
2760
+
2395
2761
  /**
2396
2762
  * After tolerating a "behind" commit divergence, pull the committed revision of
2397
2763
  * each block from a cohort peer that holds it and restore it locally. Best-effort: