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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +73 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +70 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +16 -1
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +73 -72
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts +4 -6
  10. package/dist/src/cluster/client.d.ts.map +1 -1
  11. package/dist/src/cluster/client.js +8 -13
  12. package/dist/src/cluster/client.js.map +1 -1
  13. package/dist/src/cluster/cluster-policy.d.ts +2 -1
  14. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  15. package/dist/src/cluster/cluster-policy.js.map +1 -1
  16. package/dist/src/cluster/cluster-repo.d.ts +155 -9
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +446 -58
  19. package/dist/src/cluster/cluster-repo.js.map +1 -1
  20. package/dist/src/cluster/commit-proof.js +2 -2
  21. package/dist/src/cluster/race-resolution.d.ts +51 -7
  22. package/dist/src/cluster/race-resolution.d.ts.map +1 -1
  23. package/dist/src/cluster/race-resolution.js +71 -7
  24. package/dist/src/cluster/race-resolution.js.map +1 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
  26. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  27. package/dist/src/cluster/rebalance-monitor.js +120 -26
  28. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  29. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  30. package/dist/src/cluster/spread-on-churn.js +34 -47
  31. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  32. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  33. package/dist/src/cohort-topic/host.js +8 -6
  34. package/dist/src/cohort-topic/host.js.map +1 -1
  35. package/dist/src/cohort-topic/membership-source.js +1 -1
  36. package/dist/src/cohort-topic/membership-source.js.map +1 -1
  37. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
  38. package/dist/src/cohort-topic/peer-codec.js +5 -2
  39. package/dist/src/cohort-topic/peer-codec.js.map +1 -1
  40. package/dist/src/cohort-topic/stream-util.d.ts +24 -3
  41. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  42. package/dist/src/cohort-topic/stream-util.js +36 -3
  43. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  44. package/dist/src/cohort-topic/topic-router.d.ts +9 -0
  45. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
  46. package/dist/src/cohort-topic/topic-router.js +12 -2
  47. package/dist/src/cohort-topic/topic-router.js.map +1 -1
  48. package/dist/src/dispute/client.d.ts.map +1 -1
  49. package/dist/src/dispute/client.js +15 -3
  50. package/dist/src/dispute/client.js.map +1 -1
  51. package/dist/src/index.d.ts +5 -0
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +5 -0
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/libp2p-key-network.d.ts +78 -17
  56. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  57. package/dist/src/libp2p-key-network.js +234 -147
  58. package/dist/src/libp2p-key-network.js.map +1 -1
  59. package/dist/src/libp2p-node-base.d.ts +35 -1
  60. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  61. package/dist/src/libp2p-node-base.js +151 -26
  62. package/dist/src/libp2p-node-base.js.map +1 -1
  63. package/dist/src/libp2p-node-rn.d.ts +2 -2
  64. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  65. package/dist/src/libp2p-node-rn.js.map +1 -1
  66. package/dist/src/libp2p-node.d.ts +2 -2
  67. package/dist/src/libp2p-node.d.ts.map +1 -1
  68. package/dist/src/libp2p-node.js.map +1 -1
  69. package/dist/src/logger.d.ts.map +1 -1
  70. package/dist/src/logger.js +6 -1
  71. package/dist/src/logger.js.map +1 -1
  72. package/dist/src/matchmaking/query-transport.d.ts +3 -4
  73. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  74. package/dist/src/matchmaking/query-transport.js +9 -9
  75. package/dist/src/matchmaking/query-transport.js.map +1 -1
  76. package/dist/src/network/network-manager-service.d.ts +10 -5
  77. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  78. package/dist/src/network/network-manager-service.js +11 -2
  79. package/dist/src/network/network-manager-service.js.map +1 -1
  80. package/dist/src/network/relay-reservation.d.ts +170 -0
  81. package/dist/src/network/relay-reservation.d.ts.map +1 -0
  82. package/dist/src/network/relay-reservation.js +527 -0
  83. package/dist/src/network/relay-reservation.js.map +1 -0
  84. package/dist/src/pend-validation.d.ts +3 -2
  85. package/dist/src/pend-validation.d.ts.map +1 -1
  86. package/dist/src/pend-validation.js +3 -2
  87. package/dist/src/pend-validation.js.map +1 -1
  88. package/dist/src/protocol-client.d.ts +8 -1
  89. package/dist/src/protocol-client.d.ts.map +1 -1
  90. package/dist/src/protocol-client.js +11 -0
  91. package/dist/src/protocol-client.js.map +1 -1
  92. package/dist/src/reactivity/forwarder-host.d.ts +2 -1
  93. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
  94. package/dist/src/reactivity/forwarder-host.js +2 -1
  95. package/dist/src/reactivity/forwarder-host.js.map +1 -1
  96. package/dist/src/reactivity/origination-manager.d.ts +3 -3
  97. package/dist/src/reactivity/origination-manager.js +3 -3
  98. package/dist/src/reactivity/recover-transport.d.ts +22 -14
  99. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  100. package/dist/src/reactivity/recover-transport.js +32 -19
  101. package/dist/src/reactivity/recover-transport.js.map +1 -1
  102. package/dist/src/reactivity/subscription-manager.d.ts +2 -2
  103. package/dist/src/reactivity/topic-bytes.d.ts +6 -7
  104. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
  105. package/dist/src/reactivity/topic-bytes.js +8 -9
  106. package/dist/src/reactivity/topic-bytes.js.map +1 -1
  107. package/dist/src/repo/client.d.ts +5 -2
  108. package/dist/src/repo/client.d.ts.map +1 -1
  109. package/dist/src/repo/client.js +49 -40
  110. package/dist/src/repo/client.js.map +1 -1
  111. package/dist/src/repo/cluster-coordinator.d.ts +96 -11
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +164 -25
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +240 -122
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +627 -473
  118. package/dist/src/repo/coordinator-repo.js.map +1 -1
  119. package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
  120. package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
  121. package/dist/src/repo/i-under-replication-ledger.js +2 -0
  122. package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
  123. package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
  124. package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
  125. package/dist/src/repo/kv-under-replication-ledger.js +287 -0
  126. package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
  127. package/dist/src/repo/responsibility.d.ts +37 -0
  128. package/dist/src/repo/responsibility.d.ts.map +1 -0
  129. package/dist/src/repo/responsibility.js +37 -0
  130. package/dist/src/repo/responsibility.js.map +1 -0
  131. package/dist/src/repo/service.d.ts +44 -15
  132. package/dist/src/repo/service.d.ts.map +1 -1
  133. package/dist/src/repo/service.js +64 -27
  134. package/dist/src/repo/service.js.map +1 -1
  135. package/dist/src/repo/stuck-reservation.d.ts +127 -0
  136. package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
  137. package/dist/src/repo/stuck-reservation.js +179 -0
  138. package/dist/src/repo/stuck-reservation.js.map +1 -0
  139. package/dist/src/repo/under-replication-drain.d.ts +208 -0
  140. package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
  141. package/dist/src/repo/under-replication-drain.js +461 -0
  142. package/dist/src/repo/under-replication-drain.js.map +1 -0
  143. package/dist/src/rn.d.ts +5 -0
  144. package/dist/src/rn.d.ts.map +1 -1
  145. package/dist/src/rn.js +5 -0
  146. package/dist/src/rn.js.map +1 -1
  147. package/dist/src/storage/block-latch.d.ts +3 -2
  148. package/dist/src/storage/block-latch.d.ts.map +1 -1
  149. package/dist/src/storage/block-latch.js +7 -4
  150. package/dist/src/storage/block-latch.js.map +1 -1
  151. package/dist/src/storage/block-storage.d.ts +45 -3
  152. package/dist/src/storage/block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/block-storage.js +156 -9
  154. package/dist/src/storage/block-storage.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +57 -5
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js.map +1 -1
  158. package/dist/src/storage/pending-claim.d.ts +167 -0
  159. package/dist/src/storage/pending-claim.d.ts.map +1 -0
  160. package/dist/src/storage/pending-claim.js +156 -0
  161. package/dist/src/storage/pending-claim.js.map +1 -0
  162. package/dist/src/storage/raw-store-codec.d.ts +13 -0
  163. package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +24 -3
  165. package/dist/src/storage/raw-store-codec.js.map +1 -1
  166. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
  167. package/dist/src/storage/restoration-coordinator.js +2 -1
  168. package/dist/src/storage/restoration-coordinator.js.map +1 -1
  169. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
  170. package/dist/src/storage/ring-shift-coordinator.js +3 -3
  171. package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
  172. package/dist/src/storage/storage-repo.d.ts +122 -5
  173. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  174. package/dist/src/storage/storage-repo.js +245 -68
  175. package/dist/src/storage/storage-repo.js.map +1 -1
  176. package/dist/src/storage/struct.d.ts +58 -0
  177. package/dist/src/storage/struct.d.ts.map +1 -1
  178. package/dist/src/testing/mesh-harness.d.ts +77 -11
  179. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  180. package/dist/src/testing/mesh-harness.js +226 -100
  181. package/dist/src/testing/mesh-harness.js.map +1 -1
  182. package/package.json +2 -2
  183. package/readme.md +27 -3
  184. package/src/cluster/block-transfer-service.ts +132 -4
  185. package/src/cluster/block-transfer.ts +79 -84
  186. package/src/cluster/client.ts +115 -119
  187. package/src/cluster/cluster-policy.ts +2 -1
  188. package/src/cluster/cluster-repo.ts +493 -65
  189. package/src/cluster/commit-proof.ts +2 -2
  190. package/src/cluster/race-resolution.ts +76 -7
  191. package/src/cluster/rebalance-monitor.ts +677 -539
  192. package/src/cluster/spread-on-churn.ts +34 -54
  193. package/src/cohort-topic/host.ts +8 -6
  194. package/src/cohort-topic/membership-source.ts +1 -1
  195. package/src/cohort-topic/peer-codec.ts +34 -31
  196. package/src/cohort-topic/stream-util.ts +39 -4
  197. package/src/cohort-topic/topic-router.ts +12 -2
  198. package/src/dispute/client.ts +17 -7
  199. package/src/index.ts +64 -59
  200. package/src/libp2p-key-network.ts +1377 -1260
  201. package/src/libp2p-node-base.ts +186 -27
  202. package/src/libp2p-node-rn.ts +31 -30
  203. package/src/libp2p-node.ts +37 -36
  204. package/src/logger.ts +6 -1
  205. package/src/matchmaking/query-transport.ts +9 -9
  206. package/src/network/network-manager-service.ts +16 -5
  207. package/src/network/relay-reservation.ts +636 -0
  208. package/src/pend-validation.ts +3 -2
  209. package/src/protocol-client.ts +13 -1
  210. package/src/reactivity/forwarder-host.ts +2 -1
  211. package/src/reactivity/origination-manager.ts +3 -3
  212. package/src/reactivity/recover-transport.ts +39 -22
  213. package/src/reactivity/subscription-manager.ts +2 -2
  214. package/src/reactivity/topic-bytes.ts +35 -37
  215. package/src/repo/client.ts +172 -173
  216. package/src/repo/cluster-coordinator.ts +183 -25
  217. package/src/repo/coordinator-repo.ts +656 -506
  218. package/src/repo/i-under-replication-ledger.ts +89 -0
  219. package/src/repo/kv-under-replication-ledger.ts +302 -0
  220. package/src/repo/responsibility.ts +48 -0
  221. package/src/repo/service.ts +76 -39
  222. package/src/repo/stuck-reservation.ts +224 -0
  223. package/src/repo/under-replication-drain.ts +582 -0
  224. package/src/rn.ts +74 -69
  225. package/src/storage/block-latch.ts +7 -4
  226. package/src/storage/block-storage.ts +161 -10
  227. package/src/storage/i-block-storage.ts +60 -5
  228. package/src/storage/pending-claim.ts +200 -0
  229. package/src/storage/raw-store-codec.ts +26 -3
  230. package/src/storage/restoration-coordinator.ts +212 -211
  231. package/src/storage/ring-shift-coordinator.ts +271 -271
  232. package/src/storage/storage-repo.ts +280 -72
  233. package/src/storage/struct.ts +58 -0
  234. package/src/testing/mesh-harness.ts +293 -112
@@ -1,6 +1,6 @@
1
1
  import { peerIdFromString } from "@libp2p/peer-id";
2
2
  import type { ClusterRecord, IKeyNetwork, RepoMessage, BlockId, ClusterPeers, MessageOptions, ClusterConsensusConfig, ICluster, PendResult, CommitResult, StaleFailure } from "@optimystic/db-core";
3
- import { CURRENT_MEMBERSHIP_VERSION, computeClusterMessageHash, isConflictFailure, membershipDigest } from "@optimystic/db-core";
3
+ import { CURRENT_MEMBERSHIP_VERSION, computeClusterMessageHash, isConflictFailure, membershipDigest, routingKeyForBlock } from "@optimystic/db-core";
4
4
  import { Pending } from "@optimystic/db-core";
5
5
  import type { PeerId } from "@libp2p/interface";
6
6
  import { createLogger, verbose } from '../logger.js'
@@ -9,6 +9,7 @@ import type { FretService } from "p2p-fret";
9
9
  import type { IPeerReputation } from "../reputation/types.js";
10
10
  import { PenaltyReason } from "../reputation/types.js";
11
11
  import type { ITransactionStateStore } from "../cluster/i-transaction-state-store.js";
12
+ import { ResponsibilityRefusalError } from "./responsibility.js";
12
13
 
13
14
  const log = createLogger('cluster')
14
15
 
@@ -91,6 +92,33 @@ export class ConflictRaceLostError extends Error {
91
92
  }
92
93
  }
93
94
 
95
+ /**
96
+ * The transaction's pend could not proceed because one or more members answered with a signed `held`
97
+ * vote: the requested blocks are reserved by a different unresolved action in that member's durable
98
+ * storage. Sibling of {@link ConflictRaceLostError} and retryable for the same reason — nobody judged
99
+ * this write invalid; it queued behind a reservation that disappears when the holder commits or
100
+ * cancels.
101
+ *
102
+ * The two are separate because they name different things. A conflict vote names the winning rival's
103
+ * `messageHash`, which the member holds whole; a held vote can only name the rival's **action id**,
104
+ * because it fires in the window where the rival has left the member's in-memory table but not yet its
105
+ * storage. `CoordinatorRepo.pend` converts this into a `StaleFailure` with `conflict: true` so the
106
+ * normal retry machinery (`isConflictFailure`) absorbs it, exactly as it does a lost race.
107
+ *
108
+ * Only a PEND record can produce it: `held` votes come from `ClusterMember.validatePendOperations`,
109
+ * which inspects pend operations only, so `CoordinatorRepo.commit` never meets one.
110
+ */
111
+ export class BlocksHeldError extends Error {
112
+ constructor(
113
+ message: string,
114
+ /** peerId → actionId of the unresolved action that member's storage says holds the blocks. */
115
+ readonly heldBy: Record<string, string>
116
+ ) {
117
+ super(message);
118
+ this.name = 'BlocksHeldError';
119
+ }
120
+ }
121
+
94
122
  /** Cancel handle for an injected timer; cancels a not-yet-fired timer (safe no-op after fire/cancel). */
95
123
  export type TimerCancel = () => void;
96
124
 
@@ -140,6 +168,16 @@ interface ClusterTransactionState {
140
168
  }
141
169
 
142
170
  /** Manages distributed transactions across clusters */
171
+ /**
172
+ * What a cohort lookup established about a block's cohort. `resolved: false` covers BOTH a lookup
173
+ * that threw and one that answered with nobody: neither names a destination for a write, and the
174
+ * durability class both produce is the same (`unrouted`). `reason` is for logs only — never branch
175
+ * on it.
176
+ */
177
+ export type CohortResolution =
178
+ | { readonly resolved: true; readonly peerIds: readonly string[] }
179
+ | { readonly resolved: false; readonly reason: string };
180
+
143
181
  export class ClusterCoordinator {
144
182
  private transactions: Map<string, ClusterTransactionState> = new Map();
145
183
  private readonly retryInitialIntervalMs: number;
@@ -165,6 +203,8 @@ export class ClusterCoordinator {
165
203
  getExecutedPendResult?: (messageHash: string) => PendResult | undefined;
166
204
  /** Local storage's verdict for a commit applied during consensus; see ClusterMember.getExecutedCommitResult. */
167
205
  getExecutedCommitResult?: (messageHash: string) => CommitResult | undefined;
206
+ /** One more reconcile for a behind-refused commit, once remote members hold it; see ClusterMember.reconcileRefusedCommit. */
207
+ reconcileRefusedCommit?: (record: ClusterRecord) => Promise<void>;
168
208
  },
169
209
  private readonly fretService?: FretService,
170
210
  private readonly reputation?: IPeerReputation,
@@ -236,21 +276,76 @@ export class ClusterCoordinator {
236
276
  }
237
277
 
238
278
  /**
239
- * Gets all peers in the cluster for a specific block ID
279
+ * The ONE cohort lookup every accessor on this class derives from: the raw peer map when the key
280
+ * network answered, otherwise the reason it did not. A thrown `findCluster` is logged here and
281
+ * nowhere else. Callers that need the map (`executeClusterTransaction`, which builds the record's
282
+ * `peers`) go through {@link getClusterForBlock}; callers that need to know whether the cohort
283
+ * RESOLVED go through {@link resolveCohort}.
240
284
  */
241
- private async getClusterForBlock(blockId: BlockId): Promise<ClusterPeers> {
242
- const blockIdBytes = new TextEncoder().encode(blockId);
285
+ private async lookupCluster(blockId: BlockId): Promise<{ peers: ClusterPeers } | { reason: string }> {
243
286
  try {
244
- const peers = await this.keyNetwork.findCluster(blockIdBytes);
287
+ const peers = await this.keyNetwork.findCluster(routingKeyForBlock(blockId));
245
288
  const peerIds = Object.keys(peers ?? {});
246
289
  log('cluster-tx:cluster-members', { blockId, peerIds });
247
- return peers;
290
+ return { peers: peers ?? {} };
248
291
  } catch (e) {
249
292
  log('WARN findCluster failed for %s: %o', blockId, e)
250
- return {} as ClusterPeers
293
+ return { reason: `findCluster threw: ${(e as Error)?.message ?? String(e)}` };
251
294
  }
252
295
  }
253
296
 
297
+ /**
298
+ * Gets all peers in the cluster for a specific block ID. Empty when the lookup failed — the
299
+ * consensus path treats "no cohort" and "lookup failed" alike (there is nobody to run consensus
300
+ * with either way); a caller that must tell them apart uses {@link resolveCohort}.
301
+ */
302
+ private async getClusterForBlock(blockId: BlockId): Promise<ClusterPeers> {
303
+ const outcome = await this.lookupCluster(blockId);
304
+ return 'peers' in outcome ? outcome.peers : {};
305
+ }
306
+
307
+ /**
308
+ * Whether the block's cohort could be established, and who it is. The primitive behind
309
+ * {@link getClusterPeerIds} and {@link getClusterSize}: a lookup that threw and a lookup that named
310
+ * nobody used to reach every caller as the same empty list, and `CoordinatorRepo`'s solo
311
+ * short-circuit then acknowledged a write it had no idea where to send exactly as it acknowledged a
312
+ * write to a genuine cohort of one (GitHub #19). Both shapes are still `resolved: false` here —
313
+ * neither names a destination — but they are distinguishable from a resolved cohort, which is what
314
+ * the write's durability class needs (`unrouted` vs `local`).
315
+ */
316
+ async resolveCohort(blockId: BlockId): Promise<CohortResolution> {
317
+ const outcome = await this.lookupCluster(blockId);
318
+ if ('reason' in outcome) return { resolved: false, reason: outcome.reason };
319
+ const peerIds = Object.keys(outcome.peers);
320
+ if (peerIds.length === 0) return { resolved: false, reason: 'findCluster named nobody' };
321
+ return { resolved: true, peerIds };
322
+ }
323
+
324
+ /**
325
+ * A node never runs a cluster transaction for a cohort it is not in. Behind members reconcile from the
326
+ * coordinator's own proof-carrying copy (its member applies before the merged record fans out), and a
327
+ * coordinator outside `record.peers` is not a reconcile target — so a cohort with no holder would stay
328
+ * behind and the commit durability gate would refuse, having first put this node's vote and storage
329
+ * where the cohort does not look. The invariant is held here, at the one place a record's `peers` is
330
+ * chosen, rather than left to the routing convention.
331
+ *
332
+ * Fires only on a RESOLVED cohort (at least one peer) that excludes the wired local member. An empty
333
+ * cohort is a failed lookup, not a cohort this node is outside of, so it is left to `executeTransaction`'s
334
+ * size checks; `CoordinatorRepo`'s solo short-circuit keeps unresolved and single-peer cohorts away from
335
+ * this method altogether in any case. After its responsibility check, what remains is a multi-member
336
+ * cohort that changed inside the responsibility cache's staleness window. With no local member wired the guard does not apply: that
337
+ * bypass exists for wiring without an identity (direct constructors, some tests), never for production.
338
+ */
339
+ private assertLocalMemberInCohort(blockId: BlockId, peers: ClusterPeers): void {
340
+ if (!this.localCluster) return;
341
+ const peerIds = Object.keys(peers);
342
+ const selfId = this.localCluster.peerId.toString();
343
+ if (peerIds.length === 0 || peerIds.includes(selfId)) return;
344
+ log('cluster-tx:not-in-cohort', { blockId, selfId, peerIds });
345
+ throw new ResponsibilityRefusalError('not-responsible', [blockId],
346
+ `refusing to coordinate a cluster transaction for a cohort this node is not in: ${peerIds.join(', ')}`);
347
+ }
348
+
254
349
  private makeRecord(peers: ClusterPeers, messageHash: string, message: RepoMessage, membershipDigestValue: string): ClusterRecord {
255
350
  const peerCount = Object.keys(peers ?? {}).length;
256
351
  const record: ClusterRecord = {
@@ -302,7 +397,9 @@ export class ClusterCoordinator {
302
397
  * during consensus, when the member retained one. Same availability contract as
303
398
  * `localPendResult`. `CoordinatorRepo.commit` uses a retained refusal to detect a rival's
304
399
  * win swallowed by the member-side ahead-divergence tolerance, instead of fabricating a
305
- * success no member durably stored.
400
+ * success no member durably stored. Read after the commit broadcast, so a behind member's
401
+ * verdict already reflects the second reconcile `broadcastMergedRecord` gives it once a
402
+ * remote member holds the revision.
306
403
  */
307
404
  localCommitResult?: CommitResult;
308
405
  /**
@@ -356,6 +453,7 @@ export class ClusterCoordinator {
356
453
 
357
454
  // Get the cluster peers for this block
358
455
  const peers = await this.getClusterForBlock(blockId);
456
+ this.assertLocalMemberInCohort(blockId, peers);
359
457
 
360
458
  // Bind the responsible membership into the transaction identity (v2): the digest is folded into
361
459
  // the messageHash below, so two different peer sets produce two different messageHashes rather
@@ -494,18 +592,24 @@ export class ClusterCoordinator {
494
592
  const promised = await this.collectPromises(peers, record);
495
593
  const superMajority = Math.ceil(peerCount * this.cfg.superMajorityThreshold);
496
594
 
497
- // Count approvals, rejections and conflict votes separately. A `conflict` vote is a member
498
- // saying "not now — I hold the race winner": it must count toward NEITHER approvals NOR
499
- // rejections, or a lost race would masquerade as a validator rejection (permanent) or as
500
- // silence (indistinguishable from an unreachable cohort) both wrong.
595
+ // Count approvals, rejections and the two RETRYABLE refusals separately. A `conflict` vote is a
596
+ // member saying "not now — I hold the race winner"; a `held` vote is a member saying "not now —
597
+ // a different unresolved action holds these blocks in my storage". Neither may count toward
598
+ // approvals OR rejections, or a transient refusal would masquerade as a validator rejection
599
+ // (permanent) or as silence (indistinguishable from an unreachable cohort) — both wrong.
501
600
  const promises = promised.record.promises;
502
601
  const approvalCount = Object.values(promises).filter(sig => sig.type === 'approve').length;
503
602
  const rejectionCount = Object.values(promises).filter(sig => sig.type === 'reject').length;
504
603
  const conflictCount = Object.values(promises).filter(sig => sig.type === 'conflict').length;
604
+ const heldCount = Object.values(promises).filter(sig => sig.type === 'held').length;
505
605
 
506
606
  // Check if rejections make super-majority impossible
507
607
  // If more than (peerCount - superMajority) nodes reject, we can never reach super-majority
508
608
  const maxAllowedRejections = peerCount - superMajority;
609
+ // Whether the merged record itself PROVES super-majority unreachable — the same sum a member
610
+ // re-derives as `ConflictSuperseded`/`Rejected` from the signed votes, which is what makes an
611
+ // abandonment broadcast proof-carrying rather than an unauthenticated "forget this".
612
+ const refusalsProveUnreachable = rejectionCount + conflictCount + heldCount > maxAllowedRejections;
509
613
  if (rejectionCount > maxAllowedRejections) {
510
614
  const rejectReasonsByPeer = Object.fromEntries(Object.entries(promises)
511
615
  .flatMap(([peerId, sig]) => sig.type === 'reject' ? [[peerId, sig.rejectReason ?? 'unknown'] as const] : []));
@@ -552,7 +656,7 @@ export class ClusterCoordinator {
552
656
  // super-majority (members re-derive ConflictSuperseded/Rejected from the signed votes and
553
657
  // clear their reservations immediately). Below that bar the record proves nothing and a
554
658
  // broadcast would be the unauthenticated "forget this" the shortfall NOTE below refuses.
555
- if (rejectionCount + conflictCount > maxAllowedRejections) {
659
+ if (refusalsProveUnreachable) {
556
660
  this.broadcastAbandonment(promised.record, 'conflict-race-lost');
557
661
  }
558
662
  throw new ConflictRaceLostError(
@@ -560,6 +664,31 @@ export class ClusterCoordinator {
560
664
  conflicts);
561
665
  }
562
666
 
667
+ // A `held`-answered shortfall is the OTHER retryable refusal: the pend queued behind a rival's
668
+ // unresolved reservation. Checked after the conflict branch so a lost race still wins when both
669
+ // answer — a conflict vote names the winning transaction's messageHash, which is strictly more
670
+ // actionable than an action id — and, like it, before the generic shortfall, which must stay
671
+ // reserved for the genuinely-silent cohort.
672
+ if (heldCount > 0 && approvalCount < superMajority) {
673
+ const heldBy = Object.fromEntries(Object.entries(promises)
674
+ .flatMap(([peerId, sig]) => sig.type === 'held' ? [[peerId, sig.heldBy] as const] : []));
675
+ log('cluster-tx:pend-blocks-held', {
676
+ messageHash: record.messageHash,
677
+ peerCount,
678
+ approvals: approvalCount,
679
+ rejections: rejectionCount,
680
+ heldBy,
681
+ superMajority
682
+ });
683
+ this.updateTransactionRecord(promised.record, 'pend-blocks-held');
684
+ if (refusalsProveUnreachable) {
685
+ this.broadcastAbandonment(promised.record, 'pend-blocks-held');
686
+ }
687
+ throw new BlocksHeldError(
688
+ `Pend blocks held: ${heldCount}/${peerCount} member(s) hold an unresolved rival action (${approvalCount}/${superMajority} approvals)`,
689
+ heldBy);
690
+ }
691
+
563
692
  if (peerCount > 1 && approvalCount < superMajority) {
564
693
  log('cluster-tx:supermajority-failed', {
565
694
  messageHash: record.messageHash,
@@ -578,7 +707,8 @@ export class ClusterCoordinator {
578
707
  // their own staleness sweep instead.
579
708
  // NOTE: the message below is load-bearing wire text — the consuming repo
580
709
  // (sereus cadre-core control-write-retry) matches it verbatim to retry a genuinely-silent
581
- // cohort. Keep it byte-identical, and never fold conflict votes into its rejection count.
710
+ // cohort. Keep it byte-identical, and never fold `conflict` or `held` votes into its
711
+ // rejection count.
582
712
  throw new Error(`Failed to get super-majority: ${approvalCount}/${peerCount} approvals (needed ${superMajority}, ${rejectionCount} rejections)`);
583
713
  }
584
714
 
@@ -614,14 +744,14 @@ export class ClusterCoordinator {
614
744
  }
615
745
 
616
746
  /**
617
- * The block's cohort peer ids as currently derivable. Empty when `findCluster` fails
618
- * (getClusterForBlock swallows the throw), so a caller branching on `length <= 1` is also taking
619
- * the degraded-routing branch; `CoordinatorRepo.commit` uses the ids to log whether a solo cohort
620
- * is genuinely just self or a routing failure.
747
+ * The block's cohort peer ids as currently derivable. Empty when the cohort did not resolve
748
+ * ({@link resolveCohort}: `findCluster` threw, or named nobody), so a caller branching on
749
+ * `length <= 1` is also taking the degraded-routing branch. Derived from `resolveCohort` rather
750
+ * than re-deriving the cohort, so there is exactly one lookup rule.
621
751
  */
622
752
  async getClusterPeerIds(blockId: BlockId): Promise<string[]> {
623
- const peers = await this.getClusterForBlock(blockId);
624
- return Object.keys(peers ?? {});
753
+ const cohort = await this.resolveCohort(blockId);
754
+ return cohort.resolved ? [...cohort.peerIds] : [];
625
755
  }
626
756
 
627
757
  /** {@link getClusterPeerIds}, counted. Derived from it rather than re-deriving the cohort, so the
@@ -922,11 +1052,14 @@ export class ClusterCoordinator {
922
1052
  * reconcile target and gains nothing from this ordering; the durability gate in
923
1053
  * `CoordinatorRepo.commit` is what makes that shape refuse rather than acknowledge.
924
1054
  *
925
- * NOTE: when the coordinating member is ITSELF behind (it never saw the pend), its reconcile
926
- * runs here before any remote member has applied, finds no holder, and reports not-durable; the
927
- * remote members then apply and may carry the majority on their own. Fine while the coordinator
928
- * ordinarily saw the pend; if coordinators are routinely picked after the pend phase, deliver
929
- * local-first only when the local member holds the pend, or reconcile it once more afterwards.
1055
+ * The mirror case — the coordinating member is ITSELF behind (no pend, or no base for the block)
1056
+ * — is the price of that order: its reconcile runs before any remote member has applied, finds
1057
+ * no holder, and retains a refusal. So once the remote members have answered, and at least one
1058
+ * reported holding the revision, this node's own member gets one more reconcile
1059
+ * (`reconcileRefusedCommit`). The member skips it unless its retained refusal has the behind
1060
+ * shape, so only a behind coordinator pays the extra fetch. It finishes before this method
1061
+ * returns, so `executeClusterTransaction` reads the refreshed verdict, and a two-member cohort
1062
+ * whose members both end up holding the commit is no longer refused as not durable.
930
1063
  */
931
1064
  private async broadcastMergedRecord(record: ClusterRecord, peerIds: string[]): Promise<{ failures: string[]; applyOutcomes?: ClusterRecord['applyOutcomes'] }> {
932
1065
  const deliver = async (peerIdStr: string) => {
@@ -958,9 +1091,34 @@ export class ClusterCoordinator {
958
1091
  // asked, so a member cannot report an outcome on another member's behalf by echoing a record
959
1092
  // full of entries. Unsigned and advisory either way — see ClusterRecord.applyOutcomes.
960
1093
  const applyOutcomes = collectApplyOutcomes(results);
1094
+ // NOTE: after a healing second reconcile, `applyOutcomes[selfId].commit` still carries the
1095
+ // pre-reconcile refusal. Nothing reads the self entry today (the gate reads
1096
+ // `localCommitResult`); if anything starts to, re-stamp it from `getExecutedCommitResult` here.
1097
+ // NOTE: in a 3+ cohort this also runs when the remote holders already form a majority without
1098
+ // this member — one extra fetch that heals its copy; gate on the remote count if it ever shows up.
1099
+ const remoteHolds = remote.some(id => applyOutcomes?.[id]?.commit?.success === true);
1100
+ if (remoteHolds && localResults.some(r => r.success)) {
1101
+ await this.reconcileLocalMemberAgain(record);
1102
+ }
961
1103
  return { failures, ...(applyOutcomes === undefined ? {} : { applyOutcomes }) };
962
1104
  }
963
1105
 
1106
+ /**
1107
+ * Give this node's own member its second reconcile (see {@link broadcastMergedRecord}). The
1108
+ * member contract is never to throw; the catch keeps a broken seam from failing a transaction
1109
+ * the remote members already applied.
1110
+ */
1111
+ private async reconcileLocalMemberAgain(record: ClusterRecord): Promise<void> {
1112
+ try {
1113
+ await this.localCluster?.reconcileRefusedCommit?.(record);
1114
+ } catch (err) {
1115
+ log('cluster-tx:local-reconcile-again-error', {
1116
+ messageHash: record.messageHash,
1117
+ error: err instanceof Error ? err.message : String(err)
1118
+ });
1119
+ }
1120
+ }
1121
+
964
1122
  /**
965
1123
  * Fire-and-forget replay of an abandoned transaction's record to every peer in its cohort.
966
1124
  *