@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
@@ -0,0 +1,152 @@
1
+ import { DEFAULT_SUPER_MAJORITY_THRESHOLD, type ClusterConsensusConfig } from "@optimystic/db-core";
2
+
3
+ /**
4
+ * Resolves the operator-facing cluster knobs (`clusterSize`, `clusterPolicy.*`) into the concrete
5
+ * numbers the consensus and block-restoration paths run on.
6
+ *
7
+ * Extracted from `createLibp2pNodeBase` rather than left inline so the composition root's defaults
8
+ * are assertable without booting a libp2p node — the layer a real deployment actually uses, and
9
+ * therefore the layer where a default that relaxed the repair corroboration floor to a single voter
10
+ * survived unnoticed (see `test/cluster-policy.spec.ts`).
11
+ *
12
+ * ## Why two size yardsticks, not one
13
+ *
14
+ * One operator field — `clusterPolicy.assumedClusterSize`, "the smallest cohort this deployment can
15
+ * genuinely field" — feeds two consumers whose failure modes point in opposite directions, so its
16
+ * *default* cannot serve both:
17
+ *
18
+ * - **Membership admission gate** (`cluster/cluster-repo.ts`, `admitMembership`) reads it only on its
19
+ * fallback path, when this node has no confident network-size estimate. Too small: a
20
+ * partition-induced downsize slips past while the node is unconfident. Too large: the node refuses
21
+ * legitimate writes — unavailability. It wants a *permissive* default, because an unconfigured
22
+ * two-node mesh must still be able to transact. It gets {@link minAbsoluteClusterSize} (2).
23
+ * - **Repair corroboration floor** (`corroboratorCapacity` in `cluster/quorum-restore.ts`, called by
24
+ * `CoordinatorRepo.queryClusterForLatest` and `createReconcileBlock`) reads it on *every* repair,
25
+ * unconditionally. Too small: a shrunken — and always unauthenticated — cohort view buys a lone
26
+ * peer full trust. Too large: a block stays unrepaired, degraded rather than dead. It wants a
27
+ * *strict* default. It gets {@link ResolvedClusterPolicy.repairCorroborationClusterSize}, which
28
+ * falls back to `clusterSize` (the configured replication factor).
29
+ *
30
+ * A single explicit `clusterPolicy.assumedClusterSize` still sets BOTH — an operator declaring their
31
+ * real cohort size means it for both consumers. Only the unconfigured case diverges.
32
+ *
33
+ * So a genuine two-node mesh needs exactly one setting to self-repair: either
34
+ * `clusterPolicy.assumedClusterSize: 2` (which does not lower the replication factor) or an honest
35
+ * `clusterSize: 2`. Writes and voting still work with zero configuration.
36
+ *
37
+ * ## Future
38
+ *
39
+ * Deriving the yardstick from observation (the largest peer group this node has ever seen for the
40
+ * key) would remove the trade entirely and subsume both values. Filed as backlog
41
+ * `feat-admission-floor-from-observed-cohort-high-water-mark`; do not build it here.
42
+ */
43
+
44
+ /**
45
+ * Absolute floor below which no cohort is safe, whatever the size references say. Named rather than
46
+ * inlined because the admission gate's `assumedClusterSize` defaults to exactly this value — the two
47
+ * must not drift.
48
+ */
49
+ export const minAbsoluteClusterSize = 2;
50
+
51
+ /**
52
+ * The operator-facing cluster knobs. `NodeOptions` (`libp2p-node-base.ts`) intersects this rather
53
+ * than restating it, so a knob added here is one `resolveClusterPolicy` is guaranteed to see — a
54
+ * second declaration would compile fine and be silently dropped.
55
+ */
56
+ export interface ClusterPolicyOptions {
57
+ /**
58
+ * Desired cluster size per key (default 10) — the replication factor / target cohort breadth
59
+ * the coordinator aims for. NOT a statement about how many peers actually exist, so the
60
+ * membership admission gate is never measured against it (see `cluster/cluster-repo.ts`).
61
+ *
62
+ * The read-repair/reconcile corroboration floor DOES fall back to it when
63
+ * `clusterPolicy.assumedClusterSize` is absent — the strict direction, so an unconfigured node
64
+ * cannot have its floor talked down by a shrunken cohort view. A deployment that genuinely runs
65
+ * fewer peers than this should declare `clusterPolicy.assumedClusterSize`.
66
+ */
67
+ clusterSize?: number;
68
+ clusterPolicy?: {
69
+ allowDownsize?: boolean;
70
+ /** Acceptable relative difference (e.g. 0.5 = +/-50%). */
71
+ sizeTolerance?: number;
72
+ /** Fraction of peers needed for super-majority (default {@link DEFAULT_SUPER_MAJORITY_THRESHOLD}). */
73
+ superMajorityThreshold?: number;
74
+ /**
75
+ * Opt in to transacting below the safe cluster-size floor when FRET has no confident
76
+ * network-size estimate — the membership-admission and coordinator small-cluster gates both
77
+ * fail closed without it. Default false. Turn on only for single-node / local dev meshes that
78
+ * knowingly run undersized.
79
+ */
80
+ allowUnvalidatedSmallCluster?: boolean;
81
+ /**
82
+ * The smallest cohort this deployment can genuinely field — normally the number of nodes you
83
+ * actually run, capped at `clusterSize`. Two consumers read it: the membership admission gate,
84
+ * on its fallback path when the node has no confident network-size estimate; and the
85
+ * read-repair/reconcile corroboration floor (`corroboratorCapacity`), unconditionally.
86
+ *
87
+ * Declaring it sets BOTH. Leaving it unset does NOT — see the module doc for why the two
88
+ * defaults point in opposite directions. A large deployment should still set this to its real
89
+ * cohort size, otherwise the admission gate cannot police a partition-induced downsize while
90
+ * its size estimate is unconfident; a genuine two-node mesh needs it (or an honest
91
+ * `clusterSize: 2`) to self-repair.
92
+ */
93
+ assumedClusterSize?: number;
94
+ };
95
+ }
96
+
97
+ /** Everything a node's consensus + restoration paths need, with every default already applied. */
98
+ export type ResolvedClusterPolicy = ClusterConsensusConfig & {
99
+ /** Replication factor / target cohort breadth. Always concrete after resolution. */
100
+ clusterSize: number;
101
+ /**
102
+ * Yardstick the repair corroboration floor measures a (possibly shrunken, always unauthenticated)
103
+ * cohort view against — see `corroboratorCapacity` in `cluster/quorum-restore.ts`.
104
+ *
105
+ * Deliberately distinct from {@link ClusterConsensusConfig.assumedClusterSize}, which the
106
+ * membership admission gate reads: the two share an operator field but not a default, because
107
+ * over- and under-stating them cost opposite things. See the module doc.
108
+ */
109
+ repairCorroborationClusterSize: number;
110
+ };
111
+
112
+ /**
113
+ * Apply every cluster-policy default a node needs. Pure — same options in, same numbers out — so the
114
+ * composition root's behavior is unit-testable (`test/cluster-policy.spec.ts`).
115
+ */
116
+ export function resolveClusterPolicy(options: ClusterPolicyOptions): ResolvedClusterPolicy {
117
+ // undefined here means "the operator said nothing", which is the only case where the two
118
+ // yardsticks below diverge.
119
+ //
120
+ // NOTE: a declared value is passed through unvalidated. The admission gate floors a degenerate one
121
+ // (0, negative, NaN, Infinity) itself — see `cluster-repo.admissionFloor` and its specs — but
122
+ // `corroboratorCapacity` does not: NaN there makes every quorum comparison false, so repair
123
+ // silently declines forever. Fail-safe, and unreachable through the reference-peer CLI (which
124
+ // rejects non-positive integers). If another composition root starts accepting unvalidated config,
125
+ // clamp here rather than in each consumer.
126
+ const declaredCohortSize = options.clusterPolicy?.assumedClusterSize;
127
+ const clusterSize = options.clusterSize ?? 10;
128
+
129
+ return {
130
+ superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
131
+ simpleMajorityThreshold: 0.51,
132
+ minAbsoluteClusterSize,
133
+ allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
134
+ clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
135
+ // Fail closed by default (an undersized cluster with no confident network-size estimate is
136
+ // rejected); embedders running knowingly-small meshes opt in through clusterPolicy.
137
+ allowUnvalidatedSmallCluster: options.clusterPolicy?.allowUnvalidatedSmallCluster ?? false,
138
+ partitionDetectionWindow: 60000,
139
+ // Replication factor / target cohort breadth — what the coordinator aims for when selecting a
140
+ // cohort. Deliberately NOT the membership admission gate's yardstick: it says nothing about how
141
+ // many peers actually exist, so an unconfigured small mesh would refuse every write.
142
+ clusterSize,
143
+ // Membership admission gate, fallback path only (no confident network-size estimate). Defaults
144
+ // permissive so a two- or three-node mesh transacts unconfigured; the cost of that default is
145
+ // bounded to the gate, since the repair floor no longer reads this field.
146
+ assumedClusterSize: declaredCohortSize ?? minAbsoluteClusterSize,
147
+ // Repair corroboration floor, every repair. Defaults strict — to the replication factor — so an
148
+ // unconfigured node cannot have its floor talked down to a single voter by a shrunken cohort
149
+ // view. A genuinely small mesh declares its size (either field) to regain self-repair.
150
+ repairCorroborationClusterSize: declaredCohortSize ?? clusterSize
151
+ };
152
+ }
@@ -17,6 +17,8 @@ import type { FretService } from "p2p-fret";
17
17
  import type { IPeerReputation } from "../reputation/types.js";
18
18
  import { PenaltyReason } from "../reputation/types.js";
19
19
  import type { ITransactionStateStore } from "./i-transaction-state-store.js";
20
+ import { isMissingBaseRevisionFailure } from "../storage/storage-repo.js";
21
+ import { RECONCILE_TIMEOUT_MS } from "./reconcile-block.js";
20
22
 
21
23
  const log = createLogger('cluster-member')
22
24
 
@@ -115,9 +117,10 @@ export type ExpectedClusterView = {
115
117
  * Independently derive this member's own view of a block's responsible cluster. Injected so
116
118
  * {@link ClusterMember} stays transport-agnostic — the composition root supplies it from
117
119
  * `IKeyNetwork.findCluster` + FRET (mirroring how the coordinator derives the cluster). Absent on nodes
118
- * that cannot derive a view (no FRET, unit tests): with no derived view AND no configured full-size
119
- * reference the gate preserves legacy approve behavior, but a configured `clusterSize` still lets the gate
120
- * 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.
121
124
  */
122
125
  export type DeriveExpectedClusterCallback = (blockId: BlockId) => Promise<ExpectedClusterView>;
123
126
 
@@ -176,7 +179,8 @@ const ExecutedTransactionTtlMs = 10 * 60 * 1000;
176
179
  // Upper bound on an awaited active reconciliation of a divergent commit. Bounds the
177
180
  // consensus path so a slow/unreachable cohort peer can't stall the cluster stream;
178
181
  // a timeout is logged and tolerated (never thrown — that would reset the stream).
179
- const ReconcileTimeoutMs = 5000;
182
+ // Shared with the read path's acquisition (see RECONCILE_TIMEOUT_MS) — same operation, same bound.
183
+ const ReconcileTimeoutMs = RECONCILE_TIMEOUT_MS;
180
184
 
181
185
  /**
182
186
  * True when a thrown storage error reports a missing pending action — i.e. this
@@ -229,8 +233,8 @@ export class ClusterMember implements ICluster {
229
233
  private readonly minAbsoluteClusterSize: number;
230
234
  private readonly clusterSizeTolerance: number;
231
235
  private readonly membershipAdmissionFraction: number;
232
- /** Configured full cluster size, or undefined when unknown (then the gate cannot judge a downsize). */
233
- private readonly configuredClusterSize: number | undefined;
236
+ /** Operator-asserted smallest genuine cohort size, or undefined when unknown. */
237
+ private readonly assumedClusterSize: number | undefined;
234
238
  private readonly allowUnvalidatedSmallCluster: boolean;
235
239
 
236
240
  constructor(
@@ -256,8 +260,17 @@ export class ClusterMember implements ICluster {
256
260
  this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
257
261
  this.clusterSizeTolerance = consensusConfig?.clusterSizeTolerance ?? 0.5;
258
262
  this.membershipAdmissionFraction = consensusConfig?.membershipAdmissionFraction ?? 0.75;
259
- this.configuredClusterSize = consensusConfig?.clusterSize;
263
+ this.assumedClusterSize = consensusConfig?.assumedClusterSize;
260
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
+ });
261
274
  // Periodically clean up expired transactions (.unref() so tests/short-lived processes can exit)
262
275
  this.expirationInterval = setInterval(() => this.queueExpiredTransactions(), 60000);
263
276
  this.expirationInterval.unref();
@@ -855,12 +868,18 @@ export class ClusterMember implements ICluster {
855
868
  * of a peer or two is absorbed, a wholesale-disjoint or half-size set is not.
856
869
  *
857
870
  * **Fail-closed posture.** When the member cannot confidently derive `E` (no capability, low FRET
858
- * confidence — exactly what a partition induces), it must refuse any *downsizing* decision: a
859
- * below-full-size `D` is rejected against the configured full `clusterSize`. With NEITHER a confident
860
- * view NOR a configured full size the gate cannot judge a downsize at all, so it preserves the legacy
861
- * approve behavior (backward-compatible for nodes/tests with no derivation wired). `allowUnvalidatedSmallCluster`
862
- * is the explicit opt-in (single-node / local dev knowingly below the safe floor), matching the
863
- * 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.
864
883
  */
865
884
  private async admitMembership(record: ClusterRecord): Promise<{ admit: boolean; reason?: string }> {
866
885
  const ourId = this.peerId.toString();
@@ -891,30 +910,42 @@ export class ClusterMember implements ICluster {
891
910
  && derivedSize > 0;
892
911
 
893
912
  if (!confident) {
894
- // Fail closed for downsizing under low/absent confidence. A full-size (or larger) declared set is
895
- // still admitted there is nothing to shrink. Without a configured full-size reference we cannot
896
- // tell a downsize from a legitimate small cluster, so we preserve legacy approve behavior.
897
- 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) {
898
917
  return { admit: true };
899
918
  }
900
- if (declared.length >= this.configuredClusterSize) {
919
+ const floor = this.admissionFloor(this.assumedClusterSize);
920
+ if (declared.length >= floor) {
901
921
  return { admit: true };
902
922
  }
903
923
  log('cluster-member:admission-reject', {
904
924
  messageHash: record.messageHash,
905
925
  reason: 'low-confidence-downsize',
906
926
  declaredSize: declared.length,
907
- configuredClusterSize: this.configuredClusterSize,
927
+ floor,
928
+ assumedClusterSize: this.assumedClusterSize,
908
929
  confidence: derived?.confidence
909
930
  });
910
- 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
+ };
911
942
  }
912
943
 
913
944
  const expected = Object.keys(derived!.peers ?? {});
914
945
  const kEst = expected.length;
915
946
 
916
947
  // Predicate 2: floor derived from the member's OWN confident estimate.
917
- const floor = Math.max(this.minAbsoluteClusterSize, Math.ceil(this.membershipAdmissionFraction * kEst));
948
+ const floor = this.admissionFloor(kEst);
918
949
  if (declared.length < floor) {
919
950
  log('cluster-member:admission-reject', {
920
951
  messageHash: record.messageHash,
@@ -923,7 +954,10 @@ export class ClusterMember implements ICluster {
923
954
  floor,
924
955
  kEst
925
956
  });
926
- 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
+ };
927
961
  }
928
962
 
929
963
  // Predicate 3: consistency with the derived view within tolerance.
@@ -944,6 +978,27 @@ export class ClusterMember implements ICluster {
944
978
  return { admit: true };
945
979
  }
946
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
+
947
1002
  /**
948
1003
  * Derive this member's own view of the record's block cluster via the injected capability, or
949
1004
  * `undefined` when it cannot (no capability, no coordinating block id, or a derivation error — all of
@@ -997,6 +1052,20 @@ export class ClusterMember implements ICluster {
997
1052
  const blockResults = await this.storageRepo.get({ blockIds });
998
1053
  for (const blockId of blockIds) {
999
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
+ }
1000
1069
  const latestRev = blockResult?.state?.latest?.rev;
1001
1070
  if (latestRev !== undefined && latestRev >= pendRequest.rev) {
1002
1071
  log('cluster-member:validation-stale-revision', {
@@ -1124,9 +1193,13 @@ export class ClusterMember implements ICluster {
1124
1193
  * - **behind**: we missed the prior `pend` cluster-transaction (cohort drift
1125
1194
  * between the independent pend and commit phases, or transient unreachability),
1126
1195
  * so we lack the pending action — `StorageRepo.commit` *throws* "Pending
1127
- * action … not found".
1196
+ * action … not found";
1197
+ * - **behind (no base)**: we DID see the pend, but we never saw the revision that
1198
+ * created the block, so the transform has nothing to apply to —
1199
+ * `StorageRepo.commit` returns `success:false` with a `missing-base-revision`
1200
+ * reason rather than recording a revision it could not materialize.
1128
1201
  *
1129
- * For the **behind** case we hold no revision of the committed blocks at all, so we
1202
+ * For both **behind** cases we hold no usable revision of the committed blocks, so we
1130
1203
  * actively reconcile: pull the committed revision from a cohort peer that holds it
1131
1204
  * (`reconcileBlock`) and restore it locally. Lazy read-repair on a later read cannot
1132
1205
  * recover it on its own when cohort drift has left the block under-replicated (no
@@ -1215,7 +1288,8 @@ export class ClusterMember implements ICluster {
1215
1288
  }
1216
1289
  if (!result.success) {
1217
1290
  // success:false is a StaleFailure. `missing` ⇒ ahead/stale divergence
1218
- // (we already hold ≥ this rev): tolerate, do NOT reconcile downward. A bare
1291
+ // (we already hold ≥ this rev): tolerate, do NOT reconcile downward. A
1292
+ // missing-base reason ⇒ behind divergence, reconcile (below). Any other bare
1219
1293
  // `reason` with no `missing` ⇒ a genuine internalCommit fault: propagate so
1220
1294
  // handleConsensus rolls back the executed marker and rethrows.
1221
1295
  if (result.missing?.length) {
@@ -1228,6 +1302,22 @@ export class ClusterMember implements ICluster {
1228
1302
  });
1229
1303
  return;
1230
1304
  }
1305
+ // This member holds no materializable base for one of the blocks, so
1306
+ // `StorageRepo.commit` REFUSED rather than record a revision it could never serve.
1307
+ // Same "behind" divergence as a missing pend — and the same cure: pull the committed
1308
+ // revision from a cohort peer. Reconciling here (after commit released its per-block
1309
+ // latches) is what makes refusing safe; fetching inside the commit path would deadlock
1310
+ // against the latch `saveReplicatedBlock` needs to persist what it fetched.
1311
+ if (isMissingBaseRevisionFailure(result)) {
1312
+ log('cluster-member:consensus-commit-diverged', {
1313
+ messageHash,
1314
+ actionId: commit.actionId,
1315
+ divergence: 'behind',
1316
+ reason: result.reason
1317
+ });
1318
+ await this.reconcileDivergentCommit(record, commit);
1319
+ return;
1320
+ }
1231
1321
  throw new Error(`Consensus commit for action ${commit.actionId} failed: ${result.reason ?? 'unknown reason'}`);
1232
1322
  }
1233
1323
  return;
@@ -1371,7 +1461,12 @@ export class ClusterMember implements ICluster {
1371
1461
  private async reconcileOneBlock(messageHash: string, blockId: BlockId, committed: ActionRev, cohortPeerIds: string[]): Promise<void> {
1372
1462
  try {
1373
1463
  await this.withReconcileTimeout(this.reconcileBlock!(blockId, committed, cohortPeerIds), blockId);
1374
- 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 });
1375
1470
  } catch (err) {
1376
1471
  log('cluster-member:consensus-commit-reconcile-failed', {
1377
1472
  messageHash,
Binary file
@@ -0,0 +1,191 @@
1
+ import type { ActionRev, BlockId, IBlock } from "@optimystic/db-core";
2
+ import type { BlockArchive } from "../storage/struct.js";
3
+ import type { ReconcileBlockCallback } from "./cluster-repo.js";
4
+ import type { IPeerReputation } from "../reputation/types.js";
5
+ import { PenaltyReason } from "../reputation/types.js";
6
+ import {
7
+ selectQuorumRev, selectQuorumBlock, canonicalBlockHash, corroboratorCapacity,
8
+ type RevClaim, type BlockHashCandidate, type QuorumRev
9
+ } from "./quorum-restore.js";
10
+ import { createLogger } from '../logger.js';
11
+
12
+ const log = createLogger('reconcile-block');
13
+
14
+ /**
15
+ * Wall-clock bound on one whole reconcile pass (all cohort peers, both quorums, the persist).
16
+ * Shared by both callers so a slow or unreachable cohort peer stalls neither the commit path
17
+ * (`ClusterMember.withReconcileTimeout` — a stall there holds up consensus execution) nor the read
18
+ * path (`CoordinatorRepo.restoreCorroborated` — a stall there holds up a caller's `get`).
19
+ */
20
+ export const RECONCILE_TIMEOUT_MS = 5000;
21
+
22
+ /** One cohort peer's answer for a block: its highest revision, and the block bytes if it carried them. */
23
+ interface ReconcileCandidate {
24
+ peerId: string;
25
+ rev: number;
26
+ actionId: string;
27
+ /** Present only when the serving archive carried a materialized block for `rev`. */
28
+ block?: IBlock;
29
+ }
30
+
31
+ /** Collaborators {@link createReconcileBlock} needs, injected so the logic stays transport-agnostic. */
32
+ export interface ReconcileBlockDeps {
33
+ /** This node's own peer id; excluded from the cohort targets. */
34
+ selfPeerId: string;
35
+ /** Fetch one cohort peer's archive for `blockId` — `undefined` when it is unreachable or holds nothing. */
36
+ fetchArchive: (peerId: string, blockId: BlockId) => Promise<BlockArchive | undefined>;
37
+ /** Persist the agreed content through the churn-replication funnel. */
38
+ saveReplicatedBlock: (blockId: BlockId, block: IBlock, source: ActionRev) => Promise<void>;
39
+ /** Proportional corroboration threshold; the cohort's `simpleMajorityThreshold`. */
40
+ simpleMajorityThreshold: 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;
51
+ /** Best-effort misbehavior reporting; a throwing implementation is swallowed. */
52
+ reputation?: Pick<IPeerReputation, 'reportPeer'>;
53
+ }
54
+
55
+ /**
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.
59
+ */
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;
67
+ }
68
+
69
+ /**
70
+ * The claim a peer's archive makes: its highest revision, provided that revision is at least the
71
+ * one we committed. `undefined` when the peer served nothing usable (unreachable, empty archive,
72
+ * or only revisions older than the commit we are healing).
73
+ */
74
+ function toCandidate(peerId: string, archive: BlockArchive | undefined, committedRev: number): ReconcileCandidate | undefined {
75
+ if (!archive) return undefined;
76
+ const maxRev = maxRevision(archive.revisions);
77
+ if (maxRev === undefined || maxRev < committedRev) return undefined;
78
+ const data = archive.revisions[maxRev];
79
+ if (!data?.action) return undefined;
80
+ return { peerId, rev: maxRev, actionId: data.action.actionId, block: data.block };
81
+ }
82
+
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
+ */
110
+ async function hashCarriers(candidates: ReconcileCandidate[], selected: QuorumRev): Promise<BlockHashCandidate[]> {
111
+ const carriers = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
112
+ return await Promise.all(
113
+ carriers.map(async c => ({ peerId: c.peerId, hash: await canonicalBlockHash(c.block!), block: c.block! }))
114
+ );
115
+ }
116
+
117
+ /** Report cohort members that served content contradicting the agreed hash. Best-effort; never throws. */
118
+ function penalizeContradictingContent(
119
+ reputation: Pick<IPeerReputation, 'reportPeer'> | undefined,
120
+ candidates: BlockHashCandidate[],
121
+ agreedHash: string,
122
+ blockId: BlockId
123
+ ): void {
124
+ if (!reputation) return;
125
+ try {
126
+ for (const c of candidates) {
127
+ if (c.hash !== agreedHash) {
128
+ reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
129
+ }
130
+ }
131
+ } catch (err) {
132
+ log('reconcile:penalize-error', { blockId, error: (err as Error).message });
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Active reconciliation for a block this member committed without a materializable base
138
+ * (cohort drift between the independent pend and commit cluster-transactions, or a refused
139
+ * `missing-base-revision` commit). Queries the commit cohort — self already excluded by
140
+ * `ClusterMember.reconcileDivergentCommit` — for the block, picks the target revision by quorum
141
+ * corroboration rather than raw `Math.max` (a lone peer inflating its rev cannot steer
142
+ * reconciliation), verifies the cohort agrees on the *content* at that revision, and persists it.
143
+ *
144
+ * Both quorums are capped by {@link corroboratorCapacity}: demanding two corroborators from a
145
+ * cohort that contains exactly one other peer is a permanent deadlock, not a safety property —
146
+ * the node can never heal and stays unreadable forever.
147
+ *
148
+ * **Exposure at capacity 1 (documented, not accidental).** Block ids are random 256-bit strings
149
+ * (`db-core` `structs.ts`), NOT content-addressed, so nothing on the receive path can re-derive
150
+ * the id from the bytes: `canonicalBlockHash` is a cross-peer *agreement* hash, never a check
151
+ * against `blockId`. A sole cohort peer's content is therefore believed on its word. That adds no
152
+ * trust the cohort had not already extended — the same peer's `(rev, actionId)` claim is likewise
153
+ * uncorroborable at that size (see `selectQuorumRev`'s capacity note), and a two-member cohort has
154
+ * no honest majority to appeal to in the first place. Closing it needs commit-cert verification,
155
+ * tracked by backlog `debt-read-repair-commit-cert-verification`.
156
+ *
157
+ * Declines are cheap and retryable: nothing is persisted, nothing is marked, and the next commit
158
+ * or churn/rebalance pass tries again.
159
+ */
160
+ export function createReconcileBlock(deps: ReconcileBlockDeps): ReconcileBlockCallback {
161
+ return async (blockId, committed, cohortPeerIds) => {
162
+ const targets = cohortPeerIds.filter(id => id !== deps.selfPeerId);
163
+ if (targets.length === 0) return;
164
+
165
+ const fetched = await Promise.all(
166
+ targets.map(peerId => fetchCandidate(deps, peerId, blockId, committed.rev))
167
+ );
168
+ const candidates = fetched.filter((c): c is ReconcileCandidate => c !== undefined);
169
+ const capacity = corroboratorCapacity(targets.length, deps.repairCorroborationClusterSize);
170
+
171
+ const revClaims: RevClaim[] = candidates.map(({ peerId, rev, actionId }) => ({ peerId, rev, actionId }));
172
+ const selected = selectQuorumRev(revClaims, deps.simpleMajorityThreshold, capacity);
173
+ if (!selected) {
174
+ // Leave the block behind; churn/rebalance and the next commit retry.
175
+ log('reconcile:no-rev-quorum', { blockId, rev: committed.rev, responders: revClaims.length, capacity });
176
+ return;
177
+ }
178
+
179
+ const hashCandidates = await hashCarriers(candidates, selected);
180
+ const agreed = selectQuorumBlock(hashCandidates, deps.simpleMajorityThreshold, capacity);
181
+ if (!agreed) {
182
+ log('reconcile:no-content-quorum', { blockId, rev: selected.rev, carriers: hashCandidates.length, capacity });
183
+ return;
184
+ }
185
+
186
+ penalizeContradictingContent(deps.reputation, hashCandidates, agreed.hash, blockId);
187
+
188
+ await deps.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
189
+ log('reconcile:restored', { blockId, rev: selected.rev, actionId: selected.actionId });
190
+ };
191
+ }