@optimystic/db-p2p 0.17.0 → 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 (66) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +10 -0
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +2 -1
  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 +35 -11
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +88 -19
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/quorum-restore.d.ts +25 -3
  14. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  15. package/dist/src/cluster/quorum-restore.js +27 -3
  16. package/dist/src/cluster/quorum-restore.js.map +1 -1
  17. package/dist/src/cluster/reconcile-block.d.ts +10 -2
  18. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  19. package/dist/src/cluster/reconcile-block.js +38 -18
  20. package/dist/src/cluster/reconcile-block.js.map +1 -1
  21. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  22. package/dist/src/cluster/spread-on-churn.js +8 -0
  23. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  24. package/dist/src/inbound-authorization.d.ts +6 -0
  25. package/dist/src/inbound-authorization.d.ts.map +1 -1
  26. package/dist/src/inbound-authorization.js +6 -0
  27. package/dist/src/inbound-authorization.js.map +1 -1
  28. package/dist/src/libp2p-key-network.d.ts +14 -0
  29. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  30. package/dist/src/libp2p-key-network.js +54 -4
  31. package/dist/src/libp2p-key-network.js.map +1 -1
  32. package/dist/src/libp2p-node-base.d.ts +22 -23
  33. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  34. package/dist/src/libp2p-node-base.js +22 -19
  35. package/dist/src/libp2p-node-base.js.map +1 -1
  36. package/dist/src/repo/cluster-coordinator.d.ts +21 -3
  37. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  38. package/dist/src/repo/cluster-coordinator.js +27 -5
  39. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  40. package/dist/src/repo/coordinator-repo.d.ts +60 -26
  41. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  42. package/dist/src/repo/coordinator-repo.js +218 -65
  43. package/dist/src/repo/coordinator-repo.js.map +1 -1
  44. package/dist/src/storage/storage-repo.d.ts +9 -0
  45. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  46. package/dist/src/storage/storage-repo.js +56 -4
  47. package/dist/src/storage/storage-repo.js.map +1 -1
  48. package/dist/src/testing/mesh-harness.d.ts +10 -0
  49. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  50. package/dist/src/testing/mesh-harness.js +14 -1
  51. package/dist/src/testing/mesh-harness.js.map +1 -1
  52. package/package.json +2 -2
  53. package/readme.md +20 -0
  54. package/src/cluster/block-transfer-service.ts +9 -1
  55. package/src/cluster/cluster-policy.ts +152 -0
  56. package/src/cluster/cluster-repo.ts +93 -22
  57. package/src/cluster/quorum-restore.ts +28 -3
  58. package/src/cluster/reconcile-block.ts +52 -19
  59. package/src/cluster/spread-on-churn.ts +8 -0
  60. package/src/inbound-authorization.ts +6 -0
  61. package/src/libp2p-key-network.ts +54 -4
  62. package/src/libp2p-node-base.ts +42 -43
  63. package/src/repo/cluster-coordinator.ts +30 -6
  64. package/src/repo/coordinator-repo.ts +235 -73
  65. package/src/storage/storage-repo.ts +58 -6
  66. package/src/testing/mesh-harness.ts +15 -1
@@ -117,9 +117,10 @@ export type ExpectedClusterView = {
117
117
  * Independently derive this member's own view of a block's responsible cluster. Injected so
118
118
  * {@link ClusterMember} stays transport-agnostic — the composition root supplies it from
119
119
  * `IKeyNetwork.findCluster` + FRET (mirroring how the coordinator derives the cluster). Absent on nodes
120
- * that cannot derive a view (no FRET, unit tests): with no derived view AND no configured full-size
121
- * reference the gate preserves legacy approve behavior, but a configured `clusterSize` still lets the gate
122
- * fail closed on an unjustified downsize. See {@link ClusterMember} admission gate.
120
+ * that cannot derive a view (no FRET, unit tests): with no derived view AND no asserted
121
+ * {@link ClusterConsensusConfig.assumedClusterSize} the gate preserves legacy approve behavior, but an
122
+ * asserted size still lets the gate fail closed on an unjustified downsize. See {@link ClusterMember}
123
+ * admission gate.
123
124
  */
124
125
  export type DeriveExpectedClusterCallback = (blockId: BlockId) => Promise<ExpectedClusterView>;
125
126
 
@@ -232,8 +233,8 @@ export class ClusterMember implements ICluster {
232
233
  private readonly minAbsoluteClusterSize: number;
233
234
  private readonly clusterSizeTolerance: number;
234
235
  private readonly membershipAdmissionFraction: number;
235
- /** Configured full cluster size, or undefined when unknown (then the gate cannot judge a downsize). */
236
- private readonly configuredClusterSize: number | undefined;
236
+ /** Operator-asserted smallest genuine cohort size, or undefined when unknown. */
237
+ private readonly assumedClusterSize: number | undefined;
237
238
  private readonly allowUnvalidatedSmallCluster: boolean;
238
239
 
239
240
  constructor(
@@ -259,8 +260,17 @@ export class ClusterMember implements ICluster {
259
260
  this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
260
261
  this.clusterSizeTolerance = consensusConfig?.clusterSizeTolerance ?? 0.5;
261
262
  this.membershipAdmissionFraction = consensusConfig?.membershipAdmissionFraction ?? 0.75;
262
- this.configuredClusterSize = consensusConfig?.clusterSize;
263
+ this.assumedClusterSize = consensusConfig?.assumedClusterSize;
263
264
  this.allowUnvalidatedSmallCluster = consensusConfig?.allowUnvalidatedSmallCluster ?? false;
265
+ // State the resolved gate parameters once, so an operator diagnosing a membership rejection can see
266
+ // what this node actually resolved. A fact, not a warning: `assumedClusterSize < clusterSize` is the
267
+ // normal default state, so warning on it would fire for every node and be ignored.
268
+ log('cluster-member:admission-config', {
269
+ assumedClusterSize: this.assumedClusterSize,
270
+ minAbsoluteClusterSize: this.minAbsoluteClusterSize,
271
+ membershipAdmissionFraction: this.membershipAdmissionFraction,
272
+ allowUnvalidatedSmallCluster: this.allowUnvalidatedSmallCluster
273
+ });
264
274
  // Periodically clean up expired transactions (.unref() so tests/short-lived processes can exit)
265
275
  this.expirationInterval = setInterval(() => this.queueExpiredTransactions(), 60000);
266
276
  this.expirationInterval.unref();
@@ -858,12 +868,18 @@ export class ClusterMember implements ICluster {
858
868
  * of a peer or two is absorbed, a wholesale-disjoint or half-size set is not.
859
869
  *
860
870
  * **Fail-closed posture.** When the member cannot confidently derive `E` (no capability, low FRET
861
- * confidence — exactly what a partition induces), it must refuse any *downsizing* decision: a
862
- * below-full-size `D` is rejected against the configured full `clusterSize`. With NEITHER a confident
863
- * view NOR a configured full size the gate cannot judge a downsize at all, so it preserves the legacy
864
- * approve behavior (backward-compatible for nodes/tests with no derivation wired). `allowUnvalidatedSmallCluster`
865
- * is the explicit opt-in (single-node / local dev knowingly below the safe floor), matching the
866
- * coordinator's `validateSmallCluster` semantics.
871
+ * confidence — exactly what a partition induces), it must refuse any *downsizing* decision — but it
872
+ * needs a size reference to judge "downsize" against, and it may NOT borrow `clusterSize` for that:
873
+ * `clusterSize` is the replication factor (what a cohort should aim for), not a claim about how many
874
+ * peers exist, so a small deployment configured with the default 10 would refuse every write. The
875
+ * fallback yardstick is instead {@link ClusterConsensusConfig.assumedClusterSize} the operator's own
876
+ * assertion of the smallest cohort this deployment can genuinely field — run through the SAME
877
+ * {@link admissionFloor} as the confident path, so the fallback can never be stricter than the measured
878
+ * path (it was: it demanded the full configured size, with no fraction and no slack for churn or a peer
879
+ * not yet discovered). With NEITHER a confident view NOR an asserted size the gate cannot judge a
880
+ * downsize at all, so it preserves the legacy approve behavior (backward-compatible for nodes/tests with
881
+ * no derivation wired). `allowUnvalidatedSmallCluster` is the explicit opt-in (single-node / local dev
882
+ * knowingly below the safe floor), matching the coordinator's `validateSmallCluster` semantics.
867
883
  */
868
884
  private async admitMembership(record: ClusterRecord): Promise<{ admit: boolean; reason?: string }> {
869
885
  const ourId = this.peerId.toString();
@@ -894,30 +910,42 @@ export class ClusterMember implements ICluster {
894
910
  && derivedSize > 0;
895
911
 
896
912
  if (!confident) {
897
- // Fail closed for downsizing under low/absent confidence. A full-size (or larger) declared set is
898
- // still admitted there is nothing to shrink. Without a configured full-size reference we cannot
899
- // tell a downsize from a legitimate small cluster, so we preserve legacy approve behavior.
900
- if (this.configuredClusterSize === undefined) {
913
+ // Fail closed for downsizing under low/absent confidence, measured against the operator's asserted
914
+ // cohort size rather than the replication factor. With no asserted size the gate cannot tell a
915
+ // downsize from a legitimately small cluster at all, so it preserves legacy approve behavior.
916
+ if (this.assumedClusterSize === undefined) {
901
917
  return { admit: true };
902
918
  }
903
- if (declared.length >= this.configuredClusterSize) {
919
+ const floor = this.admissionFloor(this.assumedClusterSize);
920
+ if (declared.length >= floor) {
904
921
  return { admit: true };
905
922
  }
906
923
  log('cluster-member:admission-reject', {
907
924
  messageHash: record.messageHash,
908
925
  reason: 'low-confidence-downsize',
909
926
  declaredSize: declared.length,
910
- configuredClusterSize: this.configuredClusterSize,
927
+ floor,
928
+ assumedClusterSize: this.assumedClusterSize,
911
929
  confidence: derived?.confidence
912
930
  });
913
- return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:low-confidence-downsize` };
931
+ // The numbers ride along in the reason: this rejection is caused by *local* configuration, and
932
+ // without them a coordinator (or an operator reading a dispute record) has no hint which knob did it.
933
+ // NOTE: two honest members with different local config now emit *different* reason strings for the
934
+ // same record. Nothing compares reasons across peers today (`disputeEvidence.rejectReasons` is a
935
+ // per-peer map, and the signed payload hashes the string each vote carries); if anything ever groups
936
+ // or dedupes dispute reasons by string equality, group on the `membership-not-admitted:<variant>`
937
+ // prefix, not the whole string.
938
+ return {
939
+ admit: false,
940
+ reason: `${MEMBERSHIP_NOT_ADMITTED}:low-confidence-downsize (declared=${declared.length}, floor=${floor}, assumedClusterSize=${this.assumedClusterSize})`
941
+ };
914
942
  }
915
943
 
916
944
  const expected = Object.keys(derived!.peers ?? {});
917
945
  const kEst = expected.length;
918
946
 
919
947
  // Predicate 2: floor derived from the member's OWN confident estimate.
920
- const floor = Math.max(this.minAbsoluteClusterSize, Math.ceil(this.membershipAdmissionFraction * kEst));
948
+ const floor = this.admissionFloor(kEst);
921
949
  if (declared.length < floor) {
922
950
  log('cluster-member:admission-reject', {
923
951
  messageHash: record.messageHash,
@@ -926,7 +954,10 @@ export class ClusterMember implements ICluster {
926
954
  floor,
927
955
  kEst
928
956
  });
929
- return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:below-floor` };
957
+ return {
958
+ admit: false,
959
+ reason: `${MEMBERSHIP_NOT_ADMITTED}:below-floor (declared=${declared.length}, floor=${floor}, kEst=${kEst})`
960
+ };
930
961
  }
931
962
 
932
963
  // Predicate 3: consistency with the derived view within tolerance.
@@ -947,6 +978,27 @@ export class ClusterMember implements ICluster {
947
978
  return { admit: true };
948
979
  }
949
980
 
981
+ /**
982
+ * The smallest declared peer set admissible against a cohort-size reference `k`, whether `k` is
983
+ * measured (the confident path's `kEst`) or asserted (`assumedClusterSize`). One function so the
984
+ * fallback can never be stricter than the measured path — which it was, demanding the full configured
985
+ * size with no fraction and no slack. Clamped at `minAbsoluteClusterSize`, so a degenerate `k` of 0, 1
986
+ * or negative yields the absolute floor rather than a floor that admits everything. A non-finite scaled
987
+ * size (a `NaN` or `Infinity` config value) is likewise treated as no usable reference rather than
988
+ * propagating: an unguarded `NaN` floor fails EVERY comparison, which would silently make the node
989
+ * reject every unconfident write.
990
+ *
991
+ * NOTE: partition safety needs `2 · membershipAdmissionFraction · superMajorityThreshold > 1` — each
992
+ * side of a split must recruit `fraction · threshold · K` distinct honest members, and two sides cannot
993
+ * both find them in one K-peer cluster. At the shipped defaults (0.75 · 0.67) that product is 1.005 —
994
+ * true, but with almost no margin. If either default is ever lowered, re-check Theorem 2 in
995
+ * `docs/correctness.md` before shipping it.
996
+ */
997
+ private admissionFloor(k: number): number {
998
+ const scaled = Math.ceil(this.membershipAdmissionFraction * k);
999
+ return Math.max(this.minAbsoluteClusterSize, Number.isFinite(scaled) ? scaled : 0);
1000
+ }
1001
+
950
1002
  /**
951
1003
  * Derive this member's own view of the record's block cluster via the injected capability, or
952
1004
  * `undefined` when it cannot (no capability, no coordinating block id, or a derivation error — all of
@@ -1000,6 +1052,20 @@ export class ClusterMember implements ICluster {
1000
1052
  const blockResults = await this.storageRepo.get({ blockIds });
1001
1053
  for (const blockId of blockIds) {
1002
1054
  const blockResult = blockResults[blockId];
1055
+ if (blockResult?.unavailable !== undefined) {
1056
+ // This member cannot establish the block's revision, so it cannot judge
1057
+ // staleness. Vote reject rather than approve on an answer it knows is a
1058
+ // guess — approving would let a stale pend reach consensus on the strength
1059
+ // of a member that could not check it. (Before StorageRepo caught
1060
+ // materialization faults per block, this read threw out of the promise
1061
+ // handler; rejecting keeps the fail-closed posture with a signed reason.)
1062
+ log('cluster-member:validation-block-unavailable', {
1063
+ messageHash: record.messageHash,
1064
+ blockId,
1065
+ reason: blockResult.unavailable
1066
+ });
1067
+ return { valid: false, reason: `block ${blockId} unavailable (${blockResult.unavailable}): cannot verify revision` };
1068
+ }
1003
1069
  const latestRev = blockResult?.state?.latest?.rev;
1004
1070
  if (latestRev !== undefined && latestRev >= pendRequest.rev) {
1005
1071
  log('cluster-member:validation-stale-revision', {
@@ -1395,7 +1461,12 @@ export class ClusterMember implements ICluster {
1395
1461
  private async reconcileOneBlock(messageHash: string, blockId: BlockId, committed: ActionRev, cohortPeerIds: string[]): Promise<void> {
1396
1462
  try {
1397
1463
  await this.withReconcileTimeout(this.reconcileBlock!(blockId, committed, cohortPeerIds), blockId);
1398
- log('cluster-member:consensus-commit-reconciled', { messageHash, blockId, rev: committed.rev });
1464
+ // "attempted", not "reconciled": the callback returns void, and a quorum decline is a
1465
+ // normal, non-throwing outcome — so reaching here means the pass ran to completion, NOT
1466
+ // that anything was restored. `reconcile:restored` (reconcile-block.ts) is the line that
1467
+ // says the bytes actually landed; `reconcile:no-rev-quorum` / `reconcile:no-content-quorum`
1468
+ // say they did not.
1469
+ log('cluster-member:consensus-commit-reconcile-attempted', { messageHash, blockId, rev: committed.rev });
1399
1470
  } catch (err) {
1400
1471
  log('cluster-member:consensus-commit-reconcile-failed', {
1401
1472
  messageHash,
@@ -63,6 +63,31 @@ export function quorumSize(
63
63
  return Math.max(floor, Math.floor(simpleMajorityThreshold * responderCount));
64
64
  }
65
65
 
66
+ /**
67
+ * The `corroboratorCapacity` to hand {@link quorumSize}: how many peers other than the asking node
68
+ * could answer for a block at all, given `cohortPeerCount` peers currently visible (self already
69
+ * excluded) and `repairCorroborationClusterSize` — the cohort size this deployment is measured
70
+ * against, resolved by `resolveClusterPolicy` in `cluster/cluster-policy.ts`.
71
+ *
72
+ * Deliberately the MAX of the two: the corroboration floor may be relaxed only for a cohort that is
73
+ * *genuinely* small, never for one that merely looks small. Cohort views are unauthenticated — the
74
+ * read path takes them from `IKeyNetwork.findCluster`, the commit path from a coordinator-declared
75
+ * peer set — so a partition, a self-shrunk record, or an attacker with routing influence could
76
+ * otherwise talk the requirement down to a single voter. Measuring against the resolved size keeps a
77
+ * shrunken view out of the relaxed branch.
78
+ *
79
+ * An unconfigured node resolves this to its `clusterSize` (default 10), so the floor of two binds and
80
+ * a shrunken view gains nothing. The escape hatch for a real two-node deployment is one explicit
81
+ * operator declaration — `clusterPolicy.assumedClusterSize: 2`, which does NOT also drop the
82
+ * replication factor, or an honest `clusterSize: 2`.
83
+ *
84
+ * Shared by both restoration paths so the two can never drift apart on the rule that decides how
85
+ * much trust a lone peer gets.
86
+ */
87
+ export function corroboratorCapacity(cohortPeerCount: number, repairCorroborationClusterSize: number): number {
88
+ return Math.max(cohortPeerCount, repairCorroborationClusterSize - 1);
89
+ }
90
+
66
91
  /**
67
92
  * Select the highest revision corroborated by a quorum of distinct peers.
68
93
  *
@@ -80,7 +105,7 @@ export function quorumSize(
80
105
  * all — lets a genuinely tiny cohort still converge: a cohort with exactly one other peer
81
106
  * cannot produce two corroborators, so requiring two makes divergence permanent rather
82
107
  * than making it safe. Pass a capacity that a shrunken view of the network cannot talk
83
- * down (see `CoordinatorRepo.corroboratorCapacity`), or omit it to keep the floor at two.
108
+ * down (see {@link corroboratorCapacity}), or omit it to keep the floor at two.
84
109
  *
85
110
  * Returns `undefined` when nothing is corroborated — an uncorroborated claim
86
111
  * must never drive restoration.
@@ -165,8 +190,8 @@ export interface BlockHashCandidate {
165
190
  * one the sole peer's content is therefore taken on its word. That extends no trust the cohort had
166
191
  * not already extended: the same peer's `(rev, actionId)` claim is equally uncorroborable at that
167
192
  * size, and a two-member cohort has no honest majority to appeal to. Pass a capacity a shrunken
168
- * view of the network cannot talk down (see `CoordinatorRepo.corroboratorCapacity` and
169
- * `reconcile-block.ts`), so only a cohort that is *genuinely* that small reaches this branch.
193
+ * view of the network cannot talk down (see {@link corroboratorCapacity}), so only a cohort that is
194
+ * *genuinely* that small reaches this branch.
170
195
  */
171
196
  export function selectQuorumBlock(
172
197
  candidates: BlockHashCandidate[],
@@ -4,7 +4,7 @@ import type { ReconcileBlockCallback } from "./cluster-repo.js";
4
4
  import type { IPeerReputation } from "../reputation/types.js";
5
5
  import { PenaltyReason } from "../reputation/types.js";
6
6
  import {
7
- selectQuorumRev, selectQuorumBlock, canonicalBlockHash,
7
+ selectQuorumRev, selectQuorumBlock, canonicalBlockHash, corroboratorCapacity,
8
8
  type RevClaim, type BlockHashCandidate, type QuorumRev
9
9
  } from "./quorum-restore.js";
10
10
  import { createLogger } from '../logger.js';
@@ -38,23 +38,32 @@ export interface ReconcileBlockDeps {
38
38
  saveReplicatedBlock: (blockId: BlockId, block: IBlock, source: ActionRev) => Promise<void>;
39
39
  /** Proportional corroboration threshold; the cohort's `simpleMajorityThreshold`. */
40
40
  simpleMajorityThreshold: number;
41
- /** Configured full cluster size — the floor for {@link corroboratorCapacity}. */
42
- clusterSize: number;
41
+ /**
42
+ * Yardstick the corroboration floor is measured against — the floor for
43
+ * {@link corroboratorCapacity}. Required, not optional: unlike the membership admission gate there
44
+ * is no "unknown" handling here, so a caller that cannot state an asserted cohort size should pass
45
+ * its configured `clusterSize` (the strict direction) rather than a small placeholder. The failure
46
+ * mode of overstating it is a block that stays unrepaired — degraded, not dead; of understating it,
47
+ * a shrunken cohort view that can relax the floor to a single voter. `resolveClusterPolicy`
48
+ * (`cluster/cluster-policy.ts`) resolves it for a real node and defaults it to `clusterSize`.
49
+ */
50
+ repairCorroborationClusterSize: number;
43
51
  /** Best-effort misbehavior reporting; a throwing implementation is swallowed. */
44
52
  reputation?: Pick<IPeerReputation, 'reportPeer'>;
45
53
  }
46
54
 
47
55
  /**
48
- * How many peers other than this node could answer for the block at all. Mirrors
49
- * `CoordinatorRepo.corroboratorCapacity` exactly, and for the same reason: the corroboration
50
- * floor may be relaxed only for a cohort that is *genuinely* small, never for one that merely
51
- * looks small. `cohortPeerIds` come from the (untrusted) coordinator-declared peer set, so a
52
- * self-shrunk record could otherwise talk the requirement down to a single voter; taking the MAX
53
- * against the configured `clusterSize` keeps that shrunken view out of the relaxed branch. The
54
- * escape hatch for a real two-node deployment is to configure `clusterSize: 2`.
56
+ * Highest revision an archive covers. Keys arrive as strings off the wire from an untrusted peer,
57
+ * so a non-numeric one is skipped rather than poisoning the maximum with `NaN`; folding instead of
58
+ * `Math.max(...keys)` also keeps a wide archive off the argument-count limit.
55
59
  */
56
- function corroboratorCapacity(targets: string[], clusterSize: number): number {
57
- return Math.max(targets.length, clusterSize - 1);
60
+ function maxRevision(revisions: BlockArchive['revisions']): number | undefined {
61
+ let max: number | undefined;
62
+ for (const key of Object.keys(revisions)) {
63
+ const rev = Number(key);
64
+ if (Number.isFinite(rev) && (max === undefined || rev > max)) max = rev;
65
+ }
66
+ return max;
58
67
  }
59
68
 
60
69
  /**
@@ -64,16 +73,40 @@ function corroboratorCapacity(targets: string[], clusterSize: number): number {
64
73
  */
65
74
  function toCandidate(peerId: string, archive: BlockArchive | undefined, committedRev: number): ReconcileCandidate | undefined {
66
75
  if (!archive) return undefined;
67
- const revs = Object.keys(archive.revisions).map(Number);
68
- if (revs.length === 0) return undefined;
69
- const maxRev = Math.max(...revs);
70
- if (maxRev < committedRev) return undefined;
76
+ const maxRev = maxRevision(archive.revisions);
77
+ if (maxRev === undefined || maxRev < committedRev) return undefined;
71
78
  const data = archive.revisions[maxRev];
72
79
  if (!data?.action) return undefined;
73
80
  return { peerId, rev: maxRev, actionId: data.action.actionId, block: data.block };
74
81
  }
75
82
 
76
- /** Hash the block bytes of every candidate that both corroborates `selected` and actually carried content. */
83
+ /**
84
+ * One peer's answer, isolated. `fetchArchive` is contracted to answer `undefined` for an
85
+ * unreachable peer, but a raw `Promise.all` over the cohort would let a single rejecting fetch
86
+ * discard the answers every other peer already gave — turning a heal the cohort could complete
87
+ * into a decline. One peer's failure costs only that peer's vote.
88
+ */
89
+ async function fetchCandidate(
90
+ deps: ReconcileBlockDeps,
91
+ peerId: string,
92
+ blockId: BlockId,
93
+ committedRev: number
94
+ ): Promise<ReconcileCandidate | undefined> {
95
+ try {
96
+ return toCandidate(peerId, await deps.fetchArchive(peerId, blockId), committedRev);
97
+ } catch (err) {
98
+ log('reconcile:fetch-error', { blockId, peerId, error: (err as Error).message });
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Hash the block bytes of every candidate that both corroborates `selected` and actually carried content.
105
+ *
106
+ * NOTE: this canonical-JSON-serializes and sha256s every carrier's whole block on every reconcile.
107
+ * Negligible at today's cohort widths and block sizes; if blocks grow large or cohorts wide enough
108
+ * for this to show up on a commit-path profile, hash incrementally at receive time instead.
109
+ */
77
110
  async function hashCarriers(candidates: ReconcileCandidate[], selected: QuorumRev): Promise<BlockHashCandidate[]> {
78
111
  const carriers = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
79
112
  return await Promise.all(
@@ -130,10 +163,10 @@ export function createReconcileBlock(deps: ReconcileBlockDeps): ReconcileBlockCa
130
163
  if (targets.length === 0) return;
131
164
 
132
165
  const fetched = await Promise.all(
133
- targets.map(async peerId => toCandidate(peerId, await deps.fetchArchive(peerId, blockId), committed.rev))
166
+ targets.map(peerId => fetchCandidate(deps, peerId, blockId, committed.rev))
134
167
  );
135
168
  const candidates = fetched.filter((c): c is ReconcileCandidate => c !== undefined);
136
- const capacity = corroboratorCapacity(targets, deps.clusterSize);
169
+ const capacity = corroboratorCapacity(targets.length, deps.repairCorroborationClusterSize);
137
170
 
138
171
  const revClaims: RevClaim[] = candidates.map(({ peerId, rev, actionId }) => ({ peerId, rev, actionId }));
139
172
  const selected = selectQuorumRev(revClaims, deps.simpleMajorityThreshold, capacity);
@@ -221,6 +221,14 @@ export class SpreadOnChurnMonitor implements Startable {
221
221
  // Read block data from local storage
222
222
  const result = await this.deps.repo.get({ blockIds: [blockId] })
223
223
  const blockResult = result[blockId]
224
+ if (blockResult?.unavailable !== undefined) {
225
+ // The repo could not work out whether it still holds this block (unmaterializable
226
+ // history / failed restore). Untracking on that answer would silently drop the block
227
+ // from the spread set on a guess, and only a later re-commit would put it back — so
228
+ // keep it tracked and let the next sweep (or a heal) settle it.
229
+ log('unavailable block=%s reason=%s (keeping tracked)', blockId, blockResult.unavailable)
230
+ continue
231
+ }
224
232
  if (!blockResult?.block) {
225
233
  // The block has left local storage. No deletion event exists today to evict it
226
234
  // from the tracked set, so prune here. Deleting the current element of a Set mid
@@ -32,6 +32,12 @@
32
32
  * - **Cost**: the predicate sits in the hot path of every inbound stream, ahead of the work
33
33
  * that stream would do. Embedders are expected to make it cheap — an in-memory set lookup —
34
34
  * and to memoize anything that would otherwise hit storage or the network per stream.
35
+ *
36
+ * NOTE: denial is stateless and unthrottled — a denied peer may reopen streams as fast as
37
+ * libp2p's per-connection `maxInboundStreams` allows, and nothing here records the denial. That
38
+ * is fine while the predicate is an in-memory lookup. If a denied peer ever shows up as load, the
39
+ * fix is upstream of this module, not inside it: feed denials into `PeerReputationService`, or
40
+ * refuse the peer at the connection level with `NodeOptions.connectionGater`.
35
41
  */
36
42
 
37
43
  /**
@@ -299,7 +299,25 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
299
299
  return { allow: true, reason: 'extended-isolation', warn: true };
300
300
  }
301
301
 
302
+ /**
303
+ * Memoize the coordinator for a key. A pick of SELF is deliberately ignored — the
304
+ * cache is consulted ahead of every selection tier, so a self entry would keep the
305
+ * key routed at our own (possibly stale) replica for the full TTL long after a
306
+ * better-placed peer became reachable, and would return self without re-consulting
307
+ * {@link shouldAllowSelfCoordination}, letting a partitioned node silently serve its
308
+ * own data. Self needs no memoizing anyway: every tier that can select it re-derives
309
+ * it from a local lookup with no dial and no retry sleep.
310
+ *
311
+ * The gate lives here rather than at each call site because most writers are OUTSIDE
312
+ * this class — `recordCoordinator` is public and is fed self-valued picks by
313
+ * `NetworkTransactor` (it writes back whatever `findCoordinator` returned, including
314
+ * self) and by `RepoClient`/`ClusterClient` on redirect responses.
315
+ */
302
316
  public recordCoordinator(key: Uint8Array, peerId: PeerId, ttlMs = 30 * 60 * 1000): void {
317
+ if (peerId.toString() === this.libp2p.peerId.toString()) {
318
+ this.log('coordinator-cache:self-write-ignored key=%s', this.toCacheKey(key).substring(0, 12))
319
+ return
320
+ }
303
321
  const k = this.toCacheKey(key)
304
322
  const now = Date.now()
305
323
  for (const [ck, entry] of this.coordinatorCache) {
@@ -417,11 +435,39 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
417
435
  this.log('findCoordinator:fret-neighbors key=%s candidates=%d', keyStr, ids.length)
418
436
  if (verbose) this.log('findCoordinator:fret-candidates key=%s ids=%o connected=%o', keyStr, ids, Array.from(connectedSet))
419
437
 
420
- // Filter to only connected FRET neighbors, excluding banned peers
438
+ // Filter to only connected FRET neighbors, excluding banned peers. Self is
439
+ // never "connected" to itself, so it is admitted by the explicit self clause
440
+ // below — but ONLY when the self-coordination guard allows it, otherwise a
441
+ // node whose FRET neighborhood contains self (essentially always on a small or
442
+ // forming network) would bypass the guard and the last-resort tier's
443
+ // SELF_COORDINATION_BLOCKED would never fire. On refusal self is merely DROPPED
444
+ // from the candidate list, so the connected-peer fallback below still gets its
445
+ // chance at a good remote peer; only if that also comes up empty does the
446
+ // last-resort tier raise the accurate error.
447
+ const selfStr = this.libp2p.peerId.toString()
448
+ let selfAllowedThisAttempt: boolean | undefined
449
+ // Memoized per ATTEMPT, and evaluated lazily so an all-remote neighborhood never
450
+ // pays detectPartition() / getNetworkSizeEstimate(). Re-evaluated on each attempt
451
+ // because a connection can land during the 500ms inter-attempt sleep and
452
+ // legitimately flip the answer — as filterByMembership re-reads the peerStore.
453
+ // NOTE: on a small network self is a neighbor of nearly every key, so this runs
454
+ // per findCoordinator call and self-coordinated keys are never cached to absorb
455
+ // it. Fine while detectPartition()/getNetworkSizeEstimate() stay local FRET
456
+ // table reads; if either ever grows a probe or other network round-trip, cache
457
+ // the decision with a short TTL on the instance instead of per attempt.
458
+ const isSelfAdmissible = (): boolean => {
459
+ if (selfAllowedThisAttempt === undefined) {
460
+ const decision = this.shouldAllowSelfCoordination()
461
+ selfAllowedThisAttempt = decision.allow
462
+ if (!decision.allow) {
463
+ this.log('findCoordinator:fret-self-dropped key=%s reason=%s attempt=%d', keyStr, decision.reason, attempt)
464
+ }
465
+ }
466
+ return selfAllowedThisAttempt
467
+ }
421
468
  const connectedFretIds = ids
422
- .filter(id => (connectedSet.has(id) || id === this.libp2p.peerId.toString())
423
- && !excludedSet.has(id)
424
- && !(this.reputation?.isBanned(id)))
469
+ .filter(id => !excludedSet.has(id) && !(this.reputation?.isBanned(id)))
470
+ .filter(id => connectedSet.has(id) || (id === selfStr && isSelfAdmissible()))
425
471
  .sort((a, b) => (this.reputation?.getScore(a) ?? 0) - (this.reputation?.getScore(b) ?? 0))
426
472
  this.log('findCoordinator:fret-connected key=%s count=%d peers=%o', keyStr, connectedFretIds.length, connectedFretIds.map(s => s.substring(0, 12)))
427
473
 
@@ -438,6 +484,8 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
438
484
  const pick = ranked[0]
439
485
  if (pick) {
440
486
  const pid = peerIdFromString(pick)
487
+ // A self pick is a no-op here — recordCoordinator ignores self-valued
488
+ // writes (see its doc comment), matching the last-resort self tier below.
441
489
  this.recordCoordinator(key, pid)
442
490
  this.log('findCoordinator:done key=%s ms=%d source=%s', keyStr, Date.now() - t0, 'fret')
443
491
  return pid
@@ -451,6 +499,8 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
451
499
  // `unknown` peer is never picked). Note this candidate set is built from
452
500
  // connected REMOTE peers and never includes self, so when no serving peer is
453
501
  // present selection falls through to the last-resort self-coordination block.
502
+ // Being remote-only, this tier needs no self-coordination guard check, unlike the
503
+ // FRET tier above.
454
504
  const connectedCandidates = connected
455
505
  .filter(p => !excludedSet.has(p.toString()) && !(this.reputation?.isBanned(p.toString())))
456
506
  .sort((a, b) => (this.reputation?.getScore(a.toString()) ?? 0) - (this.reputation?.getScore(b.toString()) ?? 0))
@@ -21,6 +21,7 @@ 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
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';
@@ -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,
@@ -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,29 +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
- /**
178
- * Desired cluster size per key (default 10). Beyond sizing the cohort, this is the
179
- * node's declaration of how many peers *should* exist to corroborate a claim: the
180
- * read-repair corroboration floor is measured against it, so a genuine two-node
181
- * deployment must set `clusterSize: 2` for its members to be able to repair each
182
- * other (see `CoordinatorRepo.corroboratorCapacity`).
183
- */
184
- clusterSize?: number;
185
- clusterPolicy?: {
186
- allowDownsize?: boolean;
187
- sizeTolerance?: number; // acceptable relative difference (e.g. 0.5 = +/-50%)
188
- superMajorityThreshold?: number; // fraction of peers needed for super-majority (default: DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75)
189
- /**
190
- * Opt in to transacting below the safe cluster-size floor when FRET has no confident
191
- * network-size estimate — the membership-admission and coordinator small-cluster gates
192
- * both fail closed without it. Default false. Turn on only for single-node / local dev
193
- * meshes that knowingly run undersized.
194
- */
195
- allowUnvalidatedSmallCluster?: boolean;
196
- };
197
-
198
182
  /** Override libp2p listen multiaddrs. */
199
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[];
200
196
  /** Override libp2p transports. */
201
197
  transports?: Libp2pTransports;
202
198
 
@@ -305,9 +301,10 @@ export type NodeOptions = {
305
301
  * dialing peer's `PeerId.toString()`. See {@link AuthorizeInboundStream} and
306
302
  * `docs/internals.md` § Inbound Stream Authorization.
307
303
  *
308
- * NOTE: this covers the four database protocols only. The reactivity, matchmaking,
304
+ * NOTE: this covers the four database protocols only. The dispute, reactivity, matchmaking,
309
305
  * cohort-topic and libp2p built-in (identify/ping/…) protocols this node also registers are
310
- * NOT gated by it.
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}.
311
308
  */
312
309
  authorizeInboundStream?: AuthorizeInboundStream;
313
310
 
@@ -464,8 +461,13 @@ export async function createLibp2pNodeBase(
464
461
  const libp2pOptions: Libp2pInit = {
465
462
  start: false,
466
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.
467
467
  addresses: {
468
- listen: listenAddrs
468
+ listen: listenAddrs,
469
+ ...(options.announceAddrs ? { announce: options.announceAddrs } : {}),
470
+ ...(options.appendAnnounceAddrs ? { appendAnnounce: options.appendAnnounceAddrs } : {})
469
471
  },
470
472
  connectionManager: {
471
473
  // `autoDial`, `minConnections`, and `dialQueue` were stale libp2p option keys silently
@@ -603,7 +605,9 @@ export async function createLibp2pNodeBase(
603
605
  });
604
606
  return serviceFactory({
605
607
  registrar: components.registrar,
606
- 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
607
611
  });
608
612
  },
609
613
 
@@ -696,21 +700,11 @@ export async function createLibp2pNodeBase(
696
700
  const partitionDetector = new PartitionDetector();
697
701
  const fretSvc = (node as any).services?.fret as FretService | undefined;
698
702
 
699
- const consensusConfig = {
700
- superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
701
- simpleMajorityThreshold: 0.51,
702
- minAbsoluteClusterSize: 2,
703
- allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
704
- clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
705
- // Fail closed by default (an undersized cluster with no confident network-size estimate is
706
- // rejected); embedders running knowingly-small meshes opt in through clusterPolicy.
707
- allowUnvalidatedSmallCluster: options.clusterPolicy?.allowUnvalidatedSmallCluster ?? false,
708
- partitionDetectionWindow: 60000,
709
- // Configured full cluster size — the member's own reference for "full size" in the membership
710
- // admission gate (a below-full-size declared set under low FRET confidence is refused as a possible
711
- // self-shrink). Matches the size threaded into the coordinator below.
712
- clusterSize: options.clusterSize ?? 10
713
- };
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);
714
708
 
715
709
  // Fetch a block archive from one cohort peer over the sync protocol, bounded by a
716
710
  // per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
@@ -741,12 +735,17 @@ export async function createLibp2pNodeBase(
741
735
  // (cohort drift, or a refused `missing-base-revision` commit). See `reconcile-block.ts` for
742
736
  // the corroboration rules — in particular why both quorums are capped by how many peers
743
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.
744
743
  const reconcileBlock: ReconcileBlockCallback = createReconcileBlock({
745
744
  selfPeerId: node.peerId.toString(),
746
745
  fetchArchive: fetchArchiveFromPeer,
747
746
  saveReplicatedBlock: (blockId, block, source) => storageRepo.saveReplicatedBlock(blockId, block, source),
748
747
  simpleMajorityThreshold: consensusConfig.simpleMajorityThreshold,
749
- clusterSize: consensusConfig.clusterSize,
748
+ repairCorroborationClusterSize: consensusConfig.repairCorroborationClusterSize,
750
749
  reputation
751
750
  });
752
751