@optimystic/db-p2p 0.21.0 → 0.24.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 (219) 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-policy.d.ts +13 -2
  6. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-policy.js +51 -4
  8. package/dist/src/cluster/cluster-policy.js.map +1 -1
  9. package/dist/src/cluster/cluster-repo.d.ts +42 -17
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +229 -122
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/cluster-size-coupling.d.ts +28 -0
  14. package/dist/src/cluster/cluster-size-coupling.d.ts.map +1 -0
  15. package/dist/src/cluster/cluster-size-coupling.js +35 -0
  16. package/dist/src/cluster/cluster-size-coupling.js.map +1 -0
  17. package/dist/src/cluster/quorum-restore.d.ts +6 -0
  18. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  19. package/dist/src/cluster/quorum-restore.js +1 -1
  20. package/dist/src/cluster/quorum-restore.js.map +1 -1
  21. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  22. package/dist/src/cluster/reconcile-block.js +15 -3
  23. package/dist/src/cluster/reconcile-block.js.map +1 -1
  24. package/dist/src/cluster/service.d.ts +32 -1
  25. package/dist/src/cluster/service.d.ts.map +1 -1
  26. package/dist/src/cluster/service.js +43 -2
  27. package/dist/src/cluster/service.js.map +1 -1
  28. package/dist/src/cohort-topic/stream-util.d.ts +22 -6
  29. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  30. package/dist/src/cohort-topic/stream-util.js +56 -10
  31. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  32. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  33. package/dist/src/dispute/dispute-service.js +9 -3
  34. package/dist/src/dispute/dispute-service.js.map +1 -1
  35. package/dist/src/index.d.ts +5 -0
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +5 -0
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/libp2p-key-network.d.ts +134 -7
  40. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  41. package/dist/src/libp2p-key-network.js +174 -37
  42. package/dist/src/libp2p-key-network.js.map +1 -1
  43. package/dist/src/libp2p-node-base.d.ts +3 -2
  44. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  45. package/dist/src/libp2p-node-base.js +859 -778
  46. package/dist/src/libp2p-node-base.js.map +1 -1
  47. package/dist/src/libp2p-node-rn.d.ts +2 -2
  48. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  49. package/dist/src/libp2p-node-rn.js.map +1 -1
  50. package/dist/src/libp2p-node.d.ts +2 -2
  51. package/dist/src/libp2p-node.d.ts.map +1 -1
  52. package/dist/src/libp2p-node.js.map +1 -1
  53. package/dist/src/logger.d.ts +17 -1
  54. package/dist/src/logger.d.ts.map +1 -1
  55. package/dist/src/logger.js +19 -2
  56. package/dist/src/logger.js.map +1 -1
  57. package/dist/src/network/network-manager-service.d.ts +2 -0
  58. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  59. package/dist/src/network/network-manager-service.js +4 -0
  60. package/dist/src/network/network-manager-service.js.map +1 -1
  61. package/dist/src/optimystic-node.d.ts +35 -0
  62. package/dist/src/optimystic-node.d.ts.map +1 -0
  63. package/dist/src/optimystic-node.js +2 -0
  64. package/dist/src/optimystic-node.js.map +1 -0
  65. package/dist/src/owned-block-seed.d.ts +6 -3
  66. package/dist/src/owned-block-seed.d.ts.map +1 -1
  67. package/dist/src/owned-block-seed.js +16 -3
  68. package/dist/src/owned-block-seed.js.map +1 -1
  69. package/dist/src/peer-address-book.d.ts +72 -0
  70. package/dist/src/peer-address-book.d.ts.map +1 -0
  71. package/dist/src/peer-address-book.js +123 -0
  72. package/dist/src/peer-address-book.js.map +1 -0
  73. package/dist/src/repo/client.d.ts.map +1 -1
  74. package/dist/src/repo/client.js +11 -2
  75. package/dist/src/repo/client.js.map +1 -1
  76. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  77. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  78. package/dist/src/repo/cluster-coordinator.js +95 -3
  79. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  80. package/dist/src/repo/coordinator-repo.d.ts +78 -14
  81. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  82. package/dist/src/repo/coordinator-repo.js +266 -81
  83. package/dist/src/repo/coordinator-repo.js.map +1 -1
  84. package/dist/src/rn.d.ts +5 -0
  85. package/dist/src/rn.d.ts.map +1 -1
  86. package/dist/src/rn.js +5 -0
  87. package/dist/src/rn.js.map +1 -1
  88. package/dist/src/storage/block-storage.d.ts.map +1 -1
  89. package/dist/src/storage/block-storage.js +57 -5
  90. package/dist/src/storage/block-storage.js.map +1 -1
  91. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  92. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  93. package/dist/src/storage/cached-raw-storage.js +152 -0
  94. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  95. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  96. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  97. package/dist/src/storage/cached-store-driver.js +775 -0
  98. package/dist/src/storage/cached-store-driver.js.map +1 -0
  99. package/dist/src/storage/i-block-storage.d.ts +20 -1
  100. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  101. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  102. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  103. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  104. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  105. package/dist/src/storage/shared-cache-pool.js +354 -0
  106. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  107. package/dist/src/storage/storage-repo.d.ts +56 -3
  108. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  109. package/dist/src/storage/storage-repo.js +124 -18
  110. package/dist/src/storage/storage-repo.js.map +1 -1
  111. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  112. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  113. package/dist/src/testing/raw-storage-conformance.js +52 -2
  114. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  115. package/package.json +3 -3
  116. package/readme.md +668 -653
  117. package/src/cluster/block-transfer.ts +424 -424
  118. package/src/cluster/client.ts +119 -88
  119. package/src/cluster/cluster-error.ts +64 -64
  120. package/src/cluster/cluster-policy.ts +203 -152
  121. package/src/cluster/cluster-repo.ts +245 -125
  122. package/src/cluster/cluster-size-coupling.ts +45 -0
  123. package/src/cluster/commit-cert.ts +139 -139
  124. package/src/cluster/i-transaction-state-store.ts +43 -43
  125. package/src/cluster/memory-transaction-state-store.ts +56 -56
  126. package/src/cluster/peer-key-binding.ts +37 -37
  127. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  128. package/src/cluster/quorum-restore.ts +223 -223
  129. package/src/cluster/reconcile-block.ts +203 -191
  130. package/src/cluster/service.ts +293 -241
  131. package/src/cluster/supermajority-coupling.ts +37 -37
  132. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  133. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  134. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  135. package/src/cohort-topic/change-bridge.ts +109 -109
  136. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  137. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  138. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  139. package/src/cohort-topic/host.ts +2901 -2901
  140. package/src/cohort-topic/index.ts +13 -13
  141. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  142. package/src/cohort-topic/membership-source.ts +68 -68
  143. package/src/cohort-topic/peer-codec.ts +31 -31
  144. package/src/cohort-topic/peer-sig.ts +86 -86
  145. package/src/cohort-topic/protocols.ts +71 -71
  146. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  147. package/src/cohort-topic/size-estimator.ts +16 -16
  148. package/src/cohort-topic/stream-util.ts +135 -87
  149. package/src/cohort-topic/threshold-crypto.ts +239 -239
  150. package/src/cohort-topic/topic-router.ts +77 -77
  151. package/src/dispute/arbitrator-selection.ts +138 -138
  152. package/src/dispute/cascade.ts +524 -524
  153. package/src/dispute/dispute-service.ts +11 -5
  154. package/src/dispute/invalidation.ts +625 -625
  155. package/src/inbound-authorization.ts +190 -190
  156. package/src/index.ts +52 -47
  157. package/src/libp2p-key-network.ts +1120 -958
  158. package/src/libp2p-node-base.ts +1675 -1591
  159. package/src/libp2p-node-rn.ts +30 -30
  160. package/src/libp2p-node.ts +36 -36
  161. package/src/logger.ts +19 -2
  162. package/src/matchmaking/aggregate-counts.ts +104 -104
  163. package/src/matchmaking/index.ts +20 -20
  164. package/src/matchmaking/module.ts +363 -363
  165. package/src/matchmaking/protocols.ts +51 -51
  166. package/src/matchmaking/provider-manager.ts +95 -95
  167. package/src/matchmaking/query-handler.ts +88 -88
  168. package/src/matchmaking/query-transport.ts +492 -492
  169. package/src/matchmaking/seeker-manager.ts +64 -64
  170. package/src/matchmaking/seeker-walk-client.ts +293 -293
  171. package/src/matchmaking/traffic-validation.ts +195 -195
  172. package/src/network/network-manager-service.ts +5 -0
  173. package/src/optimystic-node.ts +36 -0
  174. package/src/owned-block-seed.ts +53 -40
  175. package/src/peer-address-book.ts +149 -0
  176. package/src/protocol-limits.ts +33 -33
  177. package/src/reactivity/forwarder-host.ts +438 -438
  178. package/src/reactivity/index.ts +19 -19
  179. package/src/reactivity/notify-transport.ts +144 -144
  180. package/src/reactivity/origination-manager.ts +192 -192
  181. package/src/reactivity/protocols.ts +61 -61
  182. package/src/reactivity/push-state-gossip.ts +291 -291
  183. package/src/reactivity/recover-transport.ts +408 -408
  184. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  185. package/src/reactivity/subscriber-registry.ts +96 -96
  186. package/src/reactivity/subscription-manager.ts +450 -450
  187. package/src/reactivity/topic-bytes.ts +37 -37
  188. package/src/repo/client.ts +12 -2
  189. package/src/repo/cluster-coordinator.ts +99 -3
  190. package/src/repo/coordinator-repo.ts +305 -82
  191. package/src/repo/types.ts +7 -7
  192. package/src/rn.ts +39 -34
  193. package/src/rpc-deadline.ts +45 -45
  194. package/src/storage/arachnode-partition.ts +74 -74
  195. package/src/storage/block-storage.ts +59 -6
  196. package/src/storage/cached-raw-storage.ts +180 -0
  197. package/src/storage/cached-store-driver.ts +859 -0
  198. package/src/storage/i-block-storage.ts +20 -1
  199. package/src/storage/i-kv-store.ts +8 -8
  200. package/src/storage/i-raw-storage.ts +12 -5
  201. package/src/storage/kv-raw-storage.ts +135 -135
  202. package/src/storage/memory-kv-store.ts +28 -28
  203. package/src/storage/memory-storage.ts +25 -25
  204. package/src/storage/memory-store-driver.ts +157 -157
  205. package/src/storage/raw-store-codec.ts +42 -42
  206. package/src/storage/raw-store-driver.ts +80 -80
  207. package/src/storage/ring-selector.ts +317 -317
  208. package/src/storage/ring-shift-coordinator.ts +271 -271
  209. package/src/storage/shared-cache-pool.ts +452 -0
  210. package/src/storage/storage-repo.ts +1014 -903
  211. package/src/testing/cohort-topic-mesh-harness.ts +663 -663
  212. package/src/testing/index.ts +8 -8
  213. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  214. package/src/testing/raw-storage-conformance.ts +453 -397
  215. package/src/testing/reactivity-mesh-harness.ts +922 -922
  216. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  217. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  218. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  219. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,6 +1,6 @@
1
- import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, StaleFailure, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext, ClusterRecord } from "@optimystic/db-core";
1
+ import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, StaleFailure, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext, ClusterRecord, BlockUnavailableReason } from "@optimystic/db-core";
2
2
  import { LruMap, blockIdsForTransforms, highestStaleAt, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
3
- import { ClusterCoordinator, ValidatorRejectionError } from "./cluster-coordinator.js";
3
+ import { ClusterCoordinator, ConflictRaceLostError, ValidatorRejectionError } from "./cluster-coordinator.js";
4
4
  import type { PeerId } from "@libp2p/interface";
5
5
  import { peerIdFromString } from "@libp2p/peer-id";
6
6
  import type { FretService } from "p2p-fret";
@@ -9,12 +9,11 @@ import type { IPeerReputation } from "../reputation/types.js";
9
9
  import { PenaltyReason } from "../reputation/types.js";
10
10
  import type { ITransactionStateStore } from "../cluster/i-transaction-state-store.js";
11
11
  import { quorumSize, corroboratorCapacity, selectQuorumRev, type RevClaim, type QuorumRev } from "../cluster/quorum-restore.js";
12
+ import { DEFAULT_CLUSTER_SIZE } from "../cluster/cluster-policy.js";
12
13
  import { RECONCILE_TIMEOUT_MS } from "../cluster/reconcile-block.js";
13
14
  import { isMissingBaseRevisionFailure, MISSING_BASE_REVISION_REASON } from "../storage/storage-repo.js";
14
15
  import type { ReconcileBlockCallback } from "../cluster/cluster-repo.js";
15
16
 
16
- const log = createLogger('coordinator-repo');
17
-
18
17
  /**
19
18
  * Acquire a block's content for a cohort-corroborated revision, from the cohort, and persist it.
20
19
  *
@@ -69,8 +68,48 @@ interface ClusterLatestQuery {
69
68
  * sole holder.
70
69
  */
71
70
  silent: string[];
71
+ /**
72
+ * Highest revision any cohort peer CLAIMED when no claim met the corroboration quorum
73
+ * (set only alongside an absent `corroborated`). The claim failed quorum, so it must
74
+ * never drive restoration — it exists so `get` can report content it serves below this
75
+ * revision as possibly behind ({@link GetBlockResult.unconfirmedAheadRev}) instead of
76
+ * confirmed. When a quorum DOES corroborate, higher uncorroborated claims are dropped
77
+ * as before: the quorum's affirmative answer outweighs a lone voter (which may simply
78
+ * be ahead on an in-flight commit), and stamping doubt there would mark every read that
79
+ * races a commit broadcast.
80
+ */
81
+ uncorroboratedRev?: number;
82
+ /**
83
+ * How many cohort peers OTHER than this node answered the consult at all — with a claim
84
+ * or with "I hold nothing". `silent` says who could not be asked; this says how many
85
+ * could. Zero with a non-empty `silent` means this node reached NOBODY, which is a
86
+ * different fact from partial silence: there is no better-informed answer to be had from
87
+ * this node's position (see {@link AbsenceVerdict}).
88
+ */
89
+ answered: number;
72
90
  }
73
91
 
92
+ /**
93
+ * What one repair pass established about a block that is still MISSING locally after it.
94
+ * Ordered by how firmly the block is ruled out; `get` consults it only on the missing path.
95
+ */
96
+ type AbsenceVerdict =
97
+ /** Nobody to ask (empty cohort, or solo-self), or every non-self cohort member answered
98
+ * "I hold nothing". As confirmed as an absence gets — stays authoritative, which is what
99
+ * keeps the routine new-collection probe at one round trip. */
100
+ | 'confirmed'
101
+ /** Some of the cohort answered and some could not be asked. (A consult that THROWS produces
102
+ * no verdict at all — `get`'s catch arm reports it directly.) */
103
+ | 'unconfirmed'
104
+ /** No cohort member outside this node could be asked at all. Mutually exclusive with
105
+ * `claimed` in practice: a claim requires a non-self peer to have answered, which is
106
+ * exactly what this verdict rules out. The precedence below still orders the pair, so
107
+ * the mapping stays total, but there is no reachable case to test. */
108
+ | 'isolated'
109
+ /** A peer claimed a revision this pass did not converge onto — quorum declined it, or a
110
+ * quorum corroborated it and acquisition failed. */
111
+ | 'claimed';
112
+
74
113
  /**
75
114
  * Extended cluster interface that includes the ability to check if a transaction was executed.
76
115
  * This is used by CoordinatorRepo to avoid duplicate execution.
@@ -156,6 +195,13 @@ export class CoordinatorRepo implements IRepo {
156
195
  private readonly responsibilityCache = new LruMap<string, { inCluster: boolean, expires: number }>(1000);
157
196
  private static readonly RESPONSIBILITY_TTL_MS = 60_000;
158
197
  private readonly lastSeenCommitMs = new LruMap<string, number>(1000);
198
+ /** Per block, the cohort-claimed revision the last freshness consult could not settle — the
199
+ * doubt {@link flagUnconfirmedCurrency} stamps onto reads served below it. Outlives the
200
+ * consult on purpose: the read-repair window skips consults for blocks checked recently, and
201
+ * a doubt dropped there is a stale answer served as confirmed again.
202
+ * NOTE: LRU-bounded like `lastSeenCommitMs`; an eviction under >1000 doubted blocks loses the
203
+ * doubt until the next consult re-derives it (one read-repair window later, at worst). */
204
+ private readonly unsettledAheadClaims = new LruMap<string, number>(1000);
159
205
  private readonly readRepairMode: 'off' | 'lazy' | 'paranoid';
160
206
  private readonly readRepairWindowMs: number;
161
207
  private readonly readRepairSampleRate: number;
@@ -171,6 +217,8 @@ export class CoordinatorRepo implements IRepo {
171
217
  /** Resolved super-majority threshold the coordinator commits on (mirrors the value handed to ClusterCoordinator). */
172
218
  private readonly superMajorityThreshold: number;
173
219
  private readonly reputation?: IPeerReputation;
220
+ /** Per-instance logger, namespaced by peer id when `localPeerId` is known (degrades to the un-suffixed namespace when not — the single-node/test construction has always tolerated its absence). */
221
+ private readonly log: ReturnType<typeof createLogger>;
174
222
  /** Test seam: overridable clock for window-based read-repair gating. */
175
223
  now: () => number = () => Date.now();
176
224
  /** Test seam: overridable RNG (0..1) for sample-rate gating. */
@@ -190,8 +238,12 @@ export class CoordinatorRepo implements IRepo {
190
238
  private readonly acquireBlockFromCohort?: AcquireBlockCallback
191
239
  ) {
192
240
  this.localPeerId = localPeerId;
241
+ this.log = createLogger('coordinator-repo', localPeerId?.toString());
193
242
  const policy: ClusterConsensusConfig & { clusterSize: number } = {
194
- clusterSize: cfg?.clusterSize ?? 10,
243
+ // Same constant `resolveClusterPolicy` gives a node that declares no clusterSize, not a
244
+ // second literal: a direct constructor (the readme's manual-wiring path) and the node
245
+ // assembly must land on the same width or the two disagree about the same key's cohort.
246
+ clusterSize: cfg?.clusterSize ?? DEFAULT_CLUSTER_SIZE,
195
247
  assumedClusterSize: cfg?.assumedClusterSize,
196
248
  superMajorityThreshold: cfg?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
197
249
  simpleMajorityThreshold: cfg?.simpleMajorityThreshold ?? 0.51,
@@ -270,13 +322,13 @@ export class CoordinatorRepo implements IRepo {
270
322
  const peers = await this.keyNetwork.findCluster(blockIdBytes);
271
323
  inCluster = this.localPeerId.toString() in peers;
272
324
  } catch (err) {
273
- log('proximity:check-error', { blockId, error: (err as Error).message });
325
+ this.log('proximity:check-error', { blockId, error: (err as Error).message });
274
326
  // On failure, assume responsible to avoid false rejections
275
327
  return true;
276
328
  }
277
329
 
278
330
  this.responsibilityCache.set(blockId, { inCluster, expires: Date.now() + CoordinatorRepo.RESPONSIBILITY_TTL_MS });
279
- log('proximity:checked', { blockId, inCluster });
331
+ this.log('proximity:checked', { blockId, inCluster });
280
332
  return inCluster;
281
333
  }
282
334
 
@@ -291,7 +343,7 @@ export class CoordinatorRepo implements IRepo {
291
343
  }
292
344
  }
293
345
  if (notResponsible.length > 0) {
294
- log('proximity:rejected', { blockIds: notResponsible });
346
+ this.log('proximity:rejected', { blockIds: notResponsible });
295
347
  throw new Error(`Not responsible for block(s): ${notResponsible.join(', ')}`);
296
348
  }
297
349
  }
@@ -307,7 +359,7 @@ export class CoordinatorRepo implements IRepo {
307
359
  // isResponsibleForBlock.
308
360
  for (const blockId of blockGets.blockIds) {
309
361
  if (!await this.isResponsibleForBlock(blockId)) {
310
- log('proximity:get-warning', { blockId, msg: 'serving read for non-responsible block' });
362
+ this.log('proximity:get-warning', { blockId, msg: 'serving read for non-responsible block' });
311
363
  }
312
364
  }
313
365
 
@@ -326,8 +378,9 @@ export class CoordinatorRepo implements IRepo {
326
378
  // NOTE: NetworkTransactor.get treats an authoritative "absent" ({ state: {} })
327
379
  // as final and no longer retries it (ticket txn-perf-authoritative-notfound),
328
380
  // relying on this cluster reconciliation to have already run. When the consult
329
- // FAILS outright — or runs while part of the cohort stays SILENT and the block
330
- // stays missing — the entry is flagged `unavailable: 'peers-unreachable'` below,
381
+ // FAILS outright — or runs without ruling the block out and the block stays
382
+ // missing — the entry is flagged `unavailable` below with a reason naming what
383
+ // the consult established (see AbsenceVerdict and the mapping in the loop body),
331
384
  // which re-enables the transactor-level retry against a different peer. If a
332
385
  // coordinator is configured WITHOUT clusterLatestCallback, there is no cohort to
333
386
  // consult and the local answer IS the whole truth — it stays authoritative, with
@@ -340,10 +393,19 @@ export class CoordinatorRepo implements IRepo {
340
393
  const localRev = localEntry?.state?.latest?.rev;
341
394
  const isMissing = !localEntry?.state?.latest;
342
395
  const isStale = !isMissing && this.shouldReadRepair(blockId);
343
- if (!isMissing && !isStale) continue;
396
+ if (!isMissing && !isStale) {
397
+ // No consult this pass — the read-repair window says this block was checked
398
+ // recently. An unsettled claim an earlier pass recorded still applies: the doubt
399
+ // is a property of what this node HOLDS, not of whether a consult just ran.
400
+ // Without this, every read inside the window after a failed convergence would
401
+ // serve the same content as confirmed — the exact silent lie this marker exists
402
+ // to end, re-opened for `readRepairWindowMs` at a time.
403
+ this.flagUnconfirmedCurrency(localResult, blockId, blockGets.context);
404
+ continue;
405
+ }
344
406
 
345
407
  if (isStale) {
346
- log('cluster-tx:read-repair-triggered', {
408
+ this.log('cluster-tx:read-repair-triggered', {
347
409
  blockId,
348
410
  mode: this.readRepairMode,
349
411
  ageMs: this.ageMs(blockId),
@@ -352,7 +414,7 @@ export class CoordinatorRepo implements IRepo {
352
414
  }
353
415
 
354
416
  try {
355
- const { inconclusive } = await this.fetchBlockFromCluster(blockId, blockGets.context, localRev);
417
+ const { absence, claimedAheadRev } = await this.fetchBlockFromCluster(blockId, blockGets.context, localRev);
356
418
  const refreshed = await this.storageRepo.get({ blockIds: [blockId], context: blockGets.context }, options);
357
419
  const newRev = refreshed[blockId]?.state?.latest?.rev;
358
420
  if (refreshed[blockId]) {
@@ -360,25 +422,54 @@ export class CoordinatorRepo implements IRepo {
360
422
  }
361
423
  if (isStale) {
362
424
  if (typeof newRev === 'number' && typeof localRev === 'number' && newRev > localRev) {
363
- log('cluster-tx:read-repair-applied', { blockId, oldRev: localRev, newRev });
425
+ this.log('cluster-tx:read-repair-applied', { blockId, oldRev: localRev, newRev });
364
426
  } else {
365
- log('cluster-tx:read-repair-noop', { blockId });
427
+ this.log('cluster-tx:read-repair-noop', { blockId });
366
428
  }
367
429
  }
368
- // The consult ran but came back INCONCLUSIVE (a silent cohort peer, or a
369
- // corroborated revision this node could not acquire see
370
- // fetchBlockFromCluster). Either way the reader cannot rule the block out,
371
- // so a still-missing block must not pose as an authoritative absent. When
372
- // the whole cohort answers "holds nothing" the absent stays authoritative
373
- // the new-collection probe against a healthy cohort stays one round-trip.
374
- if (isMissing && inconclusive) {
375
- this.flagUnconfirmedAbsence(localResult, blockId);
430
+ // The consult ran but could not rule the block out, and the verdict names the
431
+ // evidence (see AbsenceVerdict): part of the cohort was silent (`unconfirmed`
432
+ // 'peers-unreachable' another coordinator may know better), no cohort
433
+ // member outside this node could be asked at all (`isolated`
434
+ // 'cohort-unreachable' there is no better-connected coordinator to re-ask),
435
+ // or a peer positively claimed a revision this pass could neither corroborate
436
+ // nor acquire (`claimed` 'claimed-elsewhere' — the block is known to exist
437
+ // somewhere). Either way a still-missing block must not pose as an
438
+ // authoritative absent. When the whole cohort answers "holds nothing" the
439
+ // absent stays authoritative (`confirmed`) — the new-collection probe against
440
+ // a healthy cohort stays one round-trip.
441
+ if (isMissing && absence !== 'confirmed') {
442
+ this.flagUnconfirmedAbsence(localResult, blockId,
443
+ absence === 'claimed' ? 'claimed-elsewhere'
444
+ : absence === 'isolated' ? 'cohort-unreachable'
445
+ : 'peers-unreachable');
446
+ }
447
+ // A PRESENT block served below a cohort claim the repair could not settle is
448
+ // the mirror lie: real content posing as confirmed-current. This consult is the
449
+ // authority on that claim, so it replaces whatever an earlier one recorded —
450
+ // including clearing it when nobody claims anything any more. The missing case
451
+ // is excluded — it is the absence path above, and a bare absent below a claim
452
+ // already reads as either authoritative (cohort answered, nothing corroborated)
453
+ // or flagged.
454
+ if (!isMissing) {
455
+ this.recordAheadClaim(blockId, claimedAheadRev);
456
+ this.flagUnconfirmedCurrency(localResult, blockId, blockGets.context);
376
457
  }
377
458
  } catch (err) {
378
- log('cluster-fetch:error', { blockId, error: (err as Error).message });
459
+ this.log('cluster-fetch:error', { blockId, error: (err as Error).message });
379
460
  // The consult that was supposed to make this answer trustworthy did not run.
461
+ // NOTE: a consult that THROWS (e.g. `findCluster` itself rejected) is reported
462
+ // 'peers-unreachable' even on an isolated node: a failed cohort lookup is a
463
+ // routing failure and says nothing about how many cohort members were
464
+ // reachable. If `findCluster` on an isolated node turns out to throw routinely
465
+ // rather than return a stale cohort view, revisit — that would put the
466
+ // isolated case back under this vaguer reason.
380
467
  if (isMissing) {
381
- this.flagUnconfirmedAbsence(localResult, blockId);
468
+ this.flagUnconfirmedAbsence(localResult, blockId, 'peers-unreachable');
469
+ } else {
470
+ // It told us nothing, so it refutes nothing: an earlier pass's unsettled
471
+ // claim stands.
472
+ this.flagUnconfirmedCurrency(localResult, blockId, blockGets.context);
382
473
  }
383
474
  }
384
475
  }
@@ -388,21 +479,88 @@ export class CoordinatorRepo implements IRepo {
388
479
  }
389
480
 
390
481
  /**
391
- * Downgrade an absence the coordinator could not confirm to `unavailable: 'peers-unreachable'` —
392
- * the flag `NetworkTransactor.get` retries against another peer instead of taking as final.
482
+ * Downgrade an absence the coordinator could not confirm to the given `unavailable` reason
483
+ * a flag `NetworkTransactor.get` retries against another peer instead of taking as final.
484
+ * The reason names the evidence (see {@link AbsenceVerdict} for the mapping in `get`); this
485
+ * method only decides WHETHER the entry may carry a flag at all.
393
486
  *
394
487
  * No-op once the entry carries a real answer (the consult restored the block) or a sharper flag
395
488
  * (storage's `'unmaterializable'`), so callers only need to establish that the answer is a guess.
489
+ *
490
+ * "Carries a real answer" is tested as `entry.block !== undefined`, NOT as `state.latest` being
491
+ * set. The two used to move together, so `state.latest` read as a serviceable proxy — but a
492
+ * pending-only insert (pended, not yet committed) served through the pending overlay has real
493
+ * CONTENT and no committed revision at all, so its `state.latest` is undefined. Flagging that
494
+ * entry would mark a block this node is positively holding as an unconfirmed absence, and
495
+ * `NetworkTransactor`'s `isAuthoritative` keys off the flag alone — the read would burn its
496
+ * retry budget re-asking other peers for content it already has. `state.latest` stays in the
497
+ * test as well so a stale-but-real committed answer is likewise never downgraded.
396
498
  */
397
- private flagUnconfirmedAbsence(results: GetBlockResults, blockId: BlockId): void {
499
+ private flagUnconfirmedAbsence(results: GetBlockResults, blockId: BlockId, reason: BlockUnavailableReason): void {
398
500
  const entry = results[blockId];
399
501
  if (!entry) {
400
- results[blockId] = { state: {}, unavailable: 'peers-unreachable' };
401
- } else if (!entry.state?.latest && entry.unavailable === undefined) {
402
- entry.unavailable = 'peers-unreachable';
502
+ results[blockId] = { state: {}, unavailable: reason };
503
+ } else if (entry.block === undefined && !entry.state?.latest && entry.unavailable === undefined) {
504
+ entry.unavailable = reason;
403
505
  }
404
506
  }
405
507
 
508
+ /**
509
+ * Remember (or forget) the cohort claim a freshness consult could not settle for a block.
510
+ * Only a consult that actually RAN may call this: it is the authority, so `undefined` clears
511
+ * a claim an earlier pass recorded. Entries are also dropped once this node reaches the
512
+ * claimed revision (see {@link flagUnconfirmedCurrency}), which is what bounds the map.
513
+ */
514
+ private recordAheadClaim(blockId: BlockId, claimedRev: number | undefined): void {
515
+ if (claimedRev === undefined) this.unsettledAheadClaims.delete(blockId);
516
+ else this.unsettledAheadClaims.set(blockId, claimedRev);
517
+ }
518
+
519
+ /**
520
+ * Stamp {@link GetBlockResult.unconfirmedAheadRev} on an entry sitting behind an unsettled
521
+ * cohort claim — served committed content the coordinator cannot confirm is current.
522
+ * Deliberately narrow; ALL of these must hold:
523
+ * - a consult (this read's or an earlier one's, see {@link recordAheadClaim}) left a claim
524
+ * unsettled for this block;
525
+ * - the entry carries a committed revision (a present block, or a committed tombstone) —
526
+ * never a plain absent, which is the absence path's business;
527
+ * - that served revision is still strictly BELOW the claim: the repair did not converge, and
528
+ * nothing committed past the claim in the meantime (if it did, the claim is settled and the
529
+ * memo is dropped here);
530
+ * - the caller asked for a view that should contain the claim: an unpinned "latest" read, or
531
+ * a pin at/above the claimed revision. A read pinned BELOW the claim is being served
532
+ * correctly and stays unstamped — this keeps a collection's context-pinned data reads
533
+ * quiet while its unpinned tail read (the one seam where fresher truth could arrive —
534
+ * Collection.bootstrapContext) speaks up.
535
+ * NOT covered, on purpose: a cohort that is merely silent and claims nothing (pinned as
536
+ * authoritative by the merely-STALE spec in coordinator-repo-unavailable.spec.ts) — silence
537
+ * carries no revision to be behind of.
538
+ *
539
+ * Pin comparability: `ActionContext.rev` and a block's `state.latest.rev` count the same
540
+ * per-collection revision sequence — `Collection.bootstrapContext` seeds the context straight
541
+ * from the tail block's `latest.rev`, and `syncInternal` commits every block of an action at
542
+ * `context.rev + 1` — so `context.rev >= claimedRev` is a well-defined comparison. `state.latest`
543
+ * is this node's newest revision for the block even on a pinned read (StorageRepo reports the
544
+ * content's own revision separately as `materializedRev`), which is exactly the number "is this
545
+ * node behind the claim?" asks about.
546
+ */
547
+ private flagUnconfirmedCurrency(results: GetBlockResults, blockId: BlockId, context?: ActionContext): void {
548
+ const claimedRev = this.unsettledAheadClaims.get(blockId);
549
+ if (claimedRev === undefined) return;
550
+ const entry = results[blockId];
551
+ if (!entry || entry.unavailable !== undefined) return;
552
+ const servedRev = entry.state?.latest?.rev;
553
+ if (typeof servedRev !== 'number') return;
554
+ if (servedRev >= claimedRev) {
555
+ // Caught up — by this pass's repair or by a commit that landed since. Nothing to doubt.
556
+ this.unsettledAheadClaims.delete(blockId);
557
+ return;
558
+ }
559
+ if (context !== undefined && context.rev < claimedRev) return;
560
+ entry.unconfirmedAheadRev = claimedRev;
561
+ this.log('cluster-tx:read-unconfirmed', { blockId, servedRev, claimedAheadRev: claimedRev });
562
+ }
563
+
406
564
  /** Decide whether the read-repair policy wants us to consult the cluster for a present-but-possibly-stale block. */
407
565
  private shouldReadRepair(blockId: BlockId): boolean {
408
566
  switch (this.readRepairMode) {
@@ -446,21 +604,27 @@ export class CoordinatorRepo implements IRepo {
446
604
  * ahead of `localRev` — the revision the caller's read already loaded, and the baseline every
447
605
  * decision below is measured against.
448
606
  *
449
- * Returns the one thing `get` needs beyond the storage side effects: whether the pass was
450
- * INCONCLUSIVEit neither confirmed the cohort holds nothing nor left this node holding the
451
- * block. Two ways that happens: a cohort peer other than this node stayed SILENT (rejected
452
- * callback or per-peer deadline), or a revision WAS corroborated and the convergence onto it
453
- * failed. In both, `get` has learned that its local absence may be wrong, so it must not report
454
- * a still-missing block as an authoritative absent. Paths that consult nobody (no cohort,
455
- * solo-self) are conclusive: there, the local answer genuinely is the whole truth.
607
+ * Returns the two things `get` needs beyond the storage side effects:
608
+ * - `absence`the verdict on this node's local absence of the block (see
609
+ * {@link AbsenceVerdict}): whether the pass may rule the block out, and on what evidence.
610
+ * Only `'confirmed'` lets a still-missing block be reported as an authoritative absent.
611
+ * Paths that consult nobody (no cohort, solo-self) are `'confirmed'`: there, the local
612
+ * answer genuinely is the whole truth. When several verdicts apply at once the sharpest
613
+ * evidence wins: `claimed` > `isolated` > `unconfirmed` > `confirmed` a peer positively
614
+ * saying "it exists" outranks any amount of silence.
615
+ * - `claimedAheadRev` — a cohort peer claimed a revision strictly ahead of what this node
616
+ * holds and the pass did NOT converge onto it: the claim failed the corroboration quorum,
617
+ * or was corroborated but could not be acquired. Content `get` serves below this revision
618
+ * cannot be confirmed current (see {@link GetBlockResult.unconfirmedAheadRev}); the claim
619
+ * itself must never drive restoration.
456
620
  */
457
- private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext, localRev?: number): Promise<{ inconclusive: boolean }> {
458
- if (!this.clusterLatestCallback) return { inconclusive: false };
621
+ private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext, localRev?: number): Promise<{ absence: AbsenceVerdict; claimedAheadRev?: number }> {
622
+ if (!this.clusterLatestCallback) return { absence: 'confirmed' };
459
623
 
460
624
  const blockIdBytes = new TextEncoder().encode(blockId);
461
625
  const peers = await this.keyNetwork.findCluster(blockIdBytes);
462
626
  const peerIds = peers ? Object.keys(peers) : [];
463
- if (peerIds.length === 0) return { inconclusive: false };
627
+ if (peerIds.length === 0) return { absence: 'confirmed' };
464
628
 
465
629
  // Solo-cluster short-circuit: the only responsible peer is us. There is no
466
630
  // remote to sync from, so skip the callback entirely. Querying ourselves
@@ -471,18 +635,33 @@ export class CoordinatorRepo implements IRepo {
471
635
  && this.localPeerId
472
636
  && peerIds[0] === this.localPeerId.toString()
473
637
  ) {
474
- log('cluster-fetch:solo-self-skip', { blockId });
475
- return { inconclusive: false };
638
+ this.log('cluster-fetch:solo-self-skip', { blockId });
639
+ return { absence: 'confirmed' };
476
640
  }
477
641
 
478
- const { corroborated, local, silent } = await this.queryClusterForLatest(peerIds, blockId, context);
479
- // Any silence flags the WHOLE consult, not a fraction of it (fail-closed): one silent
642
+ const { corroborated, local, silent, answered, uncorroboratedRev } = await this.queryClusterForLatest(peerIds, blockId, context);
643
+ // Any silence taints the WHOLE consult, not a fraction of it (fail-closed): one silent
480
644
  // peer could be the sole holder, and the cost — an extra transactor-level retry against
481
- // another coordinator — is paid only while a peer is actually unreachable.
482
- const cohortSilent = silent.length > 0;
645
+ // another coordinator — is paid only while a peer is actually unreachable. Silence with
646
+ // NOBODY else reached at all is its own verdict: partial silence says "ask a better-
647
+ // connected coordinator", total silence says there is no better-informed answer to be
648
+ // had from this node.
649
+ const silenceVerdict: AbsenceVerdict =
650
+ silent.length > 0 ? (answered === 0 ? 'isolated' : 'unconfirmed') : 'confirmed';
483
651
  // Nothing corroborated: keep local data AND stay eligible for repair — marking the
484
652
  // block seen here would suppress the next attempt for the whole read-repair window.
485
- if (!corroborated) return { inconclusive: cohortSilent };
653
+ // An uncorroborated claim strictly ahead of what this node holds still travels up as
654
+ // doubt: the answer about to be served may be behind it, and only the caller knows
655
+ // whether that matters for the view it was asked for.
656
+ if (!corroborated) {
657
+ const uncorroboratedBaseline = local?.rev ?? localRev;
658
+ const claimIsAhead = uncorroboratedRev !== undefined
659
+ && (uncorroboratedBaseline === undefined || uncorroboratedRev > uncorroboratedBaseline);
660
+ // A claim — even one the quorum declined — is a peer positively attesting the block
661
+ // exists, the sharpest fact this pass can surface. It outranks silence.
662
+ const absence: AbsenceVerdict = uncorroboratedRev !== undefined ? 'claimed' : silenceVerdict;
663
+ return { absence, ...(claimIsAhead ? { claimedAheadRev: uncorroboratedRev } : {}) };
664
+ }
486
665
 
487
666
  // The self answer is the sharper baseline (same storage, same context, read alongside the
488
667
  // cohort's), but it exists only when `findCluster` returned this node. A soft serve for a
@@ -503,9 +682,11 @@ export class CoordinatorRepo implements IRepo {
503
682
  // production topology rather than a dev convenience, stop re-arming the window on a
504
683
  // corroboration that came from a single voter.
505
684
  if (baselineRev !== undefined && corroborated.rev <= baselineRev) {
506
- log('cluster-fetch:local-current', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
685
+ this.log('cluster-fetch:local-current', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
507
686
  this.markBlocksSeen([blockId]);
508
- return { inconclusive: cohortSilent };
687
+ // Only reachable when this node HOLDS a revision (the baseline), so `get` never
688
+ // consults this verdict — computed consistently rather than hard-coded.
689
+ return { absence: silenceVerdict };
509
690
  }
510
691
 
511
692
  // Corroborated revision is ahead of ours — converge onto it.
@@ -515,17 +696,24 @@ export class CoordinatorRepo implements IRepo {
515
696
  // phantom convergences per run and made a real replication defect invisible for two debugging
516
697
  // sessions.
517
698
  if (rev !== undefined) {
518
- log('cluster-fetch:synced', { blockId, rev });
699
+ this.log('cluster-fetch:synced', { blockId, rev });
519
700
  } else {
520
- log('cluster-fetch:not-restored', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
701
+ this.log('cluster-fetch:not-restored', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
521
702
  }
522
- // A corroborated revision this node failed to converge onto is inconclusive in its own right,
523
- // even with the whole cohort answering: the reader has just been TOLD the block exists, so
524
- // reporting it absent would be a lie of the same kind a silent peer causes (see `get`).
525
- const inconclusive = cohortSilent || rev === undefined;
703
+ // A corroborated revision this node failed to converge onto rules nothing out, even with
704
+ // the whole cohort answering: the reader has just been TOLD the block exists, so reporting
705
+ // it absent would be a lie regardless of silence that is the `claimed` verdict, and it
706
+ // outranks whatever the silence-based mapping would have said.
707
+ const absence: AbsenceVerdict = rev === undefined ? 'claimed' : silenceVerdict;
708
+ // Converged means REACHED the corroborated revision, not merely advanced: a promotion that
709
+ // landed short of it (possible in principle — restoreCorroborated only requires an advance
710
+ // over the baseline) still leaves the served answer behind a revision the cohort attested.
711
+ const converged = rev !== undefined && rev >= corroborated.rev;
526
712
  // The block is marked seen either way — the cohort DID answer, so its freshness was checked,
527
713
  // which is what the read-repair window tracks. A failed convergence therefore waits out the
528
- // window before retrying.
714
+ // window before retrying. The DOUBT it produced does not wait: `get` remembers the
715
+ // unsettled claim (`recordAheadClaim`) and keeps stamping reads served below it while the
716
+ // window suppresses the retry — the window damps repair effort, not honesty.
529
717
  // NOTE: that damping covers only a block this node holds at an OLDER revision. A block entirely
530
718
  // missing locally never consults the window (`get` triggers on `isMissing` before
531
719
  // `shouldReadRepair`), so a persistently failing acquisition — e.g. a two-node deployment that
@@ -534,7 +722,7 @@ export class CoordinatorRepo implements IRepo {
534
722
  // it ever shows as read amplification, gate the acquisition step (not the latest-query) on the
535
723
  // same window rather than widening `isMissing`.
536
724
  this.markBlocksSeen([blockId]);
537
- return { inconclusive };
725
+ return { absence, ...(converged ? {} : { claimedAheadRev: corroborated.rev }) };
538
726
  }
539
727
 
540
728
  /**
@@ -591,7 +779,7 @@ export class CoordinatorRepo implements IRepo {
591
779
  );
592
780
  } catch (err) {
593
781
  // Declines are cheap and retryable — nothing was persisted. Report and leave the block behind.
594
- log('cluster-fetch:acquire-error', { blockId, rev: corroborated.rev, error: (err as Error).message });
782
+ this.log('cluster-fetch:acquire-error', { blockId, rev: corroborated.rev, error: (err as Error).message });
595
783
  return undefined;
596
784
  }
597
785
  const acquired = await this.readLocalRev(blockId);
@@ -603,22 +791,24 @@ export class CoordinatorRepo implements IRepo {
603
791
  * the repair. Returns the local revision afterwards.
604
792
  *
605
793
  * A pending-only block (metadata seeded by `savePendingTransaction`, no committed revision) asked
606
- * for a forward revision no promotion can reach used to throw out of `BlockStorage.ensureRevision`;
607
- * `StorageRepo.get` now reports it as an entry flagged `unavailable` instead (ticket
608
- * repo-reports-unavailable-vs-absent). On THIS path either shape is an absence, not a read failure
609
- * acquisition is precisely the mechanism that can supply the revision so both are logged as
610
- * `promote-unavailable` and stepped over rather than short-circuiting the caller.
794
+ * for a forward revision no promotion can reach used to throw out of `BlockStorage.ensureRevision`.
795
+ * It no longer does: "no committed base here" is an absence, so that read comes back as a plain
796
+ * unflagged `{ state: {} }` and this method simply returns `undefined` acquisition then supplies
797
+ * the revision. The `unavailable` arm below still fires for the shapes that ARE a guess (a `latest`
798
+ * this node cannot materialize, a missing-base promotion refusal); on THIS path those are an
799
+ * absence too rather than a read failure, so they are logged as `promote-unavailable` and stepped
800
+ * over rather than short-circuiting the caller. The catch stays for any other fault, same reason.
611
801
  */
612
802
  private async promoteCorroborated(blockId: BlockId, corroborated: ActionRev): Promise<number | undefined> {
613
803
  try {
614
804
  const entry = await this.readLocalEntry(blockId, { committed: [corroborated], rev: corroborated.rev });
615
805
  if (entry?.unavailable !== undefined) {
616
- log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: entry.unavailable });
806
+ this.log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: entry.unavailable });
617
807
  return undefined;
618
808
  }
619
809
  return entry?.state?.latest?.rev;
620
810
  } catch (err) {
621
- log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: (err as Error).message });
811
+ this.log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: (err as Error).message });
622
812
  return undefined;
623
813
  }
624
814
  }
@@ -681,6 +871,10 @@ export class CoordinatorRepo implements IRepo {
681
871
  // as a peer claim again. Harmless today — the self answer can only ever corroborate the
682
872
  // revision already held, so the pass declines as `local-current` — but if a future caller can
683
873
  // make self report something the reader does not hold, make `localPeerId` required instead.
874
+ // The same unset-`localPeerId` tolerance also lets self count toward `answered` below, and
875
+ // lets a self read that REJECTS land in `silent`: a solo repo whose own storage throws then
876
+ // reads as `answered === 0` and reports isolation ('cohort-unreachable') rather than a local
877
+ // fault. Same fix if it ever matters — require `localPeerId`.
684
878
  const selfId = this.localPeerId?.toString();
685
879
  let local: ActionRev | undefined;
686
880
  const claims: RevClaim[] = [];
@@ -706,18 +900,34 @@ export class CoordinatorRepo implements IRepo {
706
900
  claims.push({ peerId: peerIdStr, rev: value.rev, actionId: value.actionId });
707
901
  }
708
902
  if (silent.length > 0) {
709
- log('cluster-fetch:peers-silent', { blockId, silent: silent.length, consulted: peerIds.length });
903
+ this.log('cluster-fetch:peers-silent', { blockId, silent: silent.length, consulted: peerIds.length });
710
904
  }
711
905
 
712
- const capacity = corroboratorCapacity(peerIds.filter(id => id !== selfId).length, this.repairCorroborationClusterSize);
906
+ const nonSelfCount = peerIds.filter(id => id !== selfId).length;
907
+ const answered = nonSelfCount - silent.length;
908
+ const capacity = corroboratorCapacity(nonSelfCount, this.repairCorroborationClusterSize);
713
909
  const selected = selectQuorumRev(claims, this.simpleMajorityThreshold, capacity);
714
910
  if (!selected) {
715
- log('cluster-fetch:no-quorum', {
911
+ this.log('cluster-fetch:no-quorum', {
716
912
  blockId,
717
913
  responders: claims.length,
718
- required: quorumSize(claims.length, this.simpleMajorityThreshold, capacity)
914
+ required: quorumSize(claims.length, this.simpleMajorityThreshold, capacity),
915
+ repairCorroborationClusterSize: this.repairCorroborationClusterSize
719
916
  });
720
- return { local, silent };
917
+ // The claims themselves must not drive restoration — but their existence is
918
+ // evidence the caller needs: an answer served below the highest claim cannot be
919
+ // confirmed current (see ClusterLatestQuery.uncorroboratedRev).
920
+ // NOTE: ONE claim is enough to raise that doubt, and a claim is a bare assertion
921
+ // (no commit certificate to verify it against). So a single lying cohort peer can
922
+ // deny unpinned reads of a block by claiming a revision nobody else holds — an
923
+ // availability lever it did not have while uncorroborated claims were discarded.
924
+ // Deliberate for now: the alternative is the silent stale serve this marker exists
925
+ // to end, and the same liar can already force a silent-treated absence by staying
926
+ // quiet. Revisit if claims become attestable (backlog
927
+ // `debt-read-repair-commit-cert-verification`) — then gate the stamp on a verified
928
+ // certificate rather than on the bare claim.
929
+ const uncorroboratedRev = claims.length > 0 ? Math.max(...claims.map(c => c.rev)) : undefined;
930
+ return { local, silent, answered, ...(uncorroboratedRev !== undefined ? { uncorroboratedRev } : {}) };
721
931
  }
722
932
 
723
933
  // Best-effort: penalize peers whose claim contradicts the corroborated pair
@@ -725,7 +935,7 @@ export class CoordinatorRepo implements IRepo {
725
935
  // rev). A lower rev is just lag, never penalized. Never let this throw.
726
936
  this.penalizeContradictingRevClaims(claims, selected, blockId);
727
937
 
728
- return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local, silent };
938
+ return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local, silent, answered };
729
939
  }
730
940
 
731
941
  /**
@@ -750,7 +960,7 @@ export class CoordinatorRepo implements IRepo {
750
960
  }
751
961
  }
752
962
  } catch (err) {
753
- log('cluster-fetch:penalize-error', { blockId, error: (err as Error).message });
963
+ this.log('cluster-fetch:penalize-error', { blockId, error: (err as Error).message });
754
964
  }
755
965
  }
756
966
 
@@ -772,14 +982,14 @@ export class CoordinatorRepo implements IRepo {
772
982
 
773
983
  try {
774
984
  const { localExecuted } = await this.coordinator.executeClusterTransaction(coordinatingBlockIds[0]!, message, options);
775
- log('coordinator-repo:pend-cluster-complete', {
985
+ this.log('coordinator-repo:pend-cluster-complete', {
776
986
  actionId: request.actionId,
777
987
  localExecuted
778
988
  });
779
989
  // Only call storageRepo if local cluster didn't already execute during consensus
780
990
  if (!localExecuted) {
781
991
  const result = await this.storageRepo.pend(request, options);
782
- log('coordinator-repo:pend-fallback-result', {
992
+ this.log('coordinator-repo:pend-fallback-result', {
783
993
  actionId: request.actionId,
784
994
  success: result.success,
785
995
  hasMissing: !!(result as any).missing?.length,
@@ -794,7 +1004,20 @@ export class CoordinatorRepo implements IRepo {
794
1004
  blockIds: allBlockIds
795
1005
  };
796
1006
  } catch (error) {
797
- log('coordinator-repo:pend-error', { actionId: request.actionId, error: (error as Error).message });
1007
+ this.log('coordinator-repo:pend-error', { actionId: request.actionId, error: (error as Error).message });
1008
+ // A lost conflict race is an optimistic-concurrency loss, not a fault: surface it as the
1009
+ // StaleFailure shape the retry machinery already understands (`Collection.sync` and the
1010
+ // multi-collection pendPhase retry it via `isConflictFailure`), exactly as a confirmed
1011
+ // stale revision is. `staleAt` stays absent deliberately — it is confirmed-only, and a
1012
+ // lost race is a rival *pend* holding the blocks, not a revision claim.
1013
+ //
1014
+ // NOTE: `error.conflicts` (peerId → winning messageHash) is dropped here — `StaleFailure`
1015
+ // has no field for it and the retry loop only needs "retryable". If a caller ever needs to
1016
+ // know WHICH transaction won (e.g. to wait on it rather than re-race it), add a typed field
1017
+ // for it; never recover it by parsing `reason`.
1018
+ if (error instanceof ConflictRaceLostError) {
1019
+ return { success: false, conflict: true, reason: error.message };
1020
+ }
798
1021
  const stale = await this.classifyStaleRejection(error, request, allBlockIds);
799
1022
  if (stale) return stale;
800
1023
  throw error;
@@ -827,7 +1050,7 @@ export class CoordinatorRepo implements IRepo {
827
1050
  try {
828
1051
  results = await this.storageRepo.get({ blockIds });
829
1052
  } catch (readError) {
830
- log('coordinator-repo:pend-stale-classify-read-error', {
1053
+ this.log('coordinator-repo:pend-stale-classify-read-error', {
831
1054
  actionId: request.actionId,
832
1055
  error: (readError as Error).message
833
1056
  });
@@ -842,7 +1065,7 @@ export class CoordinatorRepo implements IRepo {
842
1065
  return latest && latest.rev >= requestedRev ? { blockId, rev: latest.rev } : undefined;
843
1066
  }));
844
1067
  if (staleAt) {
845
- log('coordinator-repo:pend-stale-classified', {
1068
+ this.log('coordinator-repo:pend-stale-classified', {
846
1069
  actionId: request.actionId,
847
1070
  blockId: staleAt.blockId,
848
1071
  latestRev: staleAt.rev,
@@ -891,7 +1114,7 @@ export class CoordinatorRepo implements IRepo {
891
1114
  await this.storageRepo.cancel(actionRef, options);
892
1115
  }
893
1116
  } catch (error) {
894
- log('coordinator-repo:cancel-error', { actionId: actionRef.actionId, error: (error as Error).message });
1117
+ this.log('coordinator-repo:cancel-error', { actionId: actionRef.actionId, error: (error as Error).message });
895
1118
  throw error;
896
1119
  }
897
1120
  }
@@ -948,7 +1171,7 @@ export class CoordinatorRepo implements IRepo {
948
1171
  throw err;
949
1172
  }
950
1173
  } catch (error) {
951
- log('coordinator-repo:commit-error', { actionId: request.actionId, error: (error as Error).message });
1174
+ this.log('coordinator-repo:commit-error', { actionId: request.actionId, error: (error as Error).message });
952
1175
  throw error;
953
1176
  }
954
1177
  }
@@ -959,7 +1182,7 @@ export class CoordinatorRepo implements IRepo {
959
1182
  * from replication (cohort reconcile, or read-driven acquisition), not from replay here.
960
1183
  */
961
1184
  private tolerateLocalCommitDivergence(request: CommitRequest, blockIds: BlockId[], detail: string): CommitResult {
962
- log('coordinator-repo:commit-local-failed-cluster-succeeded', { actionId: request.actionId, error: detail });
1185
+ this.log('coordinator-repo:commit-local-failed-cluster-succeeded', { actionId: request.actionId, error: detail });
963
1186
  this.markBlocksSeen(blockIds);
964
1187
  return { success: true };
965
1188
  }