@optimystic/db-p2p 0.16.3 → 0.18.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 (90) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +14 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +12 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/cluster-policy.d.ts +112 -0
  6. package/dist/src/cluster/cluster-policy.d.ts.map +1 -0
  7. package/dist/src/cluster/cluster-policy.js +88 -0
  8. package/dist/src/cluster/cluster-policy.js.map +1 -0
  9. package/dist/src/cluster/cluster-repo.d.ts +41 -13
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +116 -23
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/quorum-restore.d.ts +64 -14
  14. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  15. package/dist/src/cluster/quorum-restore.js +0 -0
  16. package/dist/src/cluster/quorum-restore.js.map +1 -1
  17. package/dist/src/cluster/reconcile-block.d.ts +60 -0
  18. package/dist/src/cluster/reconcile-block.d.ts.map +1 -0
  19. package/dist/src/cluster/reconcile-block.js +133 -0
  20. package/dist/src/cluster/reconcile-block.js.map +1 -0
  21. package/dist/src/cluster/service.d.ts +4 -1
  22. package/dist/src/cluster/service.d.ts.map +1 -1
  23. package/dist/src/cluster/service.js +9 -1
  24. package/dist/src/cluster/service.js.map +1 -1
  25. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  26. package/dist/src/cluster/spread-on-churn.js +8 -0
  27. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  28. package/dist/src/inbound-authorization.d.ts +117 -0
  29. package/dist/src/inbound-authorization.d.ts.map +1 -0
  30. package/dist/src/inbound-authorization.js +149 -0
  31. package/dist/src/inbound-authorization.js.map +1 -0
  32. package/dist/src/index.d.ts +1 -0
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +1 -0
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/libp2p-key-network.d.ts +14 -0
  37. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  38. package/dist/src/libp2p-key-network.js +54 -4
  39. package/dist/src/libp2p-key-network.js.map +1 -1
  40. package/dist/src/libp2p-node-base.d.ts +48 -7
  41. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  42. package/dist/src/libp2p-node-base.js +61 -83
  43. package/dist/src/libp2p-node-base.js.map +1 -1
  44. package/dist/src/repo/cluster-coordinator.d.ts +21 -3
  45. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  46. package/dist/src/repo/cluster-coordinator.js +27 -5
  47. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  48. package/dist/src/repo/coordinator-repo.d.ts +123 -16
  49. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  50. package/dist/src/repo/coordinator-repo.js +354 -49
  51. package/dist/src/repo/coordinator-repo.js.map +1 -1
  52. package/dist/src/repo/service.d.ts +4 -1
  53. package/dist/src/repo/service.d.ts.map +1 -1
  54. package/dist/src/repo/service.js +9 -1
  55. package/dist/src/repo/service.js.map +1 -1
  56. package/dist/src/storage/block-storage.d.ts.map +1 -1
  57. package/dist/src/storage/block-storage.js +11 -0
  58. package/dist/src/storage/block-storage.js.map +1 -1
  59. package/dist/src/storage/storage-repo.d.ts +56 -0
  60. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  61. package/dist/src/storage/storage-repo.js +155 -13
  62. package/dist/src/storage/storage-repo.js.map +1 -1
  63. package/dist/src/sync/service.d.ts +4 -7
  64. package/dist/src/sync/service.d.ts.map +1 -1
  65. package/dist/src/sync/service.js +13 -10
  66. package/dist/src/sync/service.js.map +1 -1
  67. package/dist/src/testing/mesh-harness.d.ts +10 -0
  68. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  69. package/dist/src/testing/mesh-harness.js +55 -49
  70. package/dist/src/testing/mesh-harness.js.map +1 -1
  71. package/package.json +2 -2
  72. package/{README.md → readme.md} +37 -0
  73. package/src/cluster/block-transfer-service.ts +20 -5
  74. package/src/cluster/cluster-policy.ts +152 -0
  75. package/src/cluster/cluster-repo.ts +121 -26
  76. package/src/cluster/quorum-restore.ts +0 -0
  77. package/src/cluster/reconcile-block.ts +191 -0
  78. package/src/cluster/service.ts +10 -3
  79. package/src/cluster/spread-on-churn.ts +8 -0
  80. package/src/inbound-authorization.ts +190 -0
  81. package/src/index.ts +1 -0
  82. package/src/libp2p-key-network.ts +54 -4
  83. package/src/libp2p-node-base.ts +111 -94
  84. package/src/repo/cluster-coordinator.ts +30 -6
  85. package/src/repo/coordinator-repo.ts +419 -58
  86. package/src/repo/service.ts +10 -3
  87. package/src/storage/block-storage.ts +11 -0
  88. package/src/storage/storage-repo.ts +172 -15
  89. package/src/sync/service.ts +14 -12
  90. package/src/testing/mesh-harness.ts +55 -49
@@ -20,12 +20,13 @@ import { MemoryRawStorage } from './storage/memory-storage.js';
20
20
  import type { IRawStorage } from './storage/i-raw-storage.js';
21
21
  import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
22
22
  import { clusterMember, type ReconcileBlockCallback, type CommitCertificateSink, type DeriveExpectedClusterCallback } from './cluster/cluster-repo.js';
23
- import { selectQuorumRev, selectQuorumBlock, canonicalBlockHash, type RevClaim, type BlockHashCandidate } from './cluster/quorum-restore.js';
23
+ import { createReconcileBlock } from './cluster/reconcile-block.js';
24
+ import { resolveClusterPolicy, type ClusterPolicyOptions } from './cluster/cluster-policy.js';
24
25
  import { createCommitCertStore, makeClusterCommitCertExtractor, type CommitCertStore } from './cluster/commit-cert.js';
25
26
  import { coordinatorRepo } from './repo/coordinator-repo.js';
26
27
  import { Libp2pKeyPeerNetwork, type NetworkMode, type NetworkStatePersistence } from './libp2p-key-network.js';
27
28
  import { ClusterClient } from './cluster/client.js';
28
- import type { IRepo, ICluster, ITransactionValidator, BlockId, IBlock, IBlockChangeNotifier } from '@optimystic/db-core';
29
+ import type { IRepo, ICluster, ITransactionValidator, BlockId, IBlockChangeNotifier } from '@optimystic/db-core';
29
30
  import type { ITransactionStateStore } from './cluster/i-transaction-state-store.js';
30
31
  import { networkManagerService, type NetworkManagerService } from './network/network-manager-service.js';
31
32
  import type { SpreadOnChurnConfig, SpreadOnChurnMonitor } from './cluster/spread-on-churn.js';
@@ -70,7 +71,6 @@ import {
70
71
  reactivityNodePolicy,
71
72
  createTierAddressing,
72
73
  createRingHash,
73
- DEFAULT_SUPER_MAJORITY_THRESHOLD,
74
74
  Tier,
75
75
  b64urlToBytes,
76
76
  bytesToB64url,
@@ -85,7 +85,7 @@ import { assertSuperMajorityCoupling } from './cluster/supermajority-coupling.js
85
85
  import { createLogger } from './logger.js';
86
86
  import { PeerReputationService } from './reputation/peer-reputation.js';
87
87
  import type { IPeerReputation } from './reputation/types.js';
88
- import { PenaltyReason } from './reputation/types.js';
88
+ import type { AuthorizeInboundStream, InboundStreamAuthorizationInit } from './inbound-authorization.js';
89
89
  import { DisputeService } from './dispute/dispute-service.js';
90
90
  import { DisputeClient } from './dispute/client.js';
91
91
  import { sampleArbitrators } from './dispute/arbitrator-selection.js';
@@ -135,7 +135,12 @@ const wiringLog = createLogger('node-wiring');
135
135
  /** Factory function or instance for creating raw storage */
136
136
  export type RawStorageProvider = IRawStorage | (() => IRawStorage);
137
137
 
138
- export type NodeOptions = {
138
+ /**
139
+ * `ClusterPolicyOptions` is intersected in, not restated: `resolveClusterPolicy` consumes those
140
+ * fields structurally, so a second copy of the shape here would let a newly added knob compile and
141
+ * be silently ignored. See `cluster/cluster-policy.ts` for what each one resolves to.
142
+ */
143
+ export type NodeOptions = ClusterPolicyOptions & {
139
144
  /**
140
145
  * Network port. Only used by the default `listenAddrs` fallback.
141
146
  * For non-TCP transports (e.g. WebSockets), set `listenAddrs` explicitly.
@@ -174,15 +179,20 @@ export type NodeOptions = {
174
179
  relayServerInit?: CircuitRelayServerInit;
175
180
  /** Storage provider - either an IRawStorage instance or a factory function. Defaults to MemoryRawStorage if not provided. */
176
181
  storage?: RawStorageProvider;
177
- clusterSize?: number; // desired cluster size per key
178
- clusterPolicy?: {
179
- allowDownsize?: boolean;
180
- sizeTolerance?: number; // acceptable relative difference (e.g. 0.5 = +/-50%)
181
- superMajorityThreshold?: number; // fraction of peers needed for super-majority (default: DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75)
182
- };
183
-
184
182
  /** Override libp2p listen multiaddrs. */
185
183
  listenAddrs?: string[];
184
+ /**
185
+ * Multiaddrs to advertise INSTEAD OF the listen addrs. For a node behind a NAT / reverse proxy /
186
+ * DNS front that binds one address but is reachable at another. When non-empty these REPLACE the
187
+ * advertised set entirely — observed/relayed addresses and {@link NodeOptions.appendAnnounceAddrs}
188
+ * are all dropped from it. An empty array means "unset" (libp2p's own semantics).
189
+ */
190
+ announceAddrs?: string[];
191
+ /**
192
+ * Multiaddrs to advertise IN ADDITION TO the listen addrs. Ignored while
193
+ * {@link NodeOptions.announceAddrs} is non-empty.
194
+ */
195
+ appendAnnounceAddrs?: string[];
186
196
  /** Override libp2p transports. */
187
197
  transports?: Libp2pTransports;
188
198
 
@@ -275,6 +285,36 @@ export type NodeOptions = {
275
285
  */
276
286
  privateKey?: PrivateKey;
277
287
 
288
+ /**
289
+ * Optional predicate deciding whether a remote peer may open one of the four Optimystic
290
+ * database protocols on this node (`repo`, `cluster`, `sync`, `block-transfer`). It is
291
+ * consulted once per inbound stream, before any frame is decoded or any operation executed.
292
+ *
293
+ * This is deliberately ONE node-level option threaded to all four services rather than four
294
+ * per-service options: "is this peer allowed to talk to my database?" is a property of the
295
+ * node, not of the protocol, and four independently-settable options make it easy to secure
296
+ * three surfaces and silently miss the fourth. (Each service still accepts the same option in
297
+ * its own init, so the services stay independently testable and usable outside this factory.)
298
+ *
299
+ * Absent → no check at all, and today's behavior exactly. Supplied → fail-closed: `false`, a
300
+ * throw, a rejection, or a timeout all deny and abort the stream. `remotePeerId` is the
301
+ * dialing peer's `PeerId.toString()`. See {@link AuthorizeInboundStream} and
302
+ * `docs/internals.md` § Inbound Stream Authorization.
303
+ *
304
+ * NOTE: this covers the four database protocols only. The dispute, reactivity, matchmaking,
305
+ * cohort-topic and libp2p built-in (identify/ping/…) protocols this node also registers are
306
+ * NOT gated by it. To refuse a peer at the connection level instead — every protocol at once,
307
+ * including identify — use {@link NodeOptions.connectionGater}.
308
+ */
309
+ authorizeInboundStream?: AuthorizeInboundStream;
310
+
311
+ /**
312
+ * Deadline for {@link NodeOptions.authorizeInboundStream}; expiry denies the stream (a hanging
313
+ * predicate would otherwise pin an inbound stream slot). Defaults to
314
+ * `DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS` (5s). Ignored when no predicate is supplied.
315
+ */
316
+ authorizeInboundStreamTimeoutMs?: number;
317
+
278
318
  /**
279
319
  * Optional libp2p connection gater. The libp2p browser default denies
280
320
  * dialing insecure WebSockets and private/loopback addresses; callers
@@ -383,6 +423,19 @@ export async function createLibp2pNodeBase(
383
423
  }
384
424
  };
385
425
 
426
+ // The ONE authorization slice, spread verbatim into all four database-protocol service inits
427
+ // below. Building it once (rather than repeating two option reads per service) is what makes
428
+ // "secured three surfaces, missed the fourth" impossible: adding a fifth protocol service is a
429
+ // spread of this object, and dropping it from one is visible at the call site.
430
+ // Absent `authorizeInboundStream` → every service constructs its gate as `undefined` and the
431
+ // inbound path is byte-for-byte what it was before this option existed.
432
+ const inboundAuthorization: InboundStreamAuthorizationInit = {
433
+ ...(options.authorizeInboundStream ? { authorizeInboundStream: options.authorizeInboundStream } : {}),
434
+ ...(options.authorizeInboundStreamTimeoutMs !== undefined
435
+ ? { authorizeInboundStreamTimeoutMs: options.authorizeInboundStreamTimeoutMs }
436
+ : {})
437
+ };
438
+
386
439
  const nodePrivateKey = options.privateKey ?? await generateKeyPair('Ed25519');
387
440
 
388
441
  const listenAddrs = options.listenAddrs ?? defaults.listenAddrs;
@@ -408,8 +461,13 @@ export async function createLibp2pNodeBase(
408
461
  const libp2pOptions: Libp2pInit = {
409
462
  start: false,
410
463
  privateKey: nodePrivateKey,
464
+ // NOTE: libp2p's `AddressManagerInit` also carries `noAnnounce` and `announceFilter`; neither is
465
+ // exposed on `NodeOptions`. Add them here the same way if a deployment ever needs to suppress a
466
+ // specific advertised address rather than replace the whole set.
411
467
  addresses: {
412
- listen: listenAddrs
468
+ listen: listenAddrs,
469
+ ...(options.announceAddrs ? { announce: options.announceAddrs } : {}),
470
+ ...(options.appendAnnounceAddrs ? { appendAnnounce: options.appendAnnounceAddrs } : {})
413
471
  },
414
472
  connectionManager: {
415
473
  // `autoDial`, `minConnections`, and `dialQueue` were stale libp2p option keys silently
@@ -479,7 +537,8 @@ export async function createLibp2pNodeBase(
479
537
  cluster: (components: any) => {
480
538
  const serviceFactory = clusterService({
481
539
  protocolPrefix: `/optimystic/${options.networkName}`,
482
- responsibilityK: options.responsibilityK ?? 1
540
+ responsibilityK: options.responsibilityK ?? 1,
541
+ ...inboundAuthorization
483
542
  });
484
543
  return serviceFactory({
485
544
  logger: components.logger,
@@ -505,7 +564,8 @@ export async function createLibp2pNodeBase(
505
564
  repo: (components: any) => {
506
565
  const serviceFactory = repoService({
507
566
  protocolPrefix: `/optimystic/${options.networkName}`,
508
- responsibilityK: options.responsibilityK ?? 1
567
+ responsibilityK: options.responsibilityK ?? 1,
568
+ ...inboundAuthorization
509
569
  });
510
570
  // RepoService.checkRedirect needs the running node (network manager for the
511
571
  // responsible-set computation, self id for the membership check, connection
@@ -525,7 +585,8 @@ export async function createLibp2pNodeBase(
525
585
 
526
586
  sync: (components: any) => {
527
587
  const serviceFactory = syncService({
528
- protocolPrefix: `/optimystic/${options.networkName}`
588
+ protocolPrefix: `/optimystic/${options.networkName}`,
589
+ ...inboundAuthorization
529
590
  });
530
591
  return serviceFactory({
531
592
  logger: components.logger,
@@ -539,11 +600,14 @@ export async function createLibp2pNodeBase(
539
600
  // node's own storage, not be re-routed through the cluster-coordinated repo.
540
601
  blockTransfer: (components: any) => {
541
602
  const serviceFactory = blockTransferService({
542
- protocolPrefix: `/optimystic/${options.networkName}`
603
+ protocolPrefix: `/optimystic/${options.networkName}`,
604
+ ...inboundAuthorization
543
605
  });
544
606
  return serviceFactory({
545
607
  registrar: components.registrar,
546
- repo: storageRepo
608
+ repo: storageRepo,
609
+ // So this service's authorization denials reach the same error sink as the other three.
610
+ logger: components.logger
547
611
  });
548
612
  },
549
613
 
@@ -636,18 +700,11 @@ export async function createLibp2pNodeBase(
636
700
  const partitionDetector = new PartitionDetector();
637
701
  const fretSvc = (node as any).services?.fret as FretService | undefined;
638
702
 
639
- const consensusConfig = {
640
- superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
641
- simpleMajorityThreshold: 0.51,
642
- minAbsoluteClusterSize: 2,
643
- allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
644
- clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
645
- partitionDetectionWindow: 60000,
646
- // Configured full cluster size — the member's own reference for "full size" in the membership
647
- // admission gate (a below-full-size declared set under low FRET confidence is refused as a possible
648
- // self-shrink). Matches the size threaded into the coordinator below.
649
- clusterSize: options.clusterSize ?? 10
650
- };
703
+ // Every cluster-policy default lives in `cluster/cluster-policy.ts` — including WHY the admission
704
+ // gate and the repair corroboration floor resolve the one operator field
705
+ // (`clusterPolicy.assumedClusterSize`) to different values when it is absent. Extracted so those
706
+ // defaults can be asserted without booting a node.
707
+ const consensusConfig = resolveClusterPolicy(options);
651
708
 
652
709
  // Fetch a block archive from one cohort peer over the sync protocol, bounded by a
653
710
  // per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
@@ -674,69 +731,23 @@ export async function createLibp2pNodeBase(
674
731
  }
675
732
  };
676
733
 
677
- // Active reconciliation for a block this member committed without the matching pend
678
- // (cohort drift). Queries the commit cohort (self already excluded) for the block,
679
- // picks the highest revision that is at least the committed rev, and persists it via
680
- // the churn-replication funnel so the block is no longer under-replicated.
681
- const reconcileBlock: ReconcileBlockCallback = async (blockId, committed, cohortPeerIds) => {
682
- const targets = cohortPeerIds.filter(id => id !== node.peerId.toString());
683
- if (targets.length === 0) return;
684
-
685
- const fetched = await Promise.all(
686
- targets.map(async peerIdStr => ({ peerIdStr, archive: await fetchArchiveFromPeer(peerIdStr, blockId) }))
687
- );
688
-
689
- // Each cohort archive contributes one (rev, actionId) claim from its max
690
- // revision (>= the rev we committed). Pick the target rev by quorum
691
- // corroboration rather than raw Math.max — a lone peer inflating its rev
692
- // cannot steer reconciliation. Keep the serving peer + block per candidate
693
- // so we can then verify content agreement.
694
- // NOTE: this quorum is corroboration-of-a-claim, NOT Sybil-resistant cohort
695
- // membership — deferred to backlog `debt-read-repair-commit-cert-verification`.
696
- const candidates: { peerIdStr: string; rev: number; actionId: string; block?: IBlock }[] = [];
697
- for (const { peerIdStr, archive } of fetched) {
698
- if (!archive) continue;
699
- const revs = Object.keys(archive.revisions).map(Number);
700
- if (revs.length === 0) continue;
701
- const maxRev = Math.max(...revs);
702
- if (maxRev < committed.rev) continue;
703
- const data = archive.revisions[maxRev];
704
- if (!data?.action) continue;
705
- candidates.push({ peerIdStr, rev: maxRev, actionId: data.action.actionId, block: data.block });
706
- }
707
-
708
- const revClaims: RevClaim[] = candidates.map(c => ({ peerId: c.peerIdStr, rev: c.rev, actionId: c.actionId }));
709
- const selected = selectQuorumRev(revClaims, consensusConfig.simpleMajorityThreshold);
710
- if (!selected) return; // no rev corroborated by a quorum → leave block, churn/rebalance retries later
711
-
712
- // Content agreement: among archives corroborating the chosen (rev, actionId)
713
- // and actually carrying the block, the content must be byte-identical across
714
- // a quorum. A cohort member serving content that hashes differently is rejected.
715
- // NOTE: selectQuorumBlock recomputes its quorum over only the block-CARRYING
716
- // corroborators, not the full rev-responder set. If most peers corroborate the
717
- // rev but few carry block bytes (e.g. mid-prune), the content quorum can shrink
718
- // to 2. Harmless with honest peers; if a colluding pair ever becomes the only
719
- // block-servers for an agreed rev, that is the Sybil regime already deferred to
720
- // backlog `debt-read-repair-commit-cert-verification`.
721
- const corroborating = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
722
- const hashCandidates: BlockHashCandidate[] = await Promise.all(
723
- corroborating.map(async c => ({ peerId: c.peerIdStr, hash: await canonicalBlockHash(c.block!), block: c.block! }))
724
- );
725
- const agreed = selectQuorumBlock(hashCandidates, consensusConfig.simpleMajorityThreshold);
726
- if (!agreed) return; // no content quorum → skip persist
727
-
728
- // Best-effort: penalize cohort members that served content contradicting the
729
- // agreed hash for the same committed (rev, actionId). Never let this throw.
730
- try {
731
- for (const c of hashCandidates) {
732
- if (c.hash !== agreed.hash) {
733
- reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
734
- }
735
- }
736
- } catch { /* reputation write must never block restoration */ }
737
-
738
- await storageRepo.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
739
- };
734
+ // Active reconciliation for a block this member committed without a materializable base
735
+ // (cohort drift, or a refused `missing-base-revision` commit). See `reconcile-block.ts` for
736
+ // the corroboration rules in particular why both quorums are capped by how many peers
737
+ // could answer at all, which is what lets a genuinely two-node cohort heal.
738
+ // NOTE: this and the CoordinatorRepo below must cap against the SAME
739
+ // repairCorroborationClusterSize, or the two restoration paths disagree about how much trust a
740
+ // lone peer gets. Safe today because both read the one `resolveClusterPolicy` result above; if
741
+ // either ever resolves its own value, add a fail-fast coupling check like
742
+ // `assertSuperMajorityCoupling` rather than relying on proximity.
743
+ const reconcileBlock: ReconcileBlockCallback = createReconcileBlock({
744
+ selfPeerId: node.peerId.toString(),
745
+ fetchArchive: fetchArchiveFromPeer,
746
+ saveReplicatedBlock: (blockId, block, source) => storageRepo.saveReplicatedBlock(blockId, block, source),
747
+ simpleMajorityThreshold: consensusConfig.simpleMajorityThreshold,
748
+ repairCorroborationClusterSize: consensusConfig.repairCorroborationClusterSize,
749
+ reputation
750
+ });
740
751
 
741
752
  // Member-side membership derivation for the admission gate: independently re-derive this block's
742
753
  // responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
@@ -828,7 +839,13 @@ export async function createLibp2pNodeBase(
828
839
  storageRepo,
829
840
  localCluster: clusterImpl,
830
841
  localPeerId: node.peerId,
831
- clusterLatestCallback
842
+ clusterLatestCallback,
843
+ // Read-driven acquisition shares the commit path's reconcile callback verbatim: same bounded
844
+ // archive fetch, same (rev, actionId) and content quorums, same monotonic saveReplicatedBlock
845
+ // funnel. `clusterLatestCallback` alone can only tell the reader WHICH revision the cohort
846
+ // holds; this is what moves the bytes. Only reached once a corroborated revision exists, so a
847
+ // genuinely absent block still costs no archive fetch.
848
+ acquireBlockFromCohort: reconcileBlock
832
849
  });
833
850
 
834
851
  // Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
@@ -1,7 +1,6 @@
1
1
  import { peerIdFromString } from "@libp2p/peer-id";
2
- import type { ClusterRecord, IKeyNetwork, RepoMessage, BlockId, ClusterPeers, MessageOptions, ClusterConsensusConfig } from "@optimystic/db-core";
2
+ import type { ClusterRecord, IKeyNetwork, RepoMessage, BlockId, ClusterPeers, MessageOptions, ClusterConsensusConfig, ICluster } from "@optimystic/db-core";
3
3
  import { CURRENT_MEMBERSHIP_VERSION, computeClusterMessageHash, membershipDigest } from "@optimystic/db-core";
4
- import { ClusterClient } from "../cluster/client.js";
5
4
  import { Pending } from "@optimystic/db-core";
6
5
  import type { PeerId } from "@libp2p/interface";
7
6
  import { createLogger, verbose } from '../logger.js'
@@ -13,6 +12,26 @@ import type { ITransactionStateStore } from "../cluster/i-transaction-state-stor
13
12
 
14
13
  const log = createLogger('cluster')
15
14
 
15
+ /**
16
+ * Consensus refused a transaction: enough members voted reject that super-majority became
17
+ * impossible. A typed error (rather than a bare `Error`) so the repo layer above can distinguish
18
+ * "the cluster voted this down" from transport/availability failures WITHOUT string-matching the
19
+ * rejection reasons — those are free-form text that is part of each member's signed vote payload
20
+ * (see cluster-repo's `computeSigningPayload`), so their wording must never become control flow.
21
+ * `CoordinatorRepo.pend` uses this to decide whether a rejection is a retryable stale-revision
22
+ * loss (confirmed against local storage) or a genuine validation fault.
23
+ */
24
+ export class ValidatorRejectionError extends Error {
25
+ constructor(
26
+ message: string,
27
+ /** Per-peer reject reasons, verbatim from the vote signatures (free-form, wire-visible). */
28
+ readonly rejectReasons: Record<string, string>
29
+ ) {
30
+ super(message);
31
+ this.name = 'ValidatorRejectionError';
32
+ }
33
+ }
34
+
16
35
  /** Cancel handle for an injected timer; cancels a not-yet-fired timer (safe no-op after fire/cancel). */
17
36
  export type TimerCancel = () => void;
18
37
 
@@ -76,7 +95,8 @@ export class ClusterCoordinator {
76
95
 
77
96
  constructor(
78
97
  private readonly keyNetwork: IKeyNetwork,
79
- private readonly createClusterClient: (peerId: PeerId) => ClusterClient,
98
+ /** Factory for a per-peer cluster RPC handle; only `update` is ever called, hence `ICluster`. */
99
+ private readonly createClusterClient: (peerId: PeerId) => ICluster,
80
100
  private readonly cfg: ClusterConsensusConfig & { clusterSize: number },
81
101
  private readonly localCluster?: {
82
102
  update: (record: ClusterRecord) => Promise<ClusterRecord>;
@@ -322,9 +342,11 @@ export class ClusterCoordinator {
322
342
  // If more than (peerCount - superMajority) nodes reject, we can never reach super-majority
323
343
  const maxAllowedRejections = peerCount - superMajority;
324
344
  if (rejectionCount > maxAllowedRejections) {
325
- const rejectReasons = Object.entries(promises)
345
+ const rejectReasonsByPeer = Object.fromEntries(Object.entries(promises)
326
346
  .filter(([_, sig]) => sig.type === 'reject')
327
- .map(([peerId, sig]) => `${peerId}: ${sig.rejectReason ?? 'unknown'}`)
347
+ .map(([peerId, sig]) => [peerId, sig.rejectReason ?? 'unknown']));
348
+ const rejectReasons = Object.entries(rejectReasonsByPeer)
349
+ .map(([peerId, reason]) => `${peerId}: ${reason}`)
328
350
  .join('; ');
329
351
  log('cluster-tx:rejected-by-validators', {
330
352
  messageHash: record.messageHash,
@@ -334,7 +356,9 @@ export class ClusterCoordinator {
334
356
  reasons: rejectReasons
335
357
  });
336
358
  this.updateTransactionRecord(promised.record, 'rejected-by-validators');
337
- throw new Error(`Transaction rejected by validators (${rejectionCount}/${peerCount} rejected): ${rejectReasons}`);
359
+ throw new ValidatorRejectionError(
360
+ `Transaction rejected by validators (${rejectionCount}/${peerCount} rejected): ${rejectReasons}`,
361
+ rejectReasonsByPeer);
338
362
  }
339
363
 
340
364
  if (peerCount > 1 && approvalCount < superMajority) {