@optimystic/db-p2p 0.25.1 → 0.27.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 (107) hide show
  1. package/dist/src/cluster/certified-claims.d.ts +17 -3
  2. package/dist/src/cluster/certified-claims.d.ts.map +1 -1
  3. package/dist/src/cluster/certified-claims.js +5 -3
  4. package/dist/src/cluster/certified-claims.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +57 -92
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +149 -201
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/commit-proof.d.ts +16 -0
  10. package/dist/src/cluster/commit-proof.d.ts.map +1 -1
  11. package/dist/src/cluster/commit-proof.js +32 -1
  12. package/dist/src/cluster/commit-proof.js.map +1 -1
  13. package/dist/src/cluster/quorum-restore.d.ts +81 -28
  14. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  15. package/dist/src/cluster/quorum-restore.js +148 -51
  16. package/dist/src/cluster/quorum-restore.js.map +1 -1
  17. package/dist/src/cluster/race-resolution.d.ts +89 -0
  18. package/dist/src/cluster/race-resolution.d.ts.map +1 -0
  19. package/dist/src/cluster/race-resolution.js +140 -0
  20. package/dist/src/cluster/race-resolution.js.map +1 -0
  21. package/dist/src/cluster/reconcile-block.d.ts +9 -4
  22. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  23. package/dist/src/cluster/reconcile-block.js +28 -11
  24. package/dist/src/cluster/reconcile-block.js.map +1 -1
  25. package/dist/src/cluster/record-operations.d.ts +25 -0
  26. package/dist/src/cluster/record-operations.d.ts.map +1 -0
  27. package/dist/src/cluster/record-operations.js +56 -0
  28. package/dist/src/cluster/record-operations.js.map +1 -0
  29. package/dist/src/cohort-topic/host.d.ts +133 -51
  30. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  31. package/dist/src/cohort-topic/host.js +290 -83
  32. package/dist/src/cohort-topic/host.js.map +1 -1
  33. package/dist/src/libp2p-node-base.d.ts +13 -4
  34. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  35. package/dist/src/libp2p-node-base.js +5 -5
  36. package/dist/src/libp2p-node-base.js.map +1 -1
  37. package/dist/src/repo/cluster-coordinator.d.ts +9 -0
  38. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  39. package/dist/src/repo/cluster-coordinator.js +13 -2
  40. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  41. package/dist/src/repo/coordinator-repo.d.ts +34 -2
  42. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  43. package/dist/src/repo/coordinator-repo.js +57 -3
  44. package/dist/src/repo/coordinator-repo.js.map +1 -1
  45. package/dist/src/storage/block-latch.d.ts +5 -4
  46. package/dist/src/storage/block-latch.d.ts.map +1 -1
  47. package/dist/src/storage/block-latch.js +5 -4
  48. package/dist/src/storage/block-latch.js.map +1 -1
  49. package/dist/src/storage/block-storage.d.ts +1 -1
  50. package/dist/src/storage/block-storage.d.ts.map +1 -1
  51. package/dist/src/storage/block-storage.js +11 -3
  52. package/dist/src/storage/block-storage.js.map +1 -1
  53. package/dist/src/storage/cached-raw-storage.d.ts +17 -1
  54. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  55. package/dist/src/storage/cached-raw-storage.js +8 -1
  56. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  57. package/dist/src/storage/cached-store-driver.d.ts +7 -0
  58. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  59. package/dist/src/storage/cached-store-driver.js +20 -0
  60. package/dist/src/storage/cached-store-driver.js.map +1 -1
  61. package/dist/src/storage/i-block-storage.d.ts +45 -2
  62. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  63. package/dist/src/storage/i-block-storage.js +29 -0
  64. package/dist/src/storage/i-block-storage.js.map +1 -1
  65. package/dist/src/storage/i-raw-storage.d.ts +16 -0
  66. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  67. package/dist/src/storage/kv-raw-storage.d.ts +5 -1
  68. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  69. package/dist/src/storage/kv-raw-storage.js +8 -1
  70. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  71. package/dist/src/storage/raw-store-driver.d.ts +15 -0
  72. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  73. package/dist/src/storage/shared-cache-pool.d.ts +5 -0
  74. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  75. package/dist/src/storage/shared-cache-pool.js +6 -1
  76. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  77. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  78. package/dist/src/storage/storage-repo.js +141 -99
  79. package/dist/src/storage/storage-repo.js.map +1 -1
  80. package/dist/src/storage/with-read-cache.d.ts +9 -5
  81. package/dist/src/storage/with-read-cache.d.ts.map +1 -1
  82. package/dist/src/storage/with-read-cache.js +16 -6
  83. package/dist/src/storage/with-read-cache.js.map +1 -1
  84. package/package.json +2 -2
  85. package/{README.md → readme.md} +2 -2
  86. package/src/cluster/certified-claims.ts +22 -9
  87. package/src/cluster/cluster-repo.ts +199 -212
  88. package/src/cluster/commit-proof.ts +38 -2
  89. package/src/cluster/quorum-restore.ts +183 -56
  90. package/src/cluster/race-resolution.ts +158 -0
  91. package/src/cluster/reconcile-block.ts +34 -11
  92. package/src/cluster/record-operations.ts +59 -0
  93. package/src/cohort-topic/host.ts +423 -118
  94. package/src/libp2p-node-base.ts +18 -9
  95. package/src/repo/cluster-coordinator.ts +1039 -1027
  96. package/src/repo/coordinator-repo.ts +1937 -1855
  97. package/src/storage/block-latch.ts +5 -4
  98. package/src/storage/block-storage.ts +11 -3
  99. package/src/storage/cached-raw-storage.ts +21 -1
  100. package/src/storage/cached-store-driver.ts +23 -0
  101. package/src/storage/i-block-storage.ts +47 -2
  102. package/src/storage/i-raw-storage.ts +17 -0
  103. package/src/storage/kv-raw-storage.ts +8 -1
  104. package/src/storage/raw-store-driver.ts +26 -0
  105. package/src/storage/shared-cache-pool.ts +6 -1
  106. package/src/storage/storage-repo.ts +143 -103
  107. package/src/storage/with-read-cache.ts +16 -6
@@ -16,8 +16,13 @@ import type { BlockCommitProof } from "./commit-proof.js";
16
16
  * caller has already verified (`cluster/certified-claims.ts`) and marked via the
17
17
  * injected `certified` flag — so a lone honest holder with a valid proof is
18
18
  * sufficient where uncertified claims still need distinct-peer corroboration.
19
- * Verification itself never happens here: both selectors stay pure and
20
- * synchronous; verdicts arrive as booleans.
19
+ * Certified claims are additionally weighed by the proof's SIGNER COUNT
20
+ * (`certifiedSignerCount`, injected alongside the flag): a single-signer proof —
21
+ * routine since solo cohorts mint their own commit receipts — is one machine's
22
+ * honest word about its own commit, so it wins only where nothing multi-peer
23
+ * contests it, and never outranks several distinct peers agreeing at the same
24
+ * revision. Verification itself never happens here: both selectors stay pure and
25
+ * synchronous; verdicts arrive as booleans and counts.
21
26
  */
22
27
 
23
28
  /** A single peer's self-reported (rev, actionId) for a block. */
@@ -41,6 +46,16 @@ export interface RevClaim {
41
46
  * Never set this from the mere presence of {@link proof}.
42
47
  */
43
48
  certified?: boolean;
49
+ /**
50
+ * Distinct signers in the verified proof, injected by the caller from the certification verdict
51
+ * (`ClaimCertification.signerCount`, `cluster/certified-claims.ts`) — never read off
52
+ * {@link proof} here. Meaningful only when {@link certified} is set. Selection weighs a certified
53
+ * claim as SINGLE-SIGNER when this is absent or below two: the conservative direction, since a
54
+ * single-signer proof (a solo cohort's self-signed commit receipt) must never outrank multi-peer
55
+ * agreement, and a caller that forgot to plumb the count should degrade toward corroboration,
56
+ * not toward trusting one signature.
57
+ */
58
+ certifiedSignerCount?: number;
44
59
  }
45
60
 
46
61
  /** The (rev, actionId) pair a quorum agreed on, plus the peers that corroborated it. */
@@ -136,18 +151,26 @@ export function corroboratorCapacity(cohortPeerCount: number, repairCorroboratio
136
151
  *
137
152
  * **Certified claims** (`certified === true`, injected by a caller that verified the claim's
138
153
  * cohort commit proof — see `cluster/certified-claims.ts`) short-circuit the distinct-peer rule,
139
- * because the proof's signature set IS the corroboration:
154
+ * because the proof's signature set IS the corroboration — weighed by how many signers that set
155
+ * actually holds ({@link RevClaim.certifiedSignerCount}):
140
156
  *
141
157
  * - No certified claims → today's corroboration result, unchanged.
142
158
  * - A corroborated pair at a HIGHER rev than every certified claim wins — corroboration stays a
143
159
  * legitimate weaker path, so a legacy uncertified tail written after the last proven rev
144
160
  * remains readable.
145
- * - Otherwise the highest certified rev wins: this covers a certified rev beaten in raw rev only
146
- * by an *uncorroborated* claim (which failed quorum and is no evidence), and the equal-rev
147
- * tie, where the proof outweighs votes.
148
- * - Except: two distinct `actionId`s certified at that top rev is equivocation the cohort
149
- * provably signed two different actions into one revision and the whole selection declines
150
- * (`undefined`). Callers distinguish this decline from a plain no-quorum via
161
+ * - A MULTI-SIGNER certified claim (two-plus signers) at the top certified rev wins over an
162
+ * equal-rev corroborated pair and over any uncorroborated claim: the proof outweighs votes.
163
+ * Single-signer certified claims at that rev neither outrank it nor equivocate against it — a
164
+ * solo cohort's self-signed receipt is one machine's word, not the cohort's second signature.
165
+ * - A SINGLE-SIGNER-only certified rev still wins where nothing multi-peer contests it (a lone
166
+ * holder with a solo proof stays repairable), but it never beats corroboration by
167
+ * {@link CORROBORATION_FLOOR}-plus distinct peers at the SAME revision — one machine that was
168
+ * briefly alone must not outrank the cohort that stayed together. A corroborated pair that
169
+ * AGREES with the sole certified action is convergence, not a contest; the certified verdict
170
+ * is kept.
171
+ * - Two distinct `actionId`s still CONTENDING at the top certified rev is equivocation — the
172
+ * same keys provably signed two different actions into one revision — and the whole selection
173
+ * declines (`undefined`). Callers distinguish this decline from a plain no-quorum via
151
174
  * {@link certifiedEquivocation}.
152
175
  */
153
176
  export function selectQuorumRev(
@@ -195,43 +218,87 @@ export function selectQuorumRev(
195
218
  // must stay readable. A merely UNcorroborated higher rev never reaches here (it is not in
196
219
  // `corroborated`), so it cannot outrank a proof.
197
220
  if (corroborated && corroborated.rev > certified.rev) return corroborated;
198
- // Two actions provably signed into the same top revision: decline the whole selection rather
199
- // than pick a side. Callers log via certifiedEquivocation.
200
- if (certified.byAction.size !== 1) return undefined;
201
- const [entry] = certified.byAction;
202
- const [actionId, supporters] = entry!; // size === 1 checked above
203
- return { rev: certified.rev, actionId, supporters: [...supporters], certified: true };
221
+ const contenders = certifiedContenders(certified.byAction);
222
+ // A single-signer-only certified rev loses the equal-rev tie to genuinely multi-peer
223
+ // corroboration — unless the corroborated pair AGREES with the sole certified action, which is
224
+ // convergence rather than a contest and keeps the certified verdict (and its logging/penalty
225
+ // exemptions) intact.
226
+ // NOTE: the weighing reaches EQUAL revisions only. A solo fork that committed twice while its
227
+ // cohort committed once claims the higher rev and still wins above. That is not an oversight of
228
+ // this rule: a claim carries no lineage, so "one peer ahead of two" is identical whether it
229
+ // forked or the two are lagging, and refusing it would break ordinary lagging-cohort repair.
230
+ // Pinned by 'does NOT reach a solo fork one revision AHEAD' in quorum-restore-certified.spec.ts;
231
+ // closing it needs new evidence — debt-repair-cannot-tell-a-fork-from-a-lagging-cohort.
232
+ if (!contenders.multiSigner && corroborated && corroborated.rev === certified.rev
233
+ && corroborated.supporters.length >= CORROBORATION_FLOOR
234
+ && !(certified.byAction.size === 1 && certified.byAction.has(corroborated.actionId))) {
235
+ return corroborated;
236
+ }
237
+ // Two actions provably signed into the same top revision by contending proofs: decline the
238
+ // whole selection rather than pick a side. Callers log via certifiedEquivocation.
239
+ if (contenders.entries.length !== 1) return undefined;
240
+ const [actionId, group] = contenders.entries[0]!;
241
+ return { rev: certified.rev, actionId, supporters: [...group.supporters], certified: true };
204
242
  }
205
243
 
206
- /** Certified claims at the top certified rev, keyed by actionId distinct certified claimants. */
207
- function certifiedGroups(claims: RevClaim[]): { rev: number; byAction: Map<string, Set<string>> } | undefined {
244
+ /** Distinct certified claimants of one action at the top certified rev, and whether any of their
245
+ * proofs carried two-plus signers. */
246
+ interface CertifiedActionGroup {
247
+ supporters: Set<string>;
248
+ /** True when at least one certified claim for this action had `certifiedSignerCount >= 2`. */
249
+ multiSigner: boolean;
250
+ }
251
+
252
+ /** Certified claims at the top certified rev, keyed by actionId → the claimants behind each. */
253
+ function certifiedGroups(claims: RevClaim[]): { rev: number; byAction: Map<string, CertifiedActionGroup> } | undefined {
208
254
  let top: number | undefined;
209
255
  for (const c of claims) {
210
256
  if (c.certified === true && (top === undefined || c.rev > top)) top = c.rev;
211
257
  }
212
258
  if (top === undefined) return undefined;
213
- const byAction = new Map<string, Set<string>>();
259
+ const byAction = new Map<string, CertifiedActionGroup>();
214
260
  for (const c of claims) {
215
261
  if (c.certified !== true || c.rev !== top) continue;
216
- let s = byAction.get(c.actionId);
217
- if (!s) {
218
- s = new Set();
219
- byAction.set(c.actionId, s);
262
+ let g = byAction.get(c.actionId);
263
+ if (!g) {
264
+ g = { supporters: new Set(), multiSigner: false };
265
+ byAction.set(c.actionId, g);
220
266
  }
221
- s.add(c.peerId);
267
+ g.supporters.add(c.peerId);
268
+ // Absent or sub-two count weighs as single-signer — see RevClaim.certifiedSignerCount.
269
+ if ((c.certifiedSignerCount ?? 1) >= 2) g.multiSigner = true;
222
270
  }
223
271
  return { rev: top, byAction };
224
272
  }
225
273
 
274
+ /**
275
+ * Which certified actions at the top rev actually CONTEND for selection. A multi-signer proof is
276
+ * the cohort's own signature set; a single-signer proof is one machine's word about its own solo
277
+ * commit — so when any action is multi-signer-backed, only the multi-signer-backed ones contend
278
+ * (a solo receipt can neither outrank nor equivocate against a cohort proof), and only with no
279
+ * multi-signer action anywhere do the single-signer ones contend among themselves. One helper so
280
+ * {@link selectQuorumRev} and {@link certifiedEquivocation} cannot drift on the rule.
281
+ */
282
+ function certifiedContenders(
283
+ byAction: Map<string, CertifiedActionGroup>
284
+ ): { entries: [string, CertifiedActionGroup][]; multiSigner: boolean } {
285
+ const multi = [...byAction].filter(([, g]) => g.multiSigner);
286
+ return multi.length > 0
287
+ ? { entries: multi, multiSigner: true }
288
+ : { entries: [...byAction], multiSigner: false };
289
+ }
290
+
226
291
  /**
227
292
  * The conflicting certified set at the top certified rev, when there is one: two-plus distinct
228
- * `actionId`s each carrying a verified cohort commit proof for the SAME revision. It deserves a
229
- * distinct log line from a plain no-quorum the cohort (or whoever holds its keys) provably
230
- * signed both sides. Selection stays pure, so callers do the logging with what this reports.
293
+ * `actionId`s that CONTEND there ({@link certifiedContenders}) each carrying a verified cohort
294
+ * commit proof for the SAME revision, at comparable weight. It deserves a distinct log line from a
295
+ * plain no-quorum whoever holds the signing keys provably signed both sides. Selection stays
296
+ * pure, so callers do the logging with what this reports.
231
297
  *
232
- * `undefined` when no certified claim exists or the top certified rev names a single action
233
- * conflicts at LOWER certified revs are history already superseded, not equivocation worth
234
- * declining over.
298
+ * `undefined` when no certified claim exists or a single action contends at the top certified rev —
299
+ * conflicts at LOWER certified revs are history already superseded, and a single-signer proof
300
+ * disagreeing with a multi-signer one at the same rev is a solo machine's fork losing to the
301
+ * cohort, not equivocation worth declining over.
235
302
  *
236
303
  * **Ask this only on a decline.** A non-`undefined` result does NOT imply {@link selectQuorumRev}
237
304
  * returned `undefined`: a corroborated pair STRICTLY above the top certified rev still wins, and
@@ -240,8 +307,10 @@ function certifiedGroups(claims: RevClaim[]): { rev: number; byAction: Map<strin
240
307
  */
241
308
  export function certifiedEquivocation(claims: RevClaim[]): { rev: number; actionIds: string[] } | undefined {
242
309
  const groups = certifiedGroups(claims);
243
- if (!groups || groups.byAction.size < 2) return undefined;
244
- return { rev: groups.rev, actionIds: [...groups.byAction.keys()] };
310
+ if (!groups) return undefined;
311
+ const contenders = certifiedContenders(groups.byAction);
312
+ if (contenders.entries.length < 2) return undefined;
313
+ return { rev: groups.rev, actionIds: contenders.entries.map(([actionId]) => actionId) };
245
314
  }
246
315
 
247
316
  /** One block candidate paired with its serving peer and canonical hash
@@ -256,6 +325,13 @@ export interface BlockHashCandidate {
256
325
  * bytes), so the cohort's signatures stand in for other peers serving the same hash.
257
326
  */
258
327
  certified?: boolean;
328
+ /**
329
+ * Distinct signers in the verified proof, from the certification verdict
330
+ * (`ContentCertification.signerCount`) — the content-side sibling of
331
+ * {@link RevClaim.certifiedSignerCount}, with the same weighing: meaningful only when
332
+ * {@link certified} is set, and absent or sub-two counts as single-signer.
333
+ */
334
+ certifiedSignerCount?: number;
259
335
  }
260
336
 
261
337
  /**
@@ -279,11 +355,23 @@ export interface BlockHashCandidate {
279
355
  * *genuinely* that small reaches this branch.
280
356
  *
281
357
  * **Certified candidates** (`certified === true` — a caller verified a cohort commit proof whose
282
- * declared digest matches these exact bytes) short-circuit the hash quorum: exactly one distinct
283
- * certified hash → that block wins outright, however many peers served it. Two-plus distinct
284
- * certified hashes is certified content equivocation → decline (`undefined`), mirroring the
285
- * existing unique-hash-tie decline; callers name that decline via
286
- * {@link certifiedContentEquivocation}. No certified candidate the hash quorum below, unchanged.
358
+ * declared digest matches these exact bytes) are weighed by the proof's signer count, mirroring
359
+ * {@link selectQuorumRev}:
360
+ *
361
+ * - Exactly one distinct MULTI-SIGNER certified hash that block wins outright, however many
362
+ * peers served anything else: the cohort's own signatures over the digest outweigh other
363
+ * carriers' bytes. Two-plus distinct multi-signer certified hashes is certified content
364
+ * equivocation → decline (`undefined`); a single-signer certified hash alongside a
365
+ * multi-signer one neither wins nor forces that decline (a solo receipt cannot equivocate
366
+ * against the cohort's proof).
367
+ * - SINGLE-SIGNER-only certified hashes no longer short-circuit: a hash group carried by
368
+ * {@link CORROBORATION_FLOOR}-plus distinct peers that meets the ordinary quorum displaces
369
+ * them (two-plus such groups is a genuine content disagreement → decline, as ever). With no
370
+ * multi-peer group to defer to, a sole single-signer certified hash still wins on its proof —
371
+ * a lone certified holder stays repairable — and two-plus distinct ones decline.
372
+ * - No certified candidate → the hash quorum below, unchanged.
373
+ *
374
+ * Callers name the certified declines via {@link certifiedContentEquivocation}.
287
375
  */
288
376
  export function selectQuorumBlock(
289
377
  candidates: BlockHashCandidate[],
@@ -293,12 +381,12 @@ export function selectQuorumBlock(
293
381
  if (candidates.length === 0) return undefined;
294
382
 
295
383
  const certifiedByHash = certifiedHashes(candidates);
296
- if (certifiedByHash.size === 1) {
297
- const [entry] = certifiedByHash;
298
- const [hash, block] = entry!;
384
+ const multiSignerHashes = [...certifiedByHash].filter(([, v]) => v.multiSigner);
385
+ if (multiSignerHashes.length === 1) {
386
+ const [hash, { block }] = multiSignerHashes[0]!;
299
387
  return { block, hash };
300
388
  }
301
- if (certifiedByHash.size > 1) return undefined; // certified content equivocation — decline
389
+ if (multiSignerHashes.length > 1) return undefined; // certified content equivocation — decline
302
390
 
303
391
  // One vote per distinct peer per hash group, matching selectQuorumRev — a peer appearing twice
304
392
  // must not be able to second itself into a content quorum.
@@ -316,33 +404,72 @@ export function selectQuorumBlock(
316
404
 
317
405
  const quorum = quorumSize(voters.size, simpleMajorityThreshold, corroboratorCapacity);
318
406
  const meeting = [...groups.entries()].filter(([, g]) => g.supporters.size >= quorum);
319
- // Exactly one hash may meet quorum; a tie is a genuine content disagreement → decline.
320
- if (meeting.length !== 1) return undefined;
321
- const [hash, group] = meeting[0]!;
322
- return { block: group.block, hash };
407
+
408
+ if (certifiedByHash.size === 0) {
409
+ // Exactly one hash may meet quorum; a tie is a genuine content disagreement → decline.
410
+ if (meeting.length !== 1) return undefined;
411
+ const [hash, group] = meeting[0]!;
412
+ return { block: group.block, hash };
413
+ }
414
+
415
+ // Only single-signer certified hashes remain. Genuinely multi-peer agreement — a quorum-meeting
416
+ // group of CORROBORATION_FLOOR-plus distinct carriers — outranks a solo proof, with the usual
417
+ // uniqueness rule among such groups. (A quorum-meeting group of ONE carrier, possible only on a
418
+ // capacity-one cohort, is a bare assertion and does not displace a verified proof.)
419
+ const multiPeer = meeting.filter(([, g]) => g.supporters.size >= CORROBORATION_FLOOR);
420
+ if (multiPeer.length > 1) return undefined;
421
+ if (multiPeer.length === 1) {
422
+ const [hash, group] = multiPeer[0]!;
423
+ return { block: group.block, hash };
424
+ }
425
+ // No multi-peer agreement to defer to: a sole single-signer certified hash wins on its proof;
426
+ // two-plus distinct ones are solo-vs-solo equivocation → decline.
427
+ if (certifiedByHash.size > 1) return undefined;
428
+ const [entry] = certifiedByHash;
429
+ const [hash, { block }] = entry!;
430
+ return { block, hash };
323
431
  }
324
432
 
325
- /** Distinct hashes carried by certified candidates → the first block instance serving each. */
326
- function certifiedHashes(candidates: BlockHashCandidate[]): Map<string, IBlock> {
327
- const byHash = new Map<string, IBlock>();
433
+ /** Distinct hashes carried by certified candidates → the first block instance serving each, and
434
+ * whether any certified candidate at that hash carried a two-plus-signer proof. */
435
+ function certifiedHashes(candidates: BlockHashCandidate[]): Map<string, { block: IBlock; multiSigner: boolean }> {
436
+ const byHash = new Map<string, { block: IBlock; multiSigner: boolean }>();
328
437
  for (const c of candidates) {
329
- if (c.certified === true && !byHash.has(c.hash)) byHash.set(c.hash, c.block);
438
+ if (c.certified !== true) continue;
439
+ let entry = byHash.get(c.hash);
440
+ if (!entry) {
441
+ entry = { block: c.block, multiSigner: false };
442
+ byHash.set(c.hash, entry);
443
+ }
444
+ // Absent or sub-two count weighs as single-signer — see BlockHashCandidate.certifiedSignerCount.
445
+ if ((c.certifiedSignerCount ?? 1) >= 2) entry.multiSigner = true;
330
446
  }
331
447
  return byHash;
332
448
  }
333
449
 
334
450
  /**
335
- * The conflicting certified hashes when there are two or more — the content-side sibling of
336
- * {@link certifiedEquivocation}, and the reason {@link selectQuorumBlock} declines outright.
337
- * Candidates reaching that selector all carry the SAME `(rev, actionId)`, so two certified hashes
338
- * mean the cohort's keys signed two different digests into one revision: a provable compromise an
339
- * operator must be able to tell apart from the routine "not enough carriers agreed" decline. Both
340
- * declines return `undefined`, so without this they log identically.
451
+ * The conflicting certified hashes when there are two or more CONTENDING — the content-side
452
+ * sibling of {@link certifiedEquivocation}, and the reason {@link selectQuorumBlock} declines
453
+ * outright. Candidates reaching that selector all carry the SAME `(rev, actionId)`, so two
454
+ * contending certified hashes mean the same keys signed two different digests into one revision: a
455
+ * provable compromise an operator must be able to tell apart from the routine "not enough carriers
456
+ * agreed" decline. Both declines return `undefined`, so without this they log identically.
457
+ * Contention mirrors the selector: multi-signer certified hashes when any exist, else the
458
+ * single-signer ones among themselves — a solo receipt disagreeing with a cohort proof is a fork
459
+ * losing, not equivocation.
341
460
  *
342
- * `undefined` when fewer than two distinct certified hashes exist — including the ordinary
461
+ * `undefined` when fewer than two certified hashes contend — including the ordinary
343
462
  * no-certified-candidate case, where a decline really is a plain content-quorum shortfall.
463
+ *
464
+ * **Ask this only on a decline**, exactly as with {@link certifiedEquivocation}: a non-`undefined`
465
+ * result does NOT imply {@link selectQuorumBlock} returned `undefined`. Two contending
466
+ * single-signer certified hashes yield to a {@link CORROBORATION_FLOOR}-plus-carrier quorum group,
467
+ * so selection can succeed while the solo-versus-solo conflict is still reported here. Call it when
468
+ * selection declined, to say WHY.
344
469
  */
345
470
  export function certifiedContentEquivocation(candidates: BlockHashCandidate[]): { hashes: string[] } | undefined {
346
471
  const byHash = certifiedHashes(candidates);
347
- return byHash.size < 2 ? undefined : { hashes: [...byHash.keys()] };
472
+ const multi = [...byHash].filter(([, v]) => v.multiSigner);
473
+ const contenders = multi.length > 0 ? multi : [...byHash];
474
+ return contenders.length < 2 ? undefined : { hashes: contenders.map(([hash]) => hash) };
348
475
  }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The deterministic arbiter between two conflicting cluster transactions: which of two writes to a
3
+ * shared block wins. Every function here is a total function of its arguments, with no member state
4
+ * and no effect beyond a debug log — `resolveRace` runs on the vote path, where a throw would cost
5
+ * the member its vote entirely — so the ordering rule can be read and tested on its own (see
6
+ * `test/race-resolution.spec.ts`). The stateful scan that consults them (`findConflict`, which sweeps stale
7
+ * reservations and clears a losing transaction) stays on `ClusterMember` in `cluster-repo.ts`.
8
+ */
9
+ import type { ClusterRecord, RepoMessage } from "@optimystic/db-core";
10
+ import { clampPriority } from "@optimystic/db-core";
11
+ import { getActionId, getAffectedBlockIds } from "./record-operations.js";
12
+ import { createLogger } from "../logger.js";
13
+
14
+ // Same sub-namespace `cluster-repo.ts` logs under: these functions moved out of `ClusterMember`, and
15
+ // their emitted tags (and the `debug` namespace they land in) must stay byte-identical, because
16
+ // several specs capture by namespace and tag substring.
17
+ const log = createLogger('cluster-member')
18
+
19
+ /** Number of *approve* promise votes on a record — the count the commit rule uses. */
20
+ export function approvalCount(record: ClusterRecord): number {
21
+ return Object.values(record.promises).filter(s => s.type === 'approve').length;
22
+ }
23
+
24
+ /**
25
+ * Resolve a race between two conflicting transactions. Total and deterministic, so every honest
26
+ * member computes the identical winner (the Theorem 1 Case-2 premise). Order:
27
+ * 1. more *approve* promise signatures wins (progress monotonicity — see safety note below);
28
+ * 2. equal approval counts → higher aged priority wins (fairness — see {@link recordPriority});
29
+ * 3. still tied → higher message hash wins.
30
+ *
31
+ * The count is APPROVALS, not `promises` keys. `promises` is the vote map — a reject occupies a key
32
+ * there exactly as an approve does — so counting keys would treat a rejection as progress, letting a
33
+ * record that can never commit outrank (and therefore block, via the reservation scan `findConflict`
34
+ * in `cluster-repo.ts`) a fresh rival for the whole staleness window. Approvals is also the count
35
+ * the invariant below actually needs: the commit rule is `approvedPromises >= superMajority`,
36
+ * which never looks at rejections.
37
+ *
38
+ * Approval count is FIRST so this comparison never displaces a transaction that is further along.
39
+ * That restores the pre-priority safety invariant: a member commits purely on promise supermajority
40
+ * (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
41
+ * conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
42
+ * With approvals-first, once transaction X holds a promise supermajority every conflicting rival Y has
43
+ * strictly fewer approvals — Y can only match X's count by getting the intersecting quorum member to
44
+ * approve it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
45
+ * `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
46
+ * overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
47
+ * Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
48
+ * higher-priority Y with fewer approvals, letting BOTH commit (split brain) — the regression fixed by
49
+ * ticket occ-priority-first-breaks-promise-monotonicity.
50
+ *
51
+ * Priority is now a tie-break that runs only at EQUAL approval counts, which is exactly the
52
+ * concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
53
+ * on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
54
+ * fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
55
+ * defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
56
+ * is the deferred feat-occ-priority-reservation).
57
+ *
58
+ * NOTE: residual-fairness tripwire. Under approvals-first an aged transaction can still lose to a fresh
59
+ * rival that has *legitimately* gathered even one more approval — that is not the pure-coin-flip
60
+ * starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
61
+ * more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
62
+ * rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
63
+ * to this race tie-break.
64
+ *
65
+ * NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
66
+ * priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
67
+ * exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
68
+ * below the approval count, so it can only break equal-count ties it might have ~50% won anyway,
69
+ * degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
70
+ * honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
71
+ *
72
+ * NOTE: keep priority a self-contained additive message field + this one comparison key so it
73
+ * composes with — does not block — a future HLC/crdt-sync redesign of this same path
74
+ * (design-hot-log-tail-sharding-guidance).
75
+ */
76
+ export function resolveRace(existing: ClusterRecord, incoming: ClusterRecord): 'keep-existing' | 'accept-incoming' {
77
+ // 1. Transaction with more APPROVALS wins — never displace a more-progressed rival (safety, see
78
+ // above). Counting `promises` keys instead would count reject votes as progress: a record holding
79
+ // one rejection would outrank an untouched rival and reserve its blocks for the whole staleness
80
+ // window, and the commit rule this ordering protects (`approvedPromises >= superMajority`) never
81
+ // looks at rejections anyway.
82
+ const existingCount = approvalCount(existing);
83
+ const incomingCount = approvalCount(incoming);
84
+ if (existingCount !== incomingCount) {
85
+ return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
86
+ }
87
+
88
+ // 2. Equal approval counts → higher aged priority wins (fairness tie-break).
89
+ const existingPriority = recordPriority(existing);
90
+ const incomingPriority = recordPriority(incoming);
91
+ if (existingPriority !== incomingPriority) {
92
+ return existingPriority > incomingPriority ? 'keep-existing' : 'accept-incoming';
93
+ }
94
+
95
+ // 3. Tie-breaker: higher message hash wins (deterministic).
96
+ return existing.messageHash > incoming.messageHash ? 'keep-existing' : 'accept-incoming';
97
+ }
98
+
99
+ /**
100
+ * Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
101
+ * The multi-collection path carries it on `pend.validation.transaction.priority`; the single-collection
102
+ * (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
103
+ * legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
104
+ * (backward compatible: such transactions simply never age). Both carriers live inside the signed
105
+ * `message`, so priority is integrity-protected in transit; clamping here bounds a self-asserted
106
+ * out-of-range value to the cap.
107
+ *
108
+ * NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
109
+ * separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
110
+ * the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
111
+ * consulted at the promise decision (`findConflict` in `cluster-repo.ts`), i.e. between two
112
+ * still-open conflicting transactions, which is exactly the concurrent-contention case priority
113
+ * is meant to order.
114
+ */
115
+ export function recordPriority(record: ClusterRecord): number {
116
+ for (const op of record.message.operations) {
117
+ if ('pend' in op) {
118
+ // Every hop optional: `validation` arrives off the wire inside a signed message whose
119
+ // hash binds its bytes, not its shape, so a malformed pair must yield priority 0 (what
120
+ // clampPriority already does for a missing or Byzantine number) rather than throw out
121
+ // of the vote path — the lost vote this fail-closed pass exists to prevent.
122
+ return clampPriority(op.pend.validation?.transaction?.priority ?? op.pend.priority);
123
+ }
124
+ }
125
+ return 0;
126
+ }
127
+
128
+ /**
129
+ * Whether two messages must serialize against each other: true when they touch a common block AND
130
+ * are not the same action. The same-action escape is what lets a commit follow its own pend — both
131
+ * name every block the action writes, so a bare overlap test would have each transaction blocking
132
+ * its own next phase. Gates whether {@link resolveRace} runs at all.
133
+ */
134
+ export function operationsConflict(ops1: RepoMessage['operations'], ops2: RepoMessage['operations']): boolean {
135
+ // Check if one is a commit for the same action as a pend - these don't conflict
136
+ const actionId1 = getActionId(ops1);
137
+ const actionId2 = getActionId(ops2);
138
+ if (actionId1 && actionId2 && actionId1 === actionId2) {
139
+ // Same action - commit is resolving the pend, not conflicting
140
+ return false;
141
+ }
142
+
143
+ const blocks1 = new Set(getAffectedBlockIds(ops1));
144
+ const blocks2 = new Set(getAffectedBlockIds(ops2));
145
+
146
+ for (const block of Array.from(blocks1)) {
147
+ if (blocks2.has(block)) {
148
+ log('cluster-member:conflict-detected', {
149
+ blocks1: Array.from(blocks1),
150
+ blocks2: Array.from(blocks2),
151
+ conflictingBlock: block
152
+ });
153
+ return true;
154
+ }
155
+ }
156
+
157
+ return false;
158
+ }
@@ -45,6 +45,12 @@ interface ReconcileCandidate {
45
45
  revCertified?: boolean;
46
46
  /** Injected by the certification pass: the proof's declared digest matches these exact bytes. */
47
47
  contentCertified?: boolean;
48
+ /**
49
+ * Injected by the certification pass alongside {@link revCertified}: the verified proof's
50
+ * distinct signer count, from the certification verdict. The selectors weigh a single-signer
51
+ * certification below multi-peer agreement (`quorum-restore.ts`).
52
+ */
53
+ certifiedSignerCount?: number;
48
54
  /**
49
55
  * Set on `digest-mismatch` only: the served bytes provably contradict the proof's declared
50
56
  * digest, so this candidate is dropped from the content quorum (its rev claim still counts —
@@ -177,6 +183,7 @@ async function certifyCandidates(deps: ReconcileBlockDeps, blockId: BlockId, can
177
183
  const verdict = await certifyContent(c.proof, claim, c.block, thresholds, deps.anchoring);
178
184
  if (verdict.revCertified) {
179
185
  c.revCertified = true;
186
+ c.certifiedSignerCount = verdict.signerCount;
180
187
  if (verdict.contentCertified) {
181
188
  c.contentCertified = true;
182
189
  } else if (verdict.failure === 'digest-mismatch') {
@@ -198,6 +205,7 @@ async function certifyCandidates(deps: ReconcileBlockDeps, blockId: BlockId, can
198
205
  const verdict = await certifyClaim(c.proof, claim, thresholds, deps.anchoring);
199
206
  if (verdict.certified) {
200
207
  c.revCertified = true;
208
+ c.certifiedSignerCount = verdict.signerCount;
201
209
  return;
202
210
  }
203
211
  logUncertified(blockId, c, verdict.failure);
@@ -247,12 +255,19 @@ async function hashCarriers(
247
255
  carriers.map(async c => ({
248
256
  peerId: c.peerId, hash: await canonicalBlockHash(c.block!), block: c.block!,
249
257
  ...(c.contentCertified ? { certified: true } : {}),
258
+ ...(c.contentCertified && c.certifiedSignerCount !== undefined
259
+ ? { certifiedSignerCount: c.certifiedSignerCount } : {}),
250
260
  ...(c.contentCertified && c.proof ? { proof: c.proof } : {})
251
261
  }))
252
262
  );
253
263
  }
254
264
 
255
- /** Report cohort members that served content contradicting the agreed hash. Best-effort; never throws. */
265
+ /**
266
+ * Report cohort members that served content contradicting the agreed hash. Best-effort; never
267
+ * throws. A CERTIFIED disagreeing carrier is exempt: its bytes match a verified proof's declared
268
+ * digest, so it honestly served a commit that really happened — when the hash quorum outweighs its
269
+ * single-signer proof (`selectQuorumBlock`), it is the losing side of a fork, not a fabricator.
270
+ */
256
271
  function penalizeContradictingContent(
257
272
  reputation: Pick<IPeerReputation, 'reportPeer'> | undefined,
258
273
  candidates: BlockHashCandidate[],
@@ -262,7 +277,7 @@ function penalizeContradictingContent(
262
277
  if (!reputation) return;
263
278
  try {
264
279
  for (const c of candidates) {
265
- if (c.hash !== agreedHash) {
280
+ if (c.hash !== agreedHash && c.certified !== true) {
266
281
  reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
267
282
  }
268
283
  }
@@ -281,8 +296,11 @@ function penalizeContradictingContent(
281
296
  *
282
297
  * Peer-attached cohort commit proofs are verified first ({@link certifyCandidates}) and both gates
283
298
  * weigh the verdicts: a claim — and, separately, the bytes — that a verified proof certifies is
284
- * accepted with no second peer at any cohort size, and the proof that certified the bytes is
285
- * persisted alongside them so the repaired replica serves it onward.
299
+ * accepted with no second peer where nothing multi-peer contests it. The proof's signer count is
300
+ * weighed too: a SINGLE-SIGNER proof (a solo cohort's self-signed receipt) never outranks several
301
+ * distinct peers agreeing at the same revision or on the same bytes — see `quorum-restore.ts`. The
302
+ * proof that certified the agreed bytes is persisted alongside them so the repaired replica serves
303
+ * it onward.
286
304
  *
287
305
  * Both quorums are capped by {@link corroboratorCapacity} for the claims that still need
288
306
  * corroboration: demanding two corroborators from a cohort that contains exactly one other peer is
@@ -298,8 +316,10 @@ function penalizeContradictingContent(
298
316
  * no honest majority to appeal to in the first place. It is closed for a candidate that carries a
299
317
  * verified cohort commit proof — {@link certifyCandidates} binds the proof's declared digest to the
300
318
  * served bytes, which is a check against the *cohort's own signatures* rather than against other
301
- * peers, so a certified carrier wins the content gate outright and this exposure never applies to
302
- * it. It remains open for a proof-less candidate, and for the residual that layer 1 proves only
319
+ * peers, so a certified carrier wins the content gate with no second carrier and this exposure
320
+ * never applies to it. (Weighing still applies at larger widths: a SINGLE-SIGNER certified carrier
321
+ * does not displace a multi-peer hash quorum — see `selectQuorumBlock` — but at the capacity-one
322
+ * width this paragraph describes there is no multi-peer group to lose to.) It remains open for a proof-less candidate, and for the residual that layer 1 proves only
303
323
  * that the listed signers signed (`feat-cluster-membership-threshold-cert-anchoring`).
304
324
  *
305
325
  * Declines are cheap and retryable: nothing is persisted, nothing is marked, and the next commit
@@ -319,8 +339,10 @@ export function createReconcileBlock(deps: ReconcileBlockDeps): ReconcileBlockCa
319
339
 
320
340
  await certifyCandidates(deps, blockId, candidates);
321
341
 
322
- const revClaims: RevClaim[] = candidates.map(({ peerId, rev, actionId, revCertified }) => ({
323
- peerId, rev, actionId, ...(revCertified ? { certified: true } : {})
342
+ const revClaims: RevClaim[] = candidates.map(({ peerId, rev, actionId, revCertified, certifiedSignerCount }) => ({
343
+ peerId, rev, actionId,
344
+ ...(revCertified ? { certified: true } : {}),
345
+ ...(revCertified && certifiedSignerCount !== undefined ? { certifiedSignerCount } : {})
324
346
  }));
325
347
  const selected = selectQuorumRev(revClaims, deps.simpleMajorityThreshold, capacity);
326
348
  if (!selected) {
@@ -381,9 +403,10 @@ export function createReconcileBlock(deps: ReconcileBlockDeps): ReconcileBlockCa
381
403
  return;
382
404
  }
383
405
 
384
- // Detection is sound: selectQuorumBlock's certified branch fires iff exactly one distinct
385
- // certified hash exists, so a defined `agreed` with a certified carrier at `agreed.hash` ⇔
386
- // the certified rule won.
406
+ // A certified carrier at the agreed hash means a proof was verified against exactly these
407
+ // bytes safe to persist whichever rule won the selection. (Since single-signer certified
408
+ // hashes stopped short-circuiting the quorum, the hash-quorum rule CAN win at a hash that
409
+ // also has a certified carrier; the proof still certifies those bytes.)
387
410
  const certifiedCarrier = hashCandidates.find(c => c.certified === true && c.hash === agreed.hash);
388
411
 
389
412
  // Contradicting-content penalties run ONLY on a corroborated win, mirroring the read path's