@optimystic/db-core 1.0.0-beta.2 → 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 (180) hide show
  1. package/dist/src/cluster/structs.d.ts +62 -16
  2. package/dist/src/cluster/structs.d.ts.map +1 -1
  3. package/dist/src/cluster/structs.js +5 -3
  4. package/dist/src/cluster/structs.js.map +1 -1
  5. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts +2 -1
  6. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts.map +1 -1
  7. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js +10 -3
  8. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js.map +1 -1
  9. package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
  10. package/dist/src/cohort-topic/wire/codec.js +8 -2
  11. package/dist/src/cohort-topic/wire/codec.js.map +1 -1
  12. package/dist/src/collection/collection.d.ts +467 -35
  13. package/dist/src/collection/collection.d.ts.map +1 -1
  14. package/dist/src/collection/collection.js +879 -149
  15. package/dist/src/collection/collection.js.map +1 -1
  16. package/dist/src/collection/struct.d.ts +109 -4
  17. package/dist/src/collection/struct.d.ts.map +1 -1
  18. package/dist/src/collection/struct.js +88 -1
  19. package/dist/src/collection/struct.js.map +1 -1
  20. package/dist/src/collections/diary/diary.d.ts +6 -2
  21. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  22. package/dist/src/collections/diary/diary.js +5 -2
  23. package/dist/src/collections/diary/diary.js.map +1 -1
  24. package/dist/src/collections/tree/struct.d.ts +192 -5
  25. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  26. package/dist/src/collections/tree/struct.js +134 -0
  27. package/dist/src/collections/tree/struct.js.map +1 -1
  28. package/dist/src/collections/tree/tree.d.ts +13 -3
  29. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  30. package/dist/src/collections/tree/tree.js +109 -6
  31. package/dist/src/collections/tree/tree.js.map +1 -1
  32. package/dist/src/index.d.ts +2 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +2 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/logger-registry.d.ts +57 -0
  37. package/dist/src/logger-registry.d.ts.map +1 -0
  38. package/dist/src/logger-registry.js +168 -0
  39. package/dist/src/logger-registry.js.map +1 -0
  40. package/dist/src/logger.d.ts.map +1 -1
  41. package/dist/src/logger.js +3 -0
  42. package/dist/src/logger.js.map +1 -1
  43. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  44. package/dist/src/matchmaking/wire.js +8 -2
  45. package/dist/src/matchmaking/wire.js.map +1 -1
  46. package/dist/src/network/durability.d.ts +45 -0
  47. package/dist/src/network/durability.d.ts.map +1 -0
  48. package/dist/src/network/durability.js +114 -0
  49. package/dist/src/network/durability.js.map +1 -0
  50. package/dist/src/network/i-key-network.d.ts +14 -6
  51. package/dist/src/network/i-key-network.d.ts.map +1 -1
  52. package/dist/src/network/index.d.ts +3 -0
  53. package/dist/src/network/index.d.ts.map +1 -1
  54. package/dist/src/network/index.js +3 -0
  55. package/dist/src/network/index.js.map +1 -1
  56. package/dist/src/network/lineage.d.ts +39 -0
  57. package/dist/src/network/lineage.d.ts.map +1 -0
  58. package/dist/src/network/lineage.js +61 -0
  59. package/dist/src/network/lineage.js.map +1 -0
  60. package/dist/src/network/routing-key.d.ts +24 -0
  61. package/dist/src/network/routing-key.d.ts.map +1 -0
  62. package/dist/src/network/routing-key.js +9 -0
  63. package/dist/src/network/routing-key.js.map +1 -0
  64. package/dist/src/network/stale-failure.d.ts +5 -1
  65. package/dist/src/network/stale-failure.d.ts.map +1 -1
  66. package/dist/src/network/stale-failure.js +5 -1
  67. package/dist/src/network/stale-failure.js.map +1 -1
  68. package/dist/src/network/struct.d.ts +151 -8
  69. package/dist/src/network/struct.d.ts.map +1 -1
  70. package/dist/src/network/struct.js.map +1 -1
  71. package/dist/src/reactivity/wire.d.ts.map +1 -1
  72. package/dist/src/reactivity/wire.js +8 -2
  73. package/dist/src/reactivity/wire.js.map +1 -1
  74. package/dist/src/testing/test-transactor.d.ts +64 -7
  75. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  76. package/dist/src/testing/test-transactor.js +150 -23
  77. package/dist/src/testing/test-transactor.js.map +1 -1
  78. package/dist/src/transaction/coordinator.d.ts +141 -21
  79. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  80. package/dist/src/transaction/coordinator.js +408 -122
  81. package/dist/src/transaction/coordinator.js.map +1 -1
  82. package/dist/src/transaction/errors.d.ts +39 -10
  83. package/dist/src/transaction/errors.d.ts.map +1 -1
  84. package/dist/src/transaction/errors.js +34 -9
  85. package/dist/src/transaction/errors.js.map +1 -1
  86. package/dist/src/transaction/index.d.ts +1 -0
  87. package/dist/src/transaction/index.d.ts.map +1 -1
  88. package/dist/src/transaction/index.js.map +1 -1
  89. package/dist/src/transactor/block-floors.d.ts +78 -0
  90. package/dist/src/transactor/block-floors.d.ts.map +1 -0
  91. package/dist/src/transactor/block-floors.js +62 -0
  92. package/dist/src/transactor/block-floors.js.map +1 -0
  93. package/dist/src/transactor/change-notifier.d.ts +43 -0
  94. package/dist/src/transactor/change-notifier.d.ts.map +1 -1
  95. package/dist/src/transactor/change-notifier.js +3 -0
  96. package/dist/src/transactor/change-notifier.js.map +1 -1
  97. package/dist/src/transactor/index.d.ts +1 -0
  98. package/dist/src/transactor/index.d.ts.map +1 -1
  99. package/dist/src/transactor/index.js +1 -0
  100. package/dist/src/transactor/index.js.map +1 -1
  101. package/dist/src/transactor/network-transactor.d.ts +43 -6
  102. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  103. package/dist/src/transactor/network-transactor.js +279 -81
  104. package/dist/src/transactor/network-transactor.js.map +1 -1
  105. package/dist/src/transactor/transactor-source.d.ts +94 -4
  106. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  107. package/dist/src/transactor/transactor-source.js +120 -42
  108. package/dist/src/transactor/transactor-source.js.map +1 -1
  109. package/dist/src/transactor/transactor.d.ts +14 -1
  110. package/dist/src/transactor/transactor.d.ts.map +1 -1
  111. package/dist/src/transform/atomic.d.ts.map +1 -1
  112. package/dist/src/transform/atomic.js +12 -9
  113. package/dist/src/transform/atomic.js.map +1 -1
  114. package/dist/src/transform/base-pins.d.ts +50 -17
  115. package/dist/src/transform/base-pins.d.ts.map +1 -1
  116. package/dist/src/transform/base-pins.js +58 -11
  117. package/dist/src/transform/base-pins.js.map +1 -1
  118. package/dist/src/transform/cache-source.d.ts +75 -11
  119. package/dist/src/transform/cache-source.d.ts.map +1 -1
  120. package/dist/src/transform/cache-source.js +162 -36
  121. package/dist/src/transform/cache-source.js.map +1 -1
  122. package/dist/src/transform/digest.d.ts +8 -1
  123. package/dist/src/transform/digest.d.ts.map +1 -1
  124. package/dist/src/transform/digest.js +19 -9
  125. package/dist/src/transform/digest.js.map +1 -1
  126. package/dist/src/transform/helpers.d.ts +6 -2
  127. package/dist/src/transform/helpers.d.ts.map +1 -1
  128. package/dist/src/transform/helpers.js +8 -3
  129. package/dist/src/transform/helpers.js.map +1 -1
  130. package/dist/src/transform/tracker.d.ts +94 -14
  131. package/dist/src/transform/tracker.d.ts.map +1 -1
  132. package/dist/src/transform/tracker.js +222 -68
  133. package/dist/src/transform/tracker.js.map +1 -1
  134. package/dist/src/utility/structural-equals.d.ts +38 -0
  135. package/dist/src/utility/structural-equals.d.ts.map +1 -0
  136. package/dist/src/utility/structural-equals.js +77 -0
  137. package/dist/src/utility/structural-equals.js.map +1 -0
  138. package/package.json +2 -2
  139. package/src/cluster/structs.ts +298 -255
  140. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
  141. package/src/cohort-topic/wire/codec.ts +222 -216
  142. package/src/collection/collection.ts +996 -158
  143. package/src/collection/struct.ts +99 -4
  144. package/src/collections/diary/diary.ts +71 -68
  145. package/src/collections/tree/struct.ts +248 -26
  146. package/src/collections/tree/tree.ts +130 -9
  147. package/src/index.ts +2 -1
  148. package/src/logger-registry.ts +224 -0
  149. package/src/logger.ts +4 -0
  150. package/src/matchmaking/wire.ts +8 -2
  151. package/src/network/durability.ts +123 -0
  152. package/src/network/i-key-network.ts +60 -52
  153. package/src/network/index.ts +10 -7
  154. package/src/network/lineage.ts +80 -0
  155. package/src/network/routing-key.ts +27 -0
  156. package/src/network/stale-failure.ts +5 -1
  157. package/src/network/struct.ts +156 -8
  158. package/src/reactivity/wire.ts +230 -224
  159. package/src/testing/test-transactor.ts +152 -24
  160. package/src/transaction/coordinator.ts +470 -131
  161. package/src/transaction/errors.ts +34 -9
  162. package/src/transaction/index.ts +1 -0
  163. package/src/transactor/block-floors.ts +105 -0
  164. package/src/transactor/change-notifier.ts +129 -80
  165. package/src/transactor/index.ts +1 -0
  166. package/src/transactor/network-transactor.ts +297 -84
  167. package/src/transactor/transactor-source.ts +131 -42
  168. package/src/transactor/transactor.ts +13 -1
  169. package/src/transform/atomic.ts +25 -22
  170. package/src/transform/base-pins.ts +76 -18
  171. package/src/transform/cache-source.ts +327 -194
  172. package/src/transform/digest.ts +21 -10
  173. package/src/transform/helpers.ts +8 -3
  174. package/src/transform/tracker.ts +423 -270
  175. package/src/utility/structural-equals.ts +72 -0
  176. package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
  177. package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
  178. package/dist/src/utility/block-id-to-bytes.js +0 -7
  179. package/dist/src/utility/block-id-to-bytes.js.map +0 -1
  180. package/src/utility/block-id-to-bytes.ts +0 -8
@@ -2,16 +2,18 @@ import { peerIdFromString } from "../network/types.js";
2
2
  import type { PeerId } from "../network/types.js";
3
3
  import { highestStaleAt, isConflictFailure } from "../network/stale-failure.js";
4
4
  import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
5
- import type { ActionTransforms, ActionBlocks, BlockActionStatus, ITransactor, PendSuccess, StaleFailure, IKeyNetwork, BlockId, GetBlockResults, PendResult, CommitResult, PendRequest, IRepo, BlockGets, Transforms, CommitRequest, ActionId, RepoCommitRequest, ClusterNomineesResult, CollectionId, IBlock, CoordinatorIntent, BlockUnavailableReason, BlockContentDigests } from "../index.js";
5
+ import { mergeDurability, withTornBlocks } from "../network/durability.js";
6
+ import { judgeCohortLineage, type CohortLineage, type MemberLineage } from "../network/lineage.js";
7
+ import type { ActionTransforms, ActionBlocks, ActionLineage, BlockActionStatus, ITransactor, PendSuccess, CommitSuccess, StaleFailure, IKeyNetwork, BlockId, GetBlockResults, PendResult, CommitResult, PendRequest, IRepo, BlockGets, Transforms, CommitRequest, ActionId, RepoCommitRequest, ClusterNomineesResult, CollectionId, IBlock, GetBlockResult, CoordinatorIntent, BlockUnavailableReason, BlockContentDigests, BlockBaseRevs, WriteDurability } from "../index.js";
6
8
  import type { IBlockChangeNotifier, CollectionChangeListener } from "./change-notifier.js";
7
9
  import { transformForBlockId, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms } from "../transform/helpers.js";
8
10
  import { Tracker } from "../transform/tracker.js";
9
- import { blockDigestsField } from "../transform/digest.js";
11
+ import { blockDigestsField, baseRevsField } from "../transform/digest.js";
10
12
  import { CacheSource } from "../transform/cache-source.js";
11
- import { TransactorSource } from "./transactor-source.js";
13
+ import { TransactorSource, servedRevision } from "./transactor-source.js";
12
14
  import { Log } from "../log/log.js";
13
15
  import { groupBy } from "../utility/groupby.js";
14
- import { blockIdToBytes } from "../utility/block-id-to-bytes.js";
16
+ import { routingKeyForBlock } from "../network/routing-key.js";
15
17
  import { isRecordEmpty } from "../utility/is-record-empty.js";
16
18
  import { type CoordinatorBatch, makeBatchesByPeer, incompleteBatches, everyBatch, allBatches, mergeBlocks, processBatches, createBatchesForPayload } from "../utility/batch-coordinator.js";
17
19
  import { abortableDelay, jitteredBackoffMs } from "../utility/backoff.js";
@@ -41,6 +43,17 @@ type NetworkTransactorInit = {
41
43
  * absent, `onCollectionChange` is a logged no-op.
42
44
  */
43
45
  localChangeNotifier?: IBlockChangeNotifier;
46
+ /**
47
+ * The peer id of the node this transactor runs on, when `getRepo` hands that id the node's own
48
+ * co-located repo rather than a network client — the same "co-located node" idea as
49
+ * `localChangeNotifier`. It only breaks ties when a pend's blocks are grouped onto coordinators:
50
+ * among cohort members covering equally many blocks, this node wins, so a write it is responsible
51
+ * for is coordinated without a network hop. It never wins over a member covering more blocks
52
+ * (fewer batches come first), and it never makes this node a coordinator for a block whose cohort
53
+ * does not include it. Omit when nothing is co-located; ties then go to the member seen first,
54
+ * which is the nearest.
55
+ */
56
+ localPeerId?: PeerId;
44
57
  }
45
58
 
46
59
  /**
@@ -59,6 +72,8 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
59
72
  private readonly dialTimeoutMs: number | undefined;
60
73
  private readonly getRepo: (peerId: PeerId) => IRepo;
61
74
  private readonly localChangeNotifier: IBlockChangeNotifier | undefined;
75
+ /** `NetworkTransactorInit.localPeerId` as a string, the form cohort peer ids arrive in. */
76
+ private readonly localPeerId: string | undefined;
62
77
 
63
78
  /**
64
79
  * Per-transaction coordinator cache: `actionId → (blockId → resolved coordinator)`.
@@ -93,6 +108,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
93
108
  : (init.dialTimeoutMs > 0 ? init.dialTimeoutMs : undefined);
94
109
  this.getRepo = init.getRepo;
95
110
  this.localChangeNotifier = init.localChangeNotifier;
111
+ this.localPeerId = init.localPeerId?.toString();
96
112
  }
97
113
 
98
114
  /**
@@ -137,7 +153,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
137
153
  batch => batch.payload,
138
154
  (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(bid => bid === blockId)],
139
155
  expiration,
140
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
156
+ (blockId, options) => this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), { ...options, intent: 'read' })
141
157
  );
142
158
  } catch (e) {
143
159
  error = e as Error;
@@ -152,30 +168,53 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
152
168
  // present and unflagged) — retrying it doubles the round-trips on the common
153
169
  // createOrOpen "does this block exist?" probe. Cross-member reconciliation for a
154
170
  // missing block has already happened one layer down: CoordinatorRepo.get detects
155
- // `isMissing` and consults cluster peers before it responds — and when that
156
- // consult FAILS, the entry now says so via `unavailable` instead of posing as an
157
- // authoritative absent. So by the time an unflagged absent reaches here there is
158
- // nothing left for a transactor-level retry to discover, while a flagged entry
159
- // earns the retry against a different peer that an absent deliberately does not.
171
+ // `isMissing` and consults cluster peers on every such read before it responds —
172
+ // and when that consult FAILS, the entry now says so via `unavailable` instead of
173
+ // posing as an authoritative absent. So by the time an unflagged absent reaches
174
+ // here there is nothing left for a transactor-level retry to discover, while a
175
+ // flagged entry earns the retry against a different peer that an absent
176
+ // deliberately does not. (The coordinator never remembers an absence across reads:
177
+ // one that did served a just-committed block as never created — GitHub issue #20.)
160
178
  // See tickets txn-perf-authoritative-notfound and repo-reports-unavailable-vs-absent.
161
179
  const hasValidResponse = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
162
180
  return b.request?.isResponse === true && b.request.response != null;
163
181
  };
164
182
 
183
+ // Content the CALLER can prove is too old, from the floor it carried on the request
184
+ // (see {@link BlockGets.floors}): the caller walked a log entry saying this block changed at
185
+ // that revision, so anything below it is not the view the read asked for, however current
186
+ // the answering repo honestly believes its copy to be. Only a block-carrying entry can be
187
+ // below a floor — an absent answer is judged by its own rules above, and treating one as
188
+ // below-floor would put the one-round `createOrOpen` probe back on the retry path.
189
+ // Reported here rather than left to the reader: `TransactorSource` reports
190
+ // `collection:block-below-floor` from the MERGED answer, so a read the retry below repaired
191
+ // is the one case that leaves no trace anywhere else — the extra round would show up in a
192
+ // field log as an unexplained second consult. Costs nothing on an unfloored read, which
193
+ // leaves on the first clause.
194
+ const belowFloor = (bid: BlockId, entry: GetBlockResult): boolean => {
195
+ const floor = blockGets.floors?.[bid];
196
+ if (floor === undefined || entry.block == null || servedRevision(entry) >= floor) return false;
197
+ log('get:below-floor blockId=%s served=%d floor=%d', bid, servedRevision(entry), floor);
198
+ return true;
199
+ };
200
+
165
201
  // A batch is answered when its response carries an entry for EVERY requested
166
- // block id and none of those entries carries a doubt marker. An entry present
167
- // with only `state` (no `block`) is an authoritative "absent", which counts as
168
- // answered — not a gap. An `unavailable` entry is the peer saying it could not
169
- // find out whether the block EXISTS; an `unconfirmedAheadRev` entry is the peer
170
- // saying it could not confirm the content it served is CURRENT (a cohort claim
171
- // sits ahead of it, unsettled). Neither counts as answered, so both earn the
172
- // second-chance retry against a different coordinator.
202
+ // block id and none of those entries carries a doubt marker or falls below the
203
+ // caller's floor. An entry present with only `state` (no `block`) is an
204
+ // authoritative "absent", which counts as answered — not a gap. An `unavailable`
205
+ // entry is the peer saying it could not find out whether the block EXISTS; an
206
+ // `unconfirmedAheadRev` entry is the peer saying it could not confirm the content
207
+ // it served is CURRENT (a cohort claim sits ahead of it, unsettled); a below-floor
208
+ // entry is the CALLER knowing the content is behind a revision it has already seen
209
+ // the log commit. None counts as answered, so each earns the second-chance retry
210
+ // against a different coordinator.
173
211
  const isAuthoritative = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
174
212
  if (!hasValidResponse(b)) return false;
175
213
  const resp = b.request!.response! as GetBlockResults;
176
214
  return b.payload.every(bid => resp[bid] !== undefined
177
215
  && resp[bid]!.unavailable === undefined
178
- && resp[bid]!.unconfirmedAheadRev === undefined);
216
+ && resp[bid]!.unconfirmedAheadRev === undefined
217
+ && !belowFloor(bid, resp[bid]!));
179
218
  };
180
219
 
181
220
  // Retry only genuine no-response / partial-response batches. An authoritative
@@ -186,6 +225,13 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
186
225
  // today — one extra bounded consult on an already failing read. If isolated-node
187
226
  // read latency ever matters, skip the retry for that reason rather than widening
188
227
  // isAuthoritative.
228
+ // This is ONE extra round, not a loop: a below-floor answer that every reachable
229
+ // coordinator repeats survives the merge below as the highest revision anyone
230
+ // served, UNFLAGGED. Deliberate — a log entry is not proof its blocks landed, so a
231
+ // floor no machine can meet may simply describe an abandoned write, in which case
232
+ // the below-floor content is the correct content and every machine agrees on it
233
+ // (see the accepted-tradeoff NOTE at `TransactorSource.mayRetain`). The reader
234
+ // judges it again and returns it uncached.
189
235
  const retryable = Array.from(allBatches(batches)).filter(b =>
190
236
  !isAuthoritative(b as any)
191
237
  ) as CoordinatorBatch<BlockId[], GetBlockResults>[];
@@ -202,7 +248,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
202
248
  b.payload,
203
249
  (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
204
250
  Array.from(excluded),
205
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
251
+ (blockId, options) => this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), { ...options, intent: 'read' })
206
252
  );
207
253
  if (retries.length > 0) {
208
254
  b.subsumedBy = [...(b.subsumedBy ?? []), ...retries];
@@ -212,7 +258,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
212
258
  batch => batch.payload,
213
259
  (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
214
260
  expiration,
215
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
261
+ (blockId, options) => this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), { ...options, intent: 'read' })
216
262
  );
217
263
  }
218
264
  }));
@@ -239,13 +285,20 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
239
285
  // — and only strictly-greater rank replaces, so first-arrival (the stale one)
240
286
  // would win the very merge the retry exists to fix. Non-object junk ranks below
241
287
  // everything so any real entry replaces it.
242
- // NOTE: `materialized` (the revision the content actually is) is not part of the ranking, so two peers answering the same
243
- // pinned get with block-carrying entries at DIFFERENT materialized revisions resolve
244
- // to whichever arrived first. Not a concern today cohort peers share the block's
245
- // revision log, so they agree on the highest committed rev at or below a pin — and the
246
- // failure direction is safe (a lower recorded revision spuriously stale-rejects rather
247
- // than wrongly accepting). If peers are ever seen to disagree here, break the tie on
248
- // the HIGHEST `materialized.rev` among top-rank entries.
288
+ // Rank alone does not order two peers that both served content: a confirmed block ranks 6
289
+ // whatever revision it is. Cohort peers were expected to agree — they share the block's
290
+ // revision log, so they agree on the highest committed revision at or below a pin — but a
291
+ // peer inside its read-repair window answers from its own copy without consulting anyone,
292
+ // and two cohort members answered one pinned read at revisions 6 and 7. Since only a
293
+ // strictly greater rank replaces, first arrival (the stale one) would win the very merge a
294
+ // below-floor retry exists to fix. So equally-ranked entries that BOTH carry a block break
295
+ // the tie on the newer content, measured by `servedRevision` — the same number the floor is
296
+ // judged against and the same number the reader records as its read dependency, so the
297
+ // merge cannot prefer one revision while the reader believes it read another. The tie-break
298
+ // is deliberately inside a rank rather than across ranks: a confirmed older block still
299
+ // beats an unconfirmed newer one, because "the peer could not confirm this is current" is
300
+ // a statement about the ANSWER, not about the revision, and adopting the unconfirmed one
301
+ // would re-open the doubt the rank split exists to resolve.
249
302
  // `unavailable` answers rank among THEMSELVES by how much they establish, so the merged
250
303
  // entry never presents a weaker doubt than some peer actually returned. This matters
251
304
  // because the reason travels out verbatim on `BlockUnavailableError` and callers act on
@@ -264,6 +317,33 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
264
317
  if (entry.unavailable !== undefined) return unavailableRank(entry.unavailable);
265
318
  return entry.unconfirmedAheadRev === undefined ? 4 : 3;
266
319
  };
320
+ const carriesBlock = (r: unknown): r is GetBlockResult =>
321
+ !!r && typeof r === 'object' && (r as GetBlockResult).block != null;
322
+ /** Whether `candidate` displaces the entry already held for the same block: a strictly
323
+ * better rank, or — at equal rank, both carrying content — strictly newer content.
324
+ *
325
+ * NOTE: `servedRevision` falls back to `state.latest` for a producer that omits
326
+ * `materialized`, and on a PINNED read that fallback overstates — `state.latest` is the
327
+ * newest revision the repo holds, not the revision it served. Every in-tree producer
328
+ * populates `materialized` (`StorageRepo`, `TestTransactor`; `CoordinatorRepo` forwards it
329
+ * verbatim), so the fallback only reaches a third-party `IRepo`, and `belowFloor` above
330
+ * reads the same number, so the two stay consistent. If such a repo ever answers pinned
331
+ * reads beside one that does report `materialized`, it can win this tie with a number
332
+ * describing content it did not serve: cap the comparison at `blockGets.context.rev` then
333
+ * (a correct answer to a pinned read is never above the pin), in both places at once.
334
+ *
335
+ * NOTE: `servedRevision` also reads `state`, which the type requires but a decoded wire
336
+ * response could still omit. Ranking never touched it, so an entry like that used to fault
337
+ * only the reader of ITS block; it now faults the whole `get`, siblings included. Left
338
+ * alone because the reader called `servedRevision` on such an entry anyway — this widens
339
+ * the blast radius, it does not create the fault. If a third-party `IRepo` ever makes it
340
+ * real, make `servedRevision` tolerate a missing `state` rather than guarding here. */
341
+ const beats = (candidate: unknown, held: unknown): boolean => {
342
+ const byRank = rankOf(candidate) - rankOf(held);
343
+ return byRank !== 0
344
+ ? byRank > 0
345
+ : carriesBlock(candidate) && carriesBlock(held) && servedRevision(candidate) > servedRevision(held);
346
+ };
267
347
 
268
348
  // Create a lookup map from successful responses only
269
349
  const resultEntries = new Map<string, any>();
@@ -271,7 +351,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
271
351
  const resp = batch.request!.response! as any;
272
352
  for (const [bid, res] of Object.entries(resp)) {
273
353
  const existing = resultEntries.get(bid);
274
- if (!existing || rankOf(res) > rankOf(existing)) {
354
+ if (!existing || beats(res, existing)) {
275
355
  resultEntries.set(bid, res);
276
356
  }
277
357
  }
@@ -420,6 +500,57 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
420
500
  }
421
501
  }
422
502
 
503
+ /**
504
+ * See {@link ITransactor.getLineage}. Asks EVERY member of each block's cohort, directly, and
505
+ * folds their answers with {@link judgeCohortLineage} — not the coordinator round {@link get}
506
+ * runs, because no single member can answer for the cohort: a member that took a later revision
507
+ * as a replica cannot say what it was built from, while the member it came from can.
508
+ *
509
+ * The per-member `get` carries no context, so it pins nothing and promotes nothing; asking is
510
+ * never what makes a write land.
511
+ *
512
+ * NOTE: one request per cohort member per block. Reached only when a writer's own half-landed
513
+ * write was refused as superseded, which is rare, so the fan-out is not batched by member. If a
514
+ * write path ever calls this routinely, group the blocks a member is in the cohort of into one
515
+ * request per member.
516
+ */
517
+ async getLineage(ref: ActionBlocks & { rev: number }): Promise<ActionLineage> {
518
+ const target = { actionId: ref.actionId, rev: ref.rev };
519
+ const expiration = Date.now() + this.timeoutMs;
520
+ const answers = await Promise.all(ref.blockIds.map(blockId => this.cohortLineage(blockId, target, expiration)));
521
+ const durabilities = answers.flatMap(answer => answer.durability === undefined ? [] : [answer.durability]);
522
+ const saved = answers.length > 0 && durabilities.length === answers.length;
523
+ log('getLineage actionId=%s rev=%d blocks=%o', ref.actionId, ref.rev, answers.map(answer => answer.lineage));
524
+ return {
525
+ blocks: answers.map(answer => answer.lineage),
526
+ ...(saved ? { durability: mergeDurability(durabilities) } : {})
527
+ };
528
+ }
529
+
530
+ /** One block's cohort answer: every member asked in parallel, a member that cannot be reached
531
+ * (or whose cohort cannot be resolved at all) counted as `unknown` rather than left out. */
532
+ private async cohortLineage(blockId: BlockId, target: { actionId: ActionId; rev: number }, expiration: number): Promise<CohortLineage> {
533
+ let cohort: string[];
534
+ try {
535
+ cohort = Object.keys(await this.keyNetwork.findCluster(routingKeyForBlock(blockId)));
536
+ } catch (err) {
537
+ log('getLineage:cohort-unresolved blockId=%s error=%s', blockId, errorMessage(err));
538
+ return { lineage: 'unknown' };
539
+ }
540
+ const members = await Promise.all(cohort.map(async (peerId): Promise<MemberLineage> => {
541
+ try {
542
+ const results = await this.getRepo(peerIdFromString(peerId)).get(
543
+ { blockIds: [blockId], lineageOf: target }, { expiration, dialTimeoutMs: this.dialTimeoutMs });
544
+ const entry = results?.[blockId];
545
+ return { peerId, lineage: entry?.lineage ?? 'unknown', ...(entry?.state?.latest ? { latest: entry.state.latest } : {}) };
546
+ } catch (err) {
547
+ log('getLineage:member-silent blockId=%s peer=%s error=%s', blockId, peerId, errorMessage(err));
548
+ return { peerId, lineage: 'unknown' };
549
+ }
550
+ }));
551
+ return judgeCohortLineage(members);
552
+ }
553
+
423
554
  private async consolidateCoordinators(
424
555
  blockIds: BlockId[],
425
556
  transforms: Transforms,
@@ -436,7 +567,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
436
567
 
437
568
  await Promise.all(blockIds.map(async bid => {
438
569
  try {
439
- const clusterPeers = await this.keyNetwork.findCluster(await blockIdToBytes(bid));
570
+ const clusterPeers = await this.keyNetwork.findCluster(routingKeyForBlock(bid));
440
571
  blockClusterPeerIds.set(bid, new Set(Object.keys(clusterPeers)));
441
572
  } catch {
442
573
  fallbackBlocks.push(bid);
@@ -458,22 +589,10 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
458
589
  const assignments = new Map<string, BlockId[]>(); // peerIdStr → assigned blockIds
459
590
 
460
591
  while (uncovered.size > 0) {
461
- let bestPeer: string | undefined;
462
- let bestCount = 0;
463
-
464
- for (const [peerId, blocks] of peerBlocks) {
465
- const coverCount = blocks.filter(bid => uncovered.has(bid)).length;
466
- if (coverCount > bestCount) {
467
- bestCount = coverCount;
468
- bestPeer = peerId;
469
- }
470
- }
471
-
472
- if (!bestPeer || bestCount === 0) break;
473
-
474
- const covered = peerBlocks.get(bestPeer)!.filter(bid => uncovered.has(bid));
475
- assignments.set(bestPeer, covered);
476
- for (const bid of covered) uncovered.delete(bid);
592
+ const best = bestCoveringPeer(peerBlocks, uncovered, this.localPeerId);
593
+ if (!best) break;
594
+ assignments.set(best.peerId, best.covered);
595
+ for (const bid of best.covered) uncovered.delete(bid);
477
596
  }
478
597
 
479
598
  // Step 4: Any remaining uncovered blocks fall back to findCoordinator
@@ -482,7 +601,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
482
601
  const fallbackCoordinators = await Promise.all(
483
602
  fallbackBlocks.map(async bid => ({
484
603
  blockId: bid,
485
- coordinator: await this.keyNetwork.findCoordinator(await blockIdToBytes(bid), { excludedPeers: [] })
604
+ coordinator: await this.keyNetwork.findCoordinator(routingKeyForBlock(bid), { excludedPeers: [] })
486
605
  }))
487
606
  );
488
607
  for (const { blockId, coordinator } of fallbackCoordinators) {
@@ -544,7 +663,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
544
663
  await processBatches(
545
664
  batches,
546
665
  (batch) => this.getRepo(batch.peerId).pend(
547
- { ...blockAction, transforms: batch.payload },
666
+ pendRequestForBatch(blockAction, batch.payload),
548
667
  {
549
668
  expiration,
550
669
  dialTimeoutMs: this.dialTimeoutMs,
@@ -554,12 +673,12 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
554
673
  batch => blockIdsForTransforms(batch.payload),
555
674
  transformForBlock,
556
675
  expiration,
557
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
676
+ (blockId, options) => this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), options)
558
677
  );
559
678
  // Cache resolved coordinators for follow-up commit to hit the same peers
560
679
  try {
561
680
  for (const b of Array.from(allBatches(batches))) {
562
- this.keyNetwork.recordCoordinator?.(await blockIdToBytes(b.blockId), b.peerId);
681
+ this.keyNetwork.recordCoordinator?.(routingKeyForBlock(b.blockId), b.peerId);
563
682
  }
564
683
  } catch (e) { log('WARN: Failed to record coordinator hint %o', e); }
565
684
  } catch (e) {
@@ -662,7 +781,15 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
662
781
  return {
663
782
  success: true,
664
783
  pending: completed.flatMap(b => (b.request!.response! as PendSuccess).pending),
665
- blockIds: blockIdsForTransforms(blockAction.transforms)
784
+ blockIds: blockIdsForTransforms(blockAction.transforms),
785
+ // The weakest coordinator's answer is the action's answer (`mergeDurability`). A pend has
786
+ // no torn case: a batch that did not succeed fails the whole pend above.
787
+ // NOTE: a pend naming NO blocks now throws here (`mergeDurability` refuses an empty input rather
788
+ // than fabricate a class), where it used to answer success. No caller produces one today —
789
+ // `Collection.sync` skips an empty change set, and the multi-collection coordinator pends only
790
+ // collections that appended a log entry. If one ever appears, decide what an empty write's
791
+ // durability means at that caller; do not make the merge invent one.
792
+ durability: mergeDurability(completed.map(b => (b.request!.response! as PendSuccess).durability))
666
793
  };
667
794
  }
668
795
 
@@ -683,8 +810,8 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
683
810
  }
684
811
 
685
812
  async queryClusterNominees(blockId: BlockId): Promise<ClusterNomineesResult> {
686
- const blockIdBytes = await blockIdToBytes(blockId);
687
- const clusterPeers = await this.keyNetwork.findCluster(blockIdBytes);
813
+ const routingKey = routingKeyForBlock(blockId);
814
+ const clusterPeers = await this.keyNetwork.findCluster(routingKey);
688
815
  const nominees = Object.keys(clusterPeers).map(idStr => peerIdFromString(idStr));
689
816
  return { nominees };
690
817
  }
@@ -704,8 +831,25 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
704
831
  // Commit the tail block
705
832
  const tailResult = await this.commitBlock(request.tailId, request.actionId, request.rev, request.tailId, request.blockDigests);
706
833
  if (!tailResult.success) {
834
+ // NOTE: a refused tail is NOT an absent tail. The coordinator's durability gate answers
835
+ // `commit-not-durable` whenever fewer than a majority hold the revision — even though
836
+ // some members stored it — and this return means the sweep below never runs, so every
837
+ // other block of the action is left uncommitted while the log entry may already be
838
+ // readable. This layer cannot tell "tail absent" from "tail present on a minority", so it
839
+ // does not sweep anyway (that would commit data blocks under a tail that may never reach
840
+ // a majority). The writer closes the gap instead: its retry keeps the SAME action id,
841
+ // and if its refresh finds this action's own log entry it re-sends this attempt — same
842
+ // transforms, same revision — to land what was left behind before treating the write as
843
+ // saved (`Collection.completeOwnEntry`). A write is saved only when every block its log
844
+ // entry names holds its revision; the entry alone proves nothing but the tail.
707
845
  return tailResult;
708
846
  }
847
+ // Every coordinator that confirmed part of this action reports who holds its part; the
848
+ // action-level answer is the WEAKEST of them (`mergeDurability`), so a caller reading only the
849
+ // scalar fields reads the binding constraint. The blocks a torn sweep abandons are on nobody,
850
+ // and `torn` names them: they never heal by replication, only by re-driving the action.
851
+ const cohortReports: WriteDurability[] = [tailResult.durability];
852
+ let torn: BlockId[] = [];
709
853
 
710
854
  // Sweep every non-tail block (the header, when the action touches it, lands here too — after
711
855
  // the tail, like any other touched block). The tail is the only exclusion needed.
@@ -719,6 +863,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
719
863
  const remainingBlocks = request.blockIds.filter(bid => bid !== request.tailId);
720
864
  if (remainingBlocks.length > 0) {
721
865
  const { batches, error } = await this.commitBlocks({ blockIds: remainingBlocks, actionId: request.actionId, rev: request.rev, tailId: request.tailId, blockDigests: request.blockDigests });
866
+ cohortReports.push(...confirmedDurabilities(batches));
722
867
  if (error) {
723
868
  // Split by the failure's NATURE, exactly as commitBlock does for the tail: a RETURNED
724
869
  // `success:false` from a cohort coordinator is a confirmed optimistic-concurrency loss
@@ -742,28 +887,33 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
742
887
  // latest.actionId === request.actionId` (StorageRepo.pend, which also skips saving
743
888
  // a pending record for such a block; ClusterMember.validatePendOperations; and
744
889
  // CoordinatorRepo's stale classification). The retry no longer wedges.
745
- // - its retry consumes its own already-committed log entry instead of replaying it
746
- // (Collection.updateInternal's `inFlightActionId` arm, threaded by syncInternal,
747
- // which reuses one actionId across all of a sync's attempts). It no longer appends
748
- // a duplicate entry.
749
- // NOTE: that second half covers the single-collection path only. The multi-collection
750
- // path (TransactionCoordinator's retry loop) still refreshes without telling the
751
- // collection which of its own actions is in flight, so a torn multi-collection commit
752
- // can still record its entry twice tracked as
753
- // tickets/fix/refresh-must-always-know-its-own-in-flight-action, and to be fixed there
754
- // rather than by tolerating the failure here.
890
+ // - its retry recognises its own already-committed log entry instead of replaying it
891
+ // (Collection.updateInternal's `inFlightActionId` arm, set by both write paths —
892
+ // syncInternal and TransactionCoordinator.commitOnce which reuse one action id
893
+ // across all of a write's attempts). It no longer appends a duplicate entry.
894
+ // - and before consuming that entry it FINISHES the action: the blocks this return
895
+ // abandons are on nobody (the caller cancels their pending records next), so the
896
+ // refresh re-sends the refused attempt verbatim at the same revision, which the
897
+ // carve-outs above turn into "land exactly what is missing"
898
+ // (Collection.completeOwnEntry). The entry alone never counts as saved. If a rival
899
+ // has since taken a revision one of those blocks needed, the writer is told so by
900
+ // name (TornActionError) rather than acknowledged.
755
901
  //
756
902
  // Transport-shaped failures (throws, no returned refusal) keep the tolerance for the
757
903
  // RESULT — the tail committed durably, and reporting failure now would disown an
758
904
  // acknowledged write — but NOT for the state the sweep abandoned, which
759
905
  // `cancelAbandonedSweepBlocks` below repairs before this returns.
760
906
  try { log('WARN: non-tail commit had errors; cancelling unconfirmed blocks, proceeding after tail commit: %s', error.message); } catch { /* ignore */ }
761
- await this.cancelAbandonedSweepBlocks(request.actionId, remainingBlocks, batches);
907
+ torn = await this.cancelAbandonedSweepBlocks(request.actionId, remainingBlocks, batches);
762
908
  }
763
909
  }
764
910
 
765
- log('commit:done actionId=%s ms=%d', request.actionId, Date.now() - t0);
766
- return { success: true };
911
+ log('commit:done actionId=%s ms=%d torn=%d', request.actionId, Date.now() - t0, torn.length);
912
+ // `withTornBlocks` clamps the class below `full` when the sweep abandoned anything — a cohort
913
+ // can hold every block it was asked for while the action as a whole is incomplete — and never
914
+ // raises it. `isFullyDurable` then answers false; a consumer that needs to know WHICH blocks
915
+ // reads `torn`.
916
+ return { success: true, durability: withTornBlocks(mergeDurability(cohortReports), torn) };
767
917
  }
768
918
 
769
919
  /**
@@ -795,8 +945,11 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
795
945
  * `debt-unpromotable-pending-records-need-a-sweep`); and this covers only the sweep's abandonment
796
946
  * — `StorageRepo.commit`'s genuine-fault arm deliberately KEEPS a failed batch's pendings for a
797
947
  * retry, so it is a second producer of the same durable state whenever that retry never comes.
948
+ *
949
+ * @returns the abandoned block ids — exactly the set cancelled here — so {@link commit} can name
950
+ * them as `torn` on the acknowledgement without re-deriving the set by a second rule.
798
951
  */
799
- private async cancelAbandonedSweepBlocks(actionId: ActionId, sweptBlocks: BlockId[], batches: CoordinatorBatch<BlockId[], CommitResult>[]): Promise<void> {
952
+ private async cancelAbandonedSweepBlocks(actionId: ActionId, sweptBlocks: BlockId[], batches: CoordinatorBatch<BlockId[], CommitResult>[]): Promise<BlockId[]> {
800
953
  // NOTE: `confirmed` is only ever non-empty when the sweep spans MORE THAN ONE batch, and it
801
954
  // does so only when no single peer covers every swept block — `consolidateCoordinators`'
802
955
  // greedy set cover collapses the pend onto one coordinator otherwise, and commit reuses that
@@ -813,13 +966,14 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
813
966
  }
814
967
  const abandoned = sweptBlocks.filter(bid => !confirmed.has(bid));
815
968
  if (abandoned.length === 0) {
816
- return;
969
+ return abandoned;
817
970
  }
818
971
  try {
819
972
  await this.cancel({ actionId, blockIds: abandoned });
820
973
  } catch (cancelError) {
821
974
  try { log('WARN: cancel of abandoned sweep blocks failed — pending records may wedge until the node-side sweep lands: %o', cancelError); } catch { /* ignore */ }
822
975
  }
976
+ return abandoned;
823
977
  }
824
978
 
825
979
  private async commitBlock(blockId: BlockId, actionId: ActionId, rev: number, tailId?: BlockId, blockDigests?: BlockContentDigests): Promise<CommitResult> {
@@ -836,7 +990,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
836
990
  }
837
991
  throw tailError;
838
992
  }
839
- return { success: true };
993
+ return { success: true, durability: mergeDurability(confirmedDurabilities(tailBatches)) };
840
994
  }
841
995
 
842
996
  /**
@@ -846,21 +1000,26 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
846
1000
  * both must distinguish a confirmed conflict (return it; the caller cancels and re-drives) from
847
1001
  * a transient fault (throw / tolerate).
848
1002
  *
849
- * NOTE: a reason-only StaleFailure (success:false, no `missing`) lands here too and returns
850
- * `{ missing: [], success:false }` the `reason` PROSE is dropped rather than surfaced.
851
- * `staleAt` is carried, so the one machine-readable fact in that prose (which block is at which
852
- * revision) survives; only the free-form wording is lost. If the wording itself is ever needed,
853
- * gate this on non-empty missing rather than reinstating it unconditionally.
1003
+ * Rebuilt the same way {@link pend}'s aggregate is: `reason` is the first one any batch gave
1004
+ * (the only diagnostic that survives into the coordinator's error text a refusal whose reason
1005
+ * is `commit-not-durable` must not read as "stale commit", a rival's win), `conflict` holds when
1006
+ * any batch was a classified conflict, and `staleAt` is the highest confirmed revision. A
1007
+ * reason-only StaleFailure (success:false, no `missing`) lands here too and comes out with
1008
+ * `missing: []` and its reason intact.
854
1009
  */
855
1010
  private staleFromBatches(batches: CoordinatorBatch<BlockId[], CommitResult>[]): StaleFailure | undefined {
856
1011
  const stale = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
857
1012
  if (stale.length === 0) {
858
1013
  return undefined;
859
1014
  }
860
- const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
1015
+ const responses = stale.map(b => b.request!.response! as StaleFailure);
1016
+ const staleAt = highestStaleAt(responses.map(r => r.staleAt));
1017
+ const reason = responses.map(r => r.reason).find(r => r !== undefined);
861
1018
  return {
862
- missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
1019
+ missing: distinctBlockActionTransforms(responses.flatMap(r => r.missing).filter((x): x is ActionTransforms => x !== undefined)),
863
1020
  ...(staleAt === undefined ? {} : { staleAt }),
1021
+ ...(reason === undefined ? {} : { reason }),
1022
+ conflict: responses.some(isConflictFailure),
864
1023
  success: false as const
865
1024
  };
866
1025
  }
@@ -949,7 +1108,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
949
1108
  return cached;
950
1109
  }
951
1110
  }
952
- return this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent });
1111
+ return this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), { ...options, intent });
953
1112
  }
954
1113
 
955
1114
  /**
@@ -1086,7 +1245,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
1086
1245
  batch => batch.payload,
1087
1246
  mergeBlocks,
1088
1247
  deadline,
1089
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
1248
+ (blockId, options) => this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), options)
1090
1249
  );
1091
1250
  } catch (e) {
1092
1251
  lastError = asError(e);
@@ -1141,6 +1300,28 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
1141
1300
  }
1142
1301
 
1143
1302
 
1303
+ /**
1304
+ * One round of `consolidateCoordinators`' greedy cover: the cohort member covering the most still-uncovered
1305
+ * blocks, with the blocks it covers, or undefined when no member covers any.
1306
+ *
1307
+ * A tie goes to `localPeerId` when it is among the tied members, otherwise to the member seen first. Cohorts
1308
+ * arrive in proximity order, so "seen first" is the nearest; without the local preference a node that is in
1309
+ * a block's cohort but not nearest would send its own write over the network, and on a network no wider than
1310
+ * a cohort (every node in every cohort) that would be nearly every write.
1311
+ */
1312
+ function bestCoveringPeer(peerBlocks: Map<string, BlockId[]>, uncovered: Set<BlockId>, localPeerId: string | undefined): { peerId: string; covered: BlockId[] } | undefined {
1313
+ let best: { peerId: string; covered: BlockId[] } | undefined;
1314
+ for (const [peerId, blocks] of peerBlocks) {
1315
+ const covered = blocks.filter(bid => uncovered.has(bid));
1316
+ if (covered.length === 0) continue;
1317
+ const bestCount = best?.covered.length ?? 0;
1318
+ if (covered.length > bestCount || (covered.length === bestCount && peerId === localPeerId)) {
1319
+ best = { peerId, covered };
1320
+ }
1321
+ }
1322
+ return best;
1323
+ }
1324
+
1144
1325
  /**
1145
1326
  * The block ids some batch in the tree got an ANSWER for. A cancel batch that errored, or never
1146
1327
  * responded, discharged nothing — its blocks' pending records are still standing. Batch payloads
@@ -1155,18 +1336,50 @@ function dischargedBlocks(batches: CoordinatorBatch<BlockId[], void>[]): Set<Blo
1155
1336
  return discharged;
1156
1337
  }
1157
1338
 
1339
+ /**
1340
+ * The durability report off every commit batch in the tree that answered `success: true` — the
1341
+ * coordinators' own answers for the blocks they drove, one per confirmed batch. Retry batches
1342
+ * (`subsumedBy`) are included, so a block re-homed after a failed first attempt reports through the
1343
+ * peer that actually committed it. Batches that threw or returned a refusal contribute nothing.
1344
+ */
1345
+ function confirmedDurabilities(batches: CoordinatorBatch<BlockId[], CommitResult>[]): WriteDurability[] {
1346
+ return Array.from(allBatches(batches, b => b.request?.isResponse === true && b.request!.response!.success))
1347
+ .map(b => (b.request!.response! as CommitSuccess).durability);
1348
+ }
1349
+
1350
+ /** The entries of `all` whose ids appear in `batchBlockIds` — the per-batch narrowing both
1351
+ * per-block declarations share ({@link digestsFor}, {@link baseRevsFor}). */
1352
+ function subsetOf<T>(all: Record<BlockId, T>, batchBlockIds: BlockId[]): Record<BlockId, T> {
1353
+ const subset: Record<BlockId, T> = {};
1354
+ for (const id of batchBlockIds) {
1355
+ const entry = all[id];
1356
+ if (entry !== undefined) subset[id] = entry;
1357
+ }
1358
+ return subset;
1359
+ }
1360
+
1158
1361
  /** The subset of `all` whose ids appear in `batchBlockIds`, wrapped (via {@link blockDigestsField})
1159
1362
  * so it spreads to nothing when the batch declares no digests. Called at SEND time, once per attempt,
1160
1363
  * because `processBatches` re-batches failed blocks onto different coordinators — a subset computed
1161
1364
  * up front would follow the wrong batch on retry. */
1162
1365
  function digestsFor(all: BlockContentDigests | undefined, batchBlockIds: BlockId[]): { blockDigests?: BlockContentDigests } {
1163
- if (!all) return {};
1164
- const subset: BlockContentDigests = {};
1165
- for (const id of batchBlockIds) {
1166
- const digest = all[id];
1167
- if (digest !== undefined) subset[id] = digest;
1168
- }
1169
- return blockDigestsField(subset);
1366
+ return all ? blockDigestsField(subsetOf(all, batchBlockIds)) : {};
1367
+ }
1368
+
1369
+ /** The pend-side sibling of {@link digestsFor}: the subset of `all` whose ids appear in
1370
+ * `batchBlockIds`, wrapped (via {@link baseRevsField}) so it spreads to nothing when no block in the
1371
+ * batch names a base. Same send-time rule, for the same reason. */
1372
+ function baseRevsFor(all: BlockBaseRevs | undefined, batchBlockIds: BlockId[]): { baseRevs?: BlockBaseRevs } {
1373
+ return all ? baseRevsField(subsetOf(all, batchBlockIds)) : {};
1374
+ }
1375
+
1376
+ /** `action` re-scoped to one coordinator batch: the batch's own transforms, and the bases for the
1377
+ * blocks in it alone (see {@link PendRequest.baseRevs}). The action-wide `baseRevs` key is dropped
1378
+ * BEFORE the subset is spread, so a batch none of whose blocks names a base carries no key at all
1379
+ * rather than the whole action's map — and no cohort signs for a block it is not responsible for. */
1380
+ function pendRequestForBatch(action: PendRequest, payload: Transforms): PendRequest {
1381
+ const { baseRevs, ...rest } = action;
1382
+ return { ...rest, transforms: payload, ...baseRevsFor(baseRevs, blockIdsForTransforms(payload)) };
1170
1383
  }
1171
1384
 
1172
1385
  /**