@optimystic/db-p2p 0.22.0 → 0.24.1

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 (194) hide show
  1. package/dist/src/cluster/client.d.ts +10 -0
  2. package/dist/src/cluster/client.d.ts.map +1 -1
  3. package/dist/src/cluster/client.js +30 -1
  4. package/dist/src/cluster/client.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +39 -14
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +226 -119
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/service.d.ts +32 -1
  10. package/dist/src/cluster/service.d.ts.map +1 -1
  11. package/dist/src/cluster/service.js +43 -2
  12. package/dist/src/cluster/service.js.map +1 -1
  13. package/dist/src/cohort-topic/host.js +34 -11
  14. package/dist/src/cohort-topic/host.js.map +1 -1
  15. package/dist/src/cohort-topic/stream-util.d.ts +37 -7
  16. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  17. package/dist/src/cohort-topic/stream-util.js +77 -19
  18. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  19. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  20. package/dist/src/dispute/dispute-service.js +9 -3
  21. package/dist/src/dispute/dispute-service.js.map +1 -1
  22. package/dist/src/index.d.ts +3 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +3 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/libp2p-key-network.d.ts +88 -2
  27. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  28. package/dist/src/libp2p-key-network.js +134 -28
  29. package/dist/src/libp2p-key-network.js.map +1 -1
  30. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  31. package/dist/src/libp2p-node-base.js +25 -1
  32. package/dist/src/libp2p-node-base.js.map +1 -1
  33. package/dist/src/logger.d.ts +17 -1
  34. package/dist/src/logger.d.ts.map +1 -1
  35. package/dist/src/logger.js +19 -2
  36. package/dist/src/logger.js.map +1 -1
  37. package/dist/src/matchmaking/query-transport.js +3 -3
  38. package/dist/src/matchmaking/query-transport.js.map +1 -1
  39. package/dist/src/owned-block-seed.d.ts +6 -3
  40. package/dist/src/owned-block-seed.d.ts.map +1 -1
  41. package/dist/src/owned-block-seed.js +16 -3
  42. package/dist/src/owned-block-seed.js.map +1 -1
  43. package/dist/src/peer-address-book.d.ts +72 -0
  44. package/dist/src/peer-address-book.d.ts.map +1 -0
  45. package/dist/src/peer-address-book.js +123 -0
  46. package/dist/src/peer-address-book.js.map +1 -0
  47. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  48. package/dist/src/reactivity/notify-transport.js +6 -6
  49. package/dist/src/reactivity/notify-transport.js.map +1 -1
  50. package/dist/src/reactivity/push-state-gossip.js +2 -2
  51. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  52. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  53. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  54. package/dist/src/reactivity/recover-transport.js +7 -3
  55. package/dist/src/reactivity/recover-transport.js.map +1 -1
  56. package/dist/src/repo/client.d.ts.map +1 -1
  57. package/dist/src/repo/client.js +11 -2
  58. package/dist/src/repo/client.js.map +1 -1
  59. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  60. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  61. package/dist/src/repo/cluster-coordinator.js +95 -3
  62. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  63. package/dist/src/repo/coordinator-repo.d.ts +62 -9
  64. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  65. package/dist/src/repo/coordinator-repo.js +242 -73
  66. package/dist/src/repo/coordinator-repo.js.map +1 -1
  67. package/dist/src/rn.d.ts +3 -0
  68. package/dist/src/rn.d.ts.map +1 -1
  69. package/dist/src/rn.js +3 -0
  70. package/dist/src/rn.js.map +1 -1
  71. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  72. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  73. package/dist/src/storage/cached-raw-storage.js +152 -0
  74. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  75. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  76. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  77. package/dist/src/storage/cached-store-driver.js +775 -0
  78. package/dist/src/storage/cached-store-driver.js.map +1 -0
  79. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  80. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  81. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  82. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  83. package/dist/src/storage/shared-cache-pool.js +354 -0
  84. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  85. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  86. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  87. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  88. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  89. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  90. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  91. package/dist/src/testing/raw-storage-conformance.js +35 -2
  92. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  93. package/package.json +3 -3
  94. package/readme.md +668 -668
  95. package/src/cluster/block-transfer.ts +424 -424
  96. package/src/cluster/client.ts +119 -88
  97. package/src/cluster/cluster-error.ts +64 -64
  98. package/src/cluster/cluster-policy.ts +203 -203
  99. package/src/cluster/cluster-repo.ts +242 -122
  100. package/src/cluster/cluster-size-coupling.ts +45 -45
  101. package/src/cluster/commit-cert.ts +139 -139
  102. package/src/cluster/i-transaction-state-store.ts +43 -43
  103. package/src/cluster/memory-transaction-state-store.ts +56 -56
  104. package/src/cluster/peer-key-binding.ts +37 -37
  105. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  106. package/src/cluster/quorum-restore.ts +223 -223
  107. package/src/cluster/reconcile-block.ts +203 -203
  108. package/src/cluster/service.ts +293 -241
  109. package/src/cluster/supermajority-coupling.ts +37 -37
  110. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  111. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  112. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  113. package/src/cohort-topic/change-bridge.ts +109 -109
  114. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  115. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  116. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  117. package/src/cohort-topic/host.ts +42 -11
  118. package/src/cohort-topic/index.ts +13 -13
  119. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  120. package/src/cohort-topic/membership-source.ts +68 -68
  121. package/src/cohort-topic/peer-codec.ts +31 -31
  122. package/src/cohort-topic/peer-sig.ts +86 -86
  123. package/src/cohort-topic/protocols.ts +71 -71
  124. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  125. package/src/cohort-topic/size-estimator.ts +16 -16
  126. package/src/cohort-topic/stream-util.ts +79 -19
  127. package/src/cohort-topic/threshold-crypto.ts +239 -239
  128. package/src/cohort-topic/topic-router.ts +77 -77
  129. package/src/dispute/arbitrator-selection.ts +138 -138
  130. package/src/dispute/cascade.ts +524 -524
  131. package/src/dispute/dispute-service.ts +11 -5
  132. package/src/dispute/invalidation.ts +625 -625
  133. package/src/inbound-authorization.ts +190 -190
  134. package/src/index.ts +52 -49
  135. package/src/libp2p-key-network.ts +1120 -990
  136. package/src/libp2p-node-base.ts +1675 -1651
  137. package/src/libp2p-node-rn.ts +30 -30
  138. package/src/libp2p-node.ts +36 -36
  139. package/src/logger.ts +19 -2
  140. package/src/matchmaking/aggregate-counts.ts +104 -104
  141. package/src/matchmaking/index.ts +20 -20
  142. package/src/matchmaking/module.ts +363 -363
  143. package/src/matchmaking/protocols.ts +51 -51
  144. package/src/matchmaking/provider-manager.ts +95 -95
  145. package/src/matchmaking/query-handler.ts +88 -88
  146. package/src/matchmaking/query-transport.ts +3 -3
  147. package/src/matchmaking/seeker-manager.ts +64 -64
  148. package/src/matchmaking/seeker-walk-client.ts +293 -293
  149. package/src/matchmaking/traffic-validation.ts +195 -195
  150. package/src/optimystic-node.ts +36 -36
  151. package/src/owned-block-seed.ts +53 -40
  152. package/src/peer-address-book.ts +149 -0
  153. package/src/protocol-limits.ts +33 -33
  154. package/src/reactivity/forwarder-host.ts +438 -438
  155. package/src/reactivity/index.ts +19 -19
  156. package/src/reactivity/notify-transport.ts +144 -144
  157. package/src/reactivity/origination-manager.ts +192 -192
  158. package/src/reactivity/protocols.ts +61 -61
  159. package/src/reactivity/push-state-gossip.ts +291 -291
  160. package/src/reactivity/recover-transport.ts +7 -3
  161. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  162. package/src/reactivity/subscriber-registry.ts +96 -96
  163. package/src/reactivity/subscription-manager.ts +450 -450
  164. package/src/reactivity/topic-bytes.ts +37 -37
  165. package/src/repo/client.ts +12 -2
  166. package/src/repo/cluster-coordinator.ts +99 -3
  167. package/src/repo/coordinator-repo.ts +281 -74
  168. package/src/repo/types.ts +7 -7
  169. package/src/rn.ts +39 -36
  170. package/src/rpc-deadline.ts +45 -45
  171. package/src/storage/arachnode-partition.ts +74 -74
  172. package/src/storage/cached-raw-storage.ts +180 -0
  173. package/src/storage/cached-store-driver.ts +859 -0
  174. package/src/storage/i-kv-store.ts +8 -8
  175. package/src/storage/i-raw-storage.ts +12 -5
  176. package/src/storage/kv-raw-storage.ts +135 -135
  177. package/src/storage/memory-kv-store.ts +28 -28
  178. package/src/storage/memory-storage.ts +25 -25
  179. package/src/storage/memory-store-driver.ts +157 -157
  180. package/src/storage/raw-store-codec.ts +42 -42
  181. package/src/storage/raw-store-driver.ts +80 -80
  182. package/src/storage/ring-selector.ts +317 -317
  183. package/src/storage/ring-shift-coordinator.ts +271 -271
  184. package/src/storage/shared-cache-pool.ts +452 -0
  185. package/src/storage/storage-repo.ts +1014 -1014
  186. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
  187. package/src/testing/index.ts +8 -8
  188. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  189. package/src/testing/raw-storage-conformance.ts +453 -417
  190. package/src/testing/reactivity-mesh-harness.ts +922 -922
  191. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  192. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  193. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  194. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,37 +1,37 @@
1
- /**
2
- * Reactivity — the one pinned `BlockId` → raw tail-bytes encoding, shared by origination and subscription.
3
- *
4
- * `reactivityTopicId` (db-core) hashes `H(tailBytes ‖ "reactivity")` to derive the topic's `coord_0`
5
- * cohort. Origination (the cohort-change membership gate, {@link
6
- * import("../cohort-topic/reactivity-membership-gate.js").createReactivitySelfMembershipGate}) and the
7
- * subscriber side (the production subscribe factory that converts a `BlockId` `tailIdAtAttach` to the bytes
8
- * {@link import("./subscription-manager.js").ReactivitySubscriptionManager} feeds `reactivityTopicId`) MUST
9
- * feed it the **same** bytes for a given tail, or they resolve different coords and origination silently
10
- * never reaches subscribers (green tests, dead feature).
11
- *
12
- * This module is that single source of truth: `reactivityTailBytes(tailId) = TextEncoder().encode(tailId)`,
13
- * the synchronous block-id→bytes convention the rest of db-p2p already uses for ring hashing of tail ids
14
- * (`cluster/client.ts`, `repo/client.ts`). db-core's async `blockIdToBytes` (which `sha256`s the utf8 bytes
15
- * first) is deliberately NOT used: it would (a) double-hash relative to the spec's `H(tailId ‖ "reactivity")`
16
- * and (b) force the synchronous origination gate to become async.
17
- *
18
- * It lives in the reactivity surface (not buried in the gate) so the subscribe path and the gate import the
19
- * exact same function rather than re-deriving the encoding independently. The
20
- * `cohort-topic/reactivity-membership-gate.ts` module re-exports it for its existing callers.
21
- */
22
-
23
- import type { BlockId } from "@optimystic/db-core";
24
-
25
- const utf8 = new TextEncoder();
26
-
27
- /**
28
- * The pinned `BlockId` → raw tail bytes encoding fed into `reactivityTopicId`: the **raw** utf8 bytes of the
29
- * tail block-id string (NOT a pre-hashed routing key, NOT db-core's double-hashing `blockIdToBytes`).
30
- *
31
- * Load-bearing: origination and the subscriber side must call this **same** function for a given tail (see
32
- * the module doc) — a mismatch resolves different `coord_0`s and origination silently never reaches
33
- * subscribers. The `topic-bytes-encoding` spec pins the coord-equality both ways.
34
- */
35
- export function reactivityTailBytes(tailId: BlockId): Uint8Array {
36
- return utf8.encode(tailId);
37
- }
1
+ /**
2
+ * Reactivity — the one pinned `BlockId` → raw tail-bytes encoding, shared by origination and subscription.
3
+ *
4
+ * `reactivityTopicId` (db-core) hashes `H(tailBytes ‖ "reactivity")` to derive the topic's `coord_0`
5
+ * cohort. Origination (the cohort-change membership gate, {@link
6
+ * import("../cohort-topic/reactivity-membership-gate.js").createReactivitySelfMembershipGate}) and the
7
+ * subscriber side (the production subscribe factory that converts a `BlockId` `tailIdAtAttach` to the bytes
8
+ * {@link import("./subscription-manager.js").ReactivitySubscriptionManager} feeds `reactivityTopicId`) MUST
9
+ * feed it the **same** bytes for a given tail, or they resolve different coords and origination silently
10
+ * never reaches subscribers (green tests, dead feature).
11
+ *
12
+ * This module is that single source of truth: `reactivityTailBytes(tailId) = TextEncoder().encode(tailId)`,
13
+ * the synchronous block-id→bytes convention the rest of db-p2p already uses for ring hashing of tail ids
14
+ * (`cluster/client.ts`, `repo/client.ts`). db-core's async `blockIdToBytes` (which `sha256`s the utf8 bytes
15
+ * first) is deliberately NOT used: it would (a) double-hash relative to the spec's `H(tailId ‖ "reactivity")`
16
+ * and (b) force the synchronous origination gate to become async.
17
+ *
18
+ * It lives in the reactivity surface (not buried in the gate) so the subscribe path and the gate import the
19
+ * exact same function rather than re-deriving the encoding independently. The
20
+ * `cohort-topic/reactivity-membership-gate.ts` module re-exports it for its existing callers.
21
+ */
22
+
23
+ import type { BlockId } from "@optimystic/db-core";
24
+
25
+ const utf8 = new TextEncoder();
26
+
27
+ /**
28
+ * The pinned `BlockId` → raw tail bytes encoding fed into `reactivityTopicId`: the **raw** utf8 bytes of the
29
+ * tail block-id string (NOT a pre-hashed routing key, NOT db-core's double-hashing `blockIdToBytes`).
30
+ *
31
+ * Load-bearing: origination and the subscriber side must call this **same** function for a given tail (see
32
+ * the module doc) — a mismatch resolves different `coord_0`s and origination silently never reaches
33
+ * subscribers. The `topic-bytes-encoding` spec pins the coord-equality both ways.
34
+ */
35
+ export function reactivityTailBytes(tailId: BlockId): Uint8Array {
36
+ return utf8.encode(tailId);
37
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  import type { RepoMessage } from "@optimystic/db-core";
6
6
  import { blockIdsForTransforms, blockIdToBytes } from "@optimystic/db-core";
7
7
  import { ProtocolClient } from "../protocol-client.js";
8
+ import type { RedirectPayload } from "./redirect.js";
8
9
  import { MAX_BLOCK_MESSAGE_BYTES } from "../protocol-limits.js";
9
10
  import { peerIdFromString } from "@libp2p/peer-id";
10
11
 
@@ -102,16 +103,25 @@ export class RepoClient extends ProtocolClient implements IRepo {
102
103
  clearTimeout(timer)
103
104
  }
104
105
 
105
- if (response?.redirect?.peers?.length) {
106
+ // Type the redirect branch against the payload the service actually produces
107
+ // (`RedirectPayload`) rather than reading it off the `any` response — the untyped read
108
+ // is what let the `addrs` the sender embedded fall on the floor.
109
+ const redirectResponse = response as Partial<RedirectPayload>
110
+ if (redirectResponse?.redirect?.peers?.length) {
106
111
  if (hop >= 2) {
107
112
  throw new Error('Redirect loop detected in RepoClient (max hops reached)')
108
113
  }
109
114
  const currentIdStr = this.peerId.toString()
110
- const next = response.redirect.peers.find((p: any) => p.id !== currentIdStr) ?? response.redirect.peers[0]
115
+ const peers = redirectResponse.redirect.peers
116
+ const next = peers.find((p) => p.id !== currentIdStr) ?? peers[0]!
111
117
  const nextId = peerIdFromString(next.id)
112
118
  if (next.id === currentIdStr) {
113
119
  throw new Error('Redirect loop detected in RepoClient (same peer)')
114
120
  }
121
+ // Learn the redirect target's addresses BEFORE dialing it: the redirect is the only
122
+ // notice we get that this peer matters, and if it is relay-only we have no address
123
+ // for it at all. Merging after the dial would help only some later hop.
124
+ this.peerNetwork.recordPeerAddresses?.(nextId, next.addrs ?? [])
115
125
  // cache hint — await so the recorded key bytes match what findCoordinator
116
126
  // later looks up (blockIdToBytes is async); deterministic ordering also
117
127
  // keeps the hint testable. The redirect retry below is async anyway.
@@ -32,6 +32,27 @@ export class ValidatorRejectionError extends Error {
32
32
  }
33
33
  }
34
34
 
35
+ /**
36
+ * The transaction lost a conflict race: one or more members answered with a signed `conflict`
37
+ * vote (they hold a rival transaction that won the deterministic race on the same blocks) and
38
+ * approvals fell short of super-majority. Distinct from {@link ValidatorRejectionError} — nobody
39
+ * judged this write invalid; it lost an optimistic-concurrency race and a fresh retry can win.
40
+ * `CoordinatorRepo.pend` converts this into a `StaleFailure` with `conflict: true` so the normal
41
+ * retry machinery (`isConflictFailure`) absorbs it; it should escape as a thrown error only from
42
+ * paths other than pend. The conflicting peers and the winning hashes ride as structured data
43
+ * (from the signed `conflictWith` fields), never parsed out of prose.
44
+ */
45
+ export class ConflictRaceLostError extends Error {
46
+ constructor(
47
+ message: string,
48
+ /** peerId → messageHash of the rival transaction that member holds as the race winner. */
49
+ readonly conflicts: Record<string, string>
50
+ ) {
51
+ super(message);
52
+ this.name = 'ConflictRaceLostError';
53
+ }
54
+ }
55
+
35
56
  /** Cancel handle for an injected timer; cancels a not-yet-fired timer (safe no-op after fire/cancel). */
36
57
  export type TimerCancel = () => void;
37
58
 
@@ -333,18 +354,21 @@ export class ClusterCoordinator {
333
354
  const promised = await this.collectPromises(peers, record);
334
355
  const superMajority = Math.ceil(peerCount * this.cfg.superMajorityThreshold);
335
356
 
336
- // Count approvals and rejections separately
357
+ // Count approvals, rejections and conflict votes separately. A `conflict` vote is a member
358
+ // saying "not now — I hold the race winner": it must count toward NEITHER approvals NOR
359
+ // rejections, or a lost race would masquerade as a validator rejection (permanent) or as
360
+ // silence (indistinguishable from an unreachable cohort) — both wrong.
337
361
  const promises = promised.record.promises;
338
362
  const approvalCount = Object.values(promises).filter(sig => sig.type === 'approve').length;
339
363
  const rejectionCount = Object.values(promises).filter(sig => sig.type === 'reject').length;
364
+ const conflictCount = Object.values(promises).filter(sig => sig.type === 'conflict').length;
340
365
 
341
366
  // Check if rejections make super-majority impossible
342
367
  // If more than (peerCount - superMajority) nodes reject, we can never reach super-majority
343
368
  const maxAllowedRejections = peerCount - superMajority;
344
369
  if (rejectionCount > maxAllowedRejections) {
345
370
  const rejectReasonsByPeer = Object.fromEntries(Object.entries(promises)
346
- .filter(([_, sig]) => sig.type === 'reject')
347
- .map(([peerId, sig]) => [peerId, sig.rejectReason ?? 'unknown']));
371
+ .flatMap(([peerId, sig]) => sig.type === 'reject' ? [[peerId, sig.rejectReason ?? 'unknown'] as const] : []));
348
372
  const rejectReasons = Object.entries(rejectReasonsByPeer)
349
373
  .map(([peerId, reason]) => `${peerId}: ${reason}`)
350
374
  .join('; ');
@@ -356,11 +380,46 @@ export class ClusterCoordinator {
356
380
  reasons: rejectReasons
357
381
  });
358
382
  this.updateTransactionRecord(promised.record, 'rejected-by-validators');
383
+ // Abandoning here without telling anyone leaves every member that voted holding this
384
+ // transaction in its own reservation table, blocking its blocks until that member's
385
+ // staleness sweep fires — and each retry we throw back to the caller plants a fresh
386
+ // reservation, so the block never frees. The merged record carries enough signed
387
+ // rejections to *prove* the transaction is dead, so replaying it to the cohort makes
388
+ // every member recompute `Rejected` and clear immediately. Proof-carrying, so a member
389
+ // need not trust us: it verifies the signatures it is shown.
390
+ this.broadcastAbandonment(promised.record, 'rejected-by-validators');
359
391
  throw new ValidatorRejectionError(
360
392
  `Transaction rejected by validators (${rejectionCount}/${peerCount} rejected): ${rejectReasons}`,
361
393
  rejectReasonsByPeer);
362
394
  }
363
395
 
396
+ // A conflict-answered shortfall is a LOST RACE, not a validator verdict and not silence.
397
+ // Checked after the rejection threshold (a genuine validator rejection still wins) and
398
+ // before the generic shortfall (which must stay reserved for the genuinely-silent cohort).
399
+ if (conflictCount > 0 && approvalCount < superMajority) {
400
+ const conflicts = Object.fromEntries(Object.entries(promises)
401
+ .flatMap(([peerId, sig]) => sig.type === 'conflict' ? [[peerId, sig.conflictWith] as const] : []));
402
+ log('cluster-tx:conflict-race-lost', {
403
+ messageHash: record.messageHash,
404
+ peerCount,
405
+ approvals: approvalCount,
406
+ rejections: rejectionCount,
407
+ conflicts,
408
+ superMajority
409
+ });
410
+ this.updateTransactionRecord(promised.record, 'conflict-race-lost');
411
+ // Broadcast only when the merged record itself PROVES the transaction can no longer reach
412
+ // super-majority (members re-derive ConflictSuperseded/Rejected from the signed votes and
413
+ // clear their reservations immediately). Below that bar the record proves nothing and a
414
+ // broadcast would be the unauthenticated "forget this" the shortfall NOTE below refuses.
415
+ if (rejectionCount + conflictCount > maxAllowedRejections) {
416
+ this.broadcastAbandonment(promised.record, 'conflict-race-lost');
417
+ }
418
+ throw new ConflictRaceLostError(
419
+ `Conflict race lost: ${conflictCount}/${peerCount} member(s) hold a conflicting winner (${approvalCount}/${superMajority} approvals)`,
420
+ conflicts);
421
+ }
422
+
364
423
  if (peerCount > 1 && approvalCount < superMajority) {
365
424
  log('cluster-tx:supermajority-failed', {
366
425
  messageHash: record.messageHash,
@@ -371,6 +430,15 @@ export class ClusterCoordinator {
371
430
  threshold: this.cfg.superMajorityThreshold
372
431
  });
373
432
  this.updateTransactionRecord(promised.record, 'supermajority-failed');
433
+ // NOTE: deliberately NOT broadcast, unlike the rejected-by-validators branch above. With
434
+ // conflict-answered shortfalls peeled off above, we get here only because peers did not
435
+ // answer at all, so the record carries no signed evidence that the transaction is dead — a
436
+ // broadcast would be an unauthenticated "forget this" that any caller could use to clear a
437
+ // live transaction out of a member's reservation table. Members that DID vote are freed by
438
+ // their own staleness sweep instead.
439
+ // NOTE: the message below is load-bearing wire text — the consuming repo
440
+ // (sereus cadre-core control-write-retry) matches it verbatim to retry a genuinely-silent
441
+ // cohort. Keep it byte-identical, and never fold conflict votes into its rejection count.
374
442
  throw new Error(`Failed to get super-majority: ${approvalCount}/${peerCount} approvals (needed ${superMajority}, ${rejectionCount} rejections)`);
375
443
  }
376
444
 
@@ -696,6 +764,34 @@ export class ClusterCoordinator {
696
764
  return { failures };
697
765
  }
698
766
 
767
+ /**
768
+ * Fire-and-forget replay of an abandoned transaction's record to every peer in its cohort.
769
+ *
770
+ * Called only where the record itself proves the transaction is dead (enough signed rejections that
771
+ * super-majority is unreachable). Each member re-derives `TransactionPhase.Rejected` from the votes
772
+ * it verifies and drops the entry from its own reservation table, freeing the blocks immediately
773
+ * instead of after its 2 s staleness window. No new message type and no wire-format change — this is
774
+ * the same `update()` every other phase uses.
775
+ *
776
+ * Never awaited into the caller's throw and never rethrows: an abandonment must not turn into a
777
+ * *different* failure, and the staleness sweep remains the backstop if delivery fails.
778
+ */
779
+ private broadcastAbandonment(record: ClusterRecord, reason: string): void {
780
+ const peerIds = Object.keys(record.peers);
781
+ log('cluster-tx:abandon-broadcast', { messageHash: record.messageHash, reason, peerIds });
782
+ void Promise.all(peerIds.map(async peerIdStr => {
783
+ try {
784
+ await this.updateMember(peerIdStr, record, 0, 'abandon-broadcast');
785
+ } catch (err) {
786
+ log('cluster-tx:abandon-broadcast-error', {
787
+ messageHash: record.messageHash,
788
+ peerId: peerIdStr,
789
+ error: err instanceof Error ? err.message : String(err)
790
+ });
791
+ }
792
+ }));
793
+ }
794
+
699
795
  private updateTransactionRecord(record: ClusterRecord, stage: string): void {
700
796
  const state = this.transactions.get(record.messageHash);
701
797
  if (!state) {