@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,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;
@@ -236,21 +274,76 @@ export class ClusterCoordinator {
236
274
  }
237
275
 
238
276
  /**
239
- * Gets all peers in the cluster for a specific block ID
277
+ * The ONE cohort lookup every accessor on this class derives from: the raw peer map when the key
278
+ * network answered, otherwise the reason it did not. A thrown `findCluster` is logged here and
279
+ * nowhere else. Callers that need the map (`executeClusterTransaction`, which builds the record's
280
+ * `peers`) go through {@link getClusterForBlock}; callers that need to know whether the cohort
281
+ * RESOLVED go through {@link resolveCohort}.
240
282
  */
241
- private async getClusterForBlock(blockId: BlockId): Promise<ClusterPeers> {
242
- const blockIdBytes = new TextEncoder().encode(blockId);
283
+ private async lookupCluster(blockId: BlockId): Promise<{ peers: ClusterPeers } | { reason: string }> {
243
284
  try {
244
- const peers = await this.keyNetwork.findCluster(blockIdBytes);
285
+ const peers = await this.keyNetwork.findCluster(routingKeyForBlock(blockId));
245
286
  const peerIds = Object.keys(peers ?? {});
246
287
  log('cluster-tx:cluster-members', { blockId, peerIds });
247
- return peers;
288
+ return { peers: peers ?? {} };
248
289
  } catch (e) {
249
290
  log('WARN findCluster failed for %s: %o', blockId, e)
250
- return {} as ClusterPeers
291
+ return { reason: `findCluster threw: ${(e as Error)?.message ?? String(e)}` };
251
292
  }
252
293
  }
253
294
 
295
+ /**
296
+ * Gets all peers in the cluster for a specific block ID. Empty when the lookup failed — the
297
+ * consensus path treats "no cohort" and "lookup failed" alike (there is nobody to run consensus
298
+ * with either way); a caller that must tell them apart uses {@link resolveCohort}.
299
+ */
300
+ private async getClusterForBlock(blockId: BlockId): Promise<ClusterPeers> {
301
+ const outcome = await this.lookupCluster(blockId);
302
+ return 'peers' in outcome ? outcome.peers : {};
303
+ }
304
+
305
+ /**
306
+ * Whether the block's cohort could be established, and who it is. The primitive behind
307
+ * {@link getClusterPeerIds} and {@link getClusterSize}: a lookup that threw and a lookup that named
308
+ * nobody used to reach every caller as the same empty list, and `CoordinatorRepo`'s solo
309
+ * short-circuit then acknowledged a write it had no idea where to send exactly as it acknowledged a
310
+ * write to a genuine cohort of one (GitHub #19). Both shapes are still `resolved: false` here —
311
+ * neither names a destination — but they are distinguishable from a resolved cohort, which is what
312
+ * the write's durability class needs (`unrouted` vs `local`).
313
+ */
314
+ async resolveCohort(blockId: BlockId): Promise<CohortResolution> {
315
+ const outcome = await this.lookupCluster(blockId);
316
+ if ('reason' in outcome) return { resolved: false, reason: outcome.reason };
317
+ const peerIds = Object.keys(outcome.peers);
318
+ if (peerIds.length === 0) return { resolved: false, reason: 'findCluster named nobody' };
319
+ return { resolved: true, peerIds };
320
+ }
321
+
322
+ /**
323
+ * A node never runs a cluster transaction for a cohort it is not in. Behind members reconcile from the
324
+ * coordinator's own proof-carrying copy (its member applies before the merged record fans out), and a
325
+ * coordinator outside `record.peers` is not a reconcile target — so a cohort with no holder would stay
326
+ * behind and the commit durability gate would refuse, having first put this node's vote and storage
327
+ * where the cohort does not look. The invariant is held here, at the one place a record's `peers` is
328
+ * chosen, rather than left to the routing convention.
329
+ *
330
+ * Fires only on a RESOLVED cohort (at least one peer) that excludes the wired local member. An empty
331
+ * cohort is a failed lookup, not a cohort this node is outside of, so it is left to `executeTransaction`'s
332
+ * size checks; `CoordinatorRepo`'s solo short-circuit keeps unresolved and single-peer cohorts away from
333
+ * this method altogether in any case. After its responsibility check, what remains is a multi-member
334
+ * cohort that changed inside the responsibility cache's staleness window. With no local member wired the guard does not apply: that
335
+ * bypass exists for wiring without an identity (direct constructors, some tests), never for production.
336
+ */
337
+ private assertLocalMemberInCohort(blockId: BlockId, peers: ClusterPeers): void {
338
+ if (!this.localCluster) return;
339
+ const peerIds = Object.keys(peers);
340
+ const selfId = this.localCluster.peerId.toString();
341
+ if (peerIds.length === 0 || peerIds.includes(selfId)) return;
342
+ log('cluster-tx:not-in-cohort', { blockId, selfId, peerIds });
343
+ throw new ResponsibilityRefusalError('not-responsible', [blockId],
344
+ `refusing to coordinate a cluster transaction for a cohort this node is not in: ${peerIds.join(', ')}`);
345
+ }
346
+
254
347
  private makeRecord(peers: ClusterPeers, messageHash: string, message: RepoMessage, membershipDigestValue: string): ClusterRecord {
255
348
  const peerCount = Object.keys(peers ?? {}).length;
256
349
  const record: ClusterRecord = {
@@ -356,6 +449,7 @@ export class ClusterCoordinator {
356
449
 
357
450
  // Get the cluster peers for this block
358
451
  const peers = await this.getClusterForBlock(blockId);
452
+ this.assertLocalMemberInCohort(blockId, peers);
359
453
 
360
454
  // Bind the responsible membership into the transaction identity (v2): the digest is folded into
361
455
  // the messageHash below, so two different peer sets produce two different messageHashes rather
@@ -494,18 +588,24 @@ export class ClusterCoordinator {
494
588
  const promised = await this.collectPromises(peers, record);
495
589
  const superMajority = Math.ceil(peerCount * this.cfg.superMajorityThreshold);
496
590
 
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.
591
+ // Count approvals, rejections and the two RETRYABLE refusals separately. A `conflict` vote is a
592
+ // member saying "not now — I hold the race winner"; a `held` vote is a member saying "not now —
593
+ // a different unresolved action holds these blocks in my storage". Neither may count toward
594
+ // approvals OR rejections, or a transient refusal would masquerade as a validator rejection
595
+ // (permanent) or as silence (indistinguishable from an unreachable cohort) — both wrong.
501
596
  const promises = promised.record.promises;
502
597
  const approvalCount = Object.values(promises).filter(sig => sig.type === 'approve').length;
503
598
  const rejectionCount = Object.values(promises).filter(sig => sig.type === 'reject').length;
504
599
  const conflictCount = Object.values(promises).filter(sig => sig.type === 'conflict').length;
600
+ const heldCount = Object.values(promises).filter(sig => sig.type === 'held').length;
505
601
 
506
602
  // Check if rejections make super-majority impossible
507
603
  // If more than (peerCount - superMajority) nodes reject, we can never reach super-majority
508
604
  const maxAllowedRejections = peerCount - superMajority;
605
+ // Whether the merged record itself PROVES super-majority unreachable — the same sum a member
606
+ // re-derives as `ConflictSuperseded`/`Rejected` from the signed votes, which is what makes an
607
+ // abandonment broadcast proof-carrying rather than an unauthenticated "forget this".
608
+ const refusalsProveUnreachable = rejectionCount + conflictCount + heldCount > maxAllowedRejections;
509
609
  if (rejectionCount > maxAllowedRejections) {
510
610
  const rejectReasonsByPeer = Object.fromEntries(Object.entries(promises)
511
611
  .flatMap(([peerId, sig]) => sig.type === 'reject' ? [[peerId, sig.rejectReason ?? 'unknown'] as const] : []));
@@ -552,7 +652,7 @@ export class ClusterCoordinator {
552
652
  // super-majority (members re-derive ConflictSuperseded/Rejected from the signed votes and
553
653
  // clear their reservations immediately). Below that bar the record proves nothing and a
554
654
  // broadcast would be the unauthenticated "forget this" the shortfall NOTE below refuses.
555
- if (rejectionCount + conflictCount > maxAllowedRejections) {
655
+ if (refusalsProveUnreachable) {
556
656
  this.broadcastAbandonment(promised.record, 'conflict-race-lost');
557
657
  }
558
658
  throw new ConflictRaceLostError(
@@ -560,6 +660,31 @@ export class ClusterCoordinator {
560
660
  conflicts);
561
661
  }
562
662
 
663
+ // A `held`-answered shortfall is the OTHER retryable refusal: the pend queued behind a rival's
664
+ // unresolved reservation. Checked after the conflict branch so a lost race still wins when both
665
+ // answer — a conflict vote names the winning transaction's messageHash, which is strictly more
666
+ // actionable than an action id — and, like it, before the generic shortfall, which must stay
667
+ // reserved for the genuinely-silent cohort.
668
+ if (heldCount > 0 && approvalCount < superMajority) {
669
+ const heldBy = Object.fromEntries(Object.entries(promises)
670
+ .flatMap(([peerId, sig]) => sig.type === 'held' ? [[peerId, sig.heldBy] as const] : []));
671
+ log('cluster-tx:pend-blocks-held', {
672
+ messageHash: record.messageHash,
673
+ peerCount,
674
+ approvals: approvalCount,
675
+ rejections: rejectionCount,
676
+ heldBy,
677
+ superMajority
678
+ });
679
+ this.updateTransactionRecord(promised.record, 'pend-blocks-held');
680
+ if (refusalsProveUnreachable) {
681
+ this.broadcastAbandonment(promised.record, 'pend-blocks-held');
682
+ }
683
+ throw new BlocksHeldError(
684
+ `Pend blocks held: ${heldCount}/${peerCount} member(s) hold an unresolved rival action (${approvalCount}/${superMajority} approvals)`,
685
+ heldBy);
686
+ }
687
+
563
688
  if (peerCount > 1 && approvalCount < superMajority) {
564
689
  log('cluster-tx:supermajority-failed', {
565
690
  messageHash: record.messageHash,
@@ -578,7 +703,8 @@ export class ClusterCoordinator {
578
703
  // their own staleness sweep instead.
579
704
  // NOTE: the message below is load-bearing wire text — the consuming repo
580
705
  // (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.
706
+ // cohort. Keep it byte-identical, and never fold `conflict` or `held` votes into its
707
+ // rejection count.
582
708
  throw new Error(`Failed to get super-majority: ${approvalCount}/${peerCount} approvals (needed ${superMajority}, ${rejectionCount} rejections)`);
583
709
  }
584
710
 
@@ -614,14 +740,14 @@ export class ClusterCoordinator {
614
740
  }
615
741
 
616
742
  /**
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.
743
+ * The block's cohort peer ids as currently derivable. Empty when the cohort did not resolve
744
+ * ({@link resolveCohort}: `findCluster` threw, or named nobody), so a caller branching on
745
+ * `length <= 1` is also taking the degraded-routing branch. Derived from `resolveCohort` rather
746
+ * than re-deriving the cohort, so there is exactly one lookup rule.
621
747
  */
622
748
  async getClusterPeerIds(blockId: BlockId): Promise<string[]> {
623
- const peers = await this.getClusterForBlock(blockId);
624
- return Object.keys(peers ?? {});
749
+ const cohort = await this.resolveCohort(blockId);
750
+ return cohort.resolved ? [...cohort.peerIds] : [];
625
751
  }
626
752
 
627
753
  /** {@link getClusterPeerIds}, counted. Derived from it rather than re-deriving the cohort, so the