@optimystic/db-p2p 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/src/cluster/certified-claims.d.ts +17 -3
  2. package/dist/src/cluster/certified-claims.d.ts.map +1 -1
  3. package/dist/src/cluster/certified-claims.js +5 -3
  4. package/dist/src/cluster/certified-claims.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +57 -92
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +149 -201
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/commit-proof.d.ts +16 -0
  10. package/dist/src/cluster/commit-proof.d.ts.map +1 -1
  11. package/dist/src/cluster/commit-proof.js +32 -1
  12. package/dist/src/cluster/commit-proof.js.map +1 -1
  13. package/dist/src/cluster/quorum-restore.d.ts +81 -28
  14. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  15. package/dist/src/cluster/quorum-restore.js +148 -51
  16. package/dist/src/cluster/quorum-restore.js.map +1 -1
  17. package/dist/src/cluster/race-resolution.d.ts +89 -0
  18. package/dist/src/cluster/race-resolution.d.ts.map +1 -0
  19. package/dist/src/cluster/race-resolution.js +140 -0
  20. package/dist/src/cluster/race-resolution.js.map +1 -0
  21. package/dist/src/cluster/reconcile-block.d.ts +9 -4
  22. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  23. package/dist/src/cluster/reconcile-block.js +28 -11
  24. package/dist/src/cluster/reconcile-block.js.map +1 -1
  25. package/dist/src/cluster/record-operations.d.ts +25 -0
  26. package/dist/src/cluster/record-operations.d.ts.map +1 -0
  27. package/dist/src/cluster/record-operations.js +56 -0
  28. package/dist/src/cluster/record-operations.js.map +1 -0
  29. package/dist/src/cohort-topic/host.d.ts +133 -51
  30. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  31. package/dist/src/cohort-topic/host.js +290 -83
  32. package/dist/src/cohort-topic/host.js.map +1 -1
  33. package/dist/src/libp2p-node-base.d.ts +13 -4
  34. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  35. package/dist/src/libp2p-node-base.js +5 -5
  36. package/dist/src/libp2p-node-base.js.map +1 -1
  37. package/dist/src/repo/cluster-coordinator.d.ts +9 -0
  38. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  39. package/dist/src/repo/cluster-coordinator.js +13 -2
  40. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  41. package/dist/src/repo/coordinator-repo.d.ts +34 -2
  42. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  43. package/dist/src/repo/coordinator-repo.js +57 -3
  44. package/dist/src/repo/coordinator-repo.js.map +1 -1
  45. package/dist/src/storage/block-latch.d.ts +5 -4
  46. package/dist/src/storage/block-latch.d.ts.map +1 -1
  47. package/dist/src/storage/block-latch.js +5 -4
  48. package/dist/src/storage/block-latch.js.map +1 -1
  49. package/dist/src/storage/block-storage.d.ts +1 -1
  50. package/dist/src/storage/block-storage.d.ts.map +1 -1
  51. package/dist/src/storage/block-storage.js +11 -3
  52. package/dist/src/storage/block-storage.js.map +1 -1
  53. package/dist/src/storage/cached-raw-storage.d.ts +17 -1
  54. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  55. package/dist/src/storage/cached-raw-storage.js +8 -1
  56. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  57. package/dist/src/storage/cached-store-driver.d.ts +7 -0
  58. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  59. package/dist/src/storage/cached-store-driver.js +20 -0
  60. package/dist/src/storage/cached-store-driver.js.map +1 -1
  61. package/dist/src/storage/i-block-storage.d.ts +45 -2
  62. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  63. package/dist/src/storage/i-block-storage.js +29 -0
  64. package/dist/src/storage/i-block-storage.js.map +1 -1
  65. package/dist/src/storage/i-raw-storage.d.ts +16 -0
  66. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  67. package/dist/src/storage/kv-raw-storage.d.ts +5 -1
  68. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  69. package/dist/src/storage/kv-raw-storage.js +8 -1
  70. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  71. package/dist/src/storage/raw-store-driver.d.ts +15 -0
  72. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  73. package/dist/src/storage/shared-cache-pool.d.ts +5 -0
  74. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  75. package/dist/src/storage/shared-cache-pool.js +6 -1
  76. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  77. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  78. package/dist/src/storage/storage-repo.js +141 -99
  79. package/dist/src/storage/storage-repo.js.map +1 -1
  80. package/dist/src/storage/with-read-cache.d.ts +9 -5
  81. package/dist/src/storage/with-read-cache.d.ts.map +1 -1
  82. package/dist/src/storage/with-read-cache.js +16 -6
  83. package/dist/src/storage/with-read-cache.js.map +1 -1
  84. package/package.json +2 -2
  85. package/{README.md → readme.md} +2 -2
  86. package/src/cluster/certified-claims.ts +22 -9
  87. package/src/cluster/cluster-repo.ts +199 -212
  88. package/src/cluster/commit-proof.ts +38 -2
  89. package/src/cluster/quorum-restore.ts +183 -56
  90. package/src/cluster/race-resolution.ts +158 -0
  91. package/src/cluster/reconcile-block.ts +34 -11
  92. package/src/cluster/record-operations.ts +59 -0
  93. package/src/cohort-topic/host.ts +423 -118
  94. package/src/libp2p-node-base.ts +18 -9
  95. package/src/repo/cluster-coordinator.ts +1039 -1027
  96. package/src/repo/coordinator-repo.ts +1937 -1855
  97. package/src/storage/block-latch.ts +5 -4
  98. package/src/storage/block-storage.ts +11 -3
  99. package/src/storage/cached-raw-storage.ts +21 -1
  100. package/src/storage/cached-store-driver.ts +23 -0
  101. package/src/storage/i-block-storage.ts +47 -2
  102. package/src/storage/i-raw-storage.ts +17 -0
  103. package/src/storage/kv-raw-storage.ts +8 -1
  104. package/src/storage/raw-store-driver.ts +26 -0
  105. package/src/storage/shared-cache-pool.ts +6 -1
  106. package/src/storage/storage-repo.ts +143 -103
  107. package/src/storage/with-read-cache.ts +16 -6
@@ -2,7 +2,7 @@ import type { IRepo, ClusterRecord, ClusterPeers, Signature, RepoMessage, ITrans
2
2
  import type { ICluster } from "@optimystic/db-core";
3
3
  import type { IPeerNetwork } from "@optimystic/db-core";
4
4
  import { blockIdsForTransforms, isOwnRevision, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
5
- import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority, clusterVoteSigningPayload, clusterVoteVerificationPayload } from "@optimystic/db-core";
5
+ import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clusterVoteSigningPayload, clusterVoteVerificationPayload } from "@optimystic/db-core";
6
6
  import { verifyInvalidationCertificate, type ArbitratorSetRecompute } from "../dispute/invalidation.js";
7
7
  import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
8
8
  import { ClusterClient } from "./client.js";
@@ -19,7 +19,9 @@ import { PenaltyReason } from "../reputation/types.js";
19
19
  import type { ITransactionStateStore } from "./i-transaction-state-store.js";
20
20
  import { isMissingBaseRevisionFailure, type CommitDigestPreview, type ICommitDigestPreviewer, type ICommitProofPersister, type IRevisionActionReader } from "../storage/storage-repo.js";
21
21
  import { checkPendValidation } from "../pend-validation.js";
22
- import { buildBlockCommitProof } from "./commit-proof.js";
22
+ import { getAffectedBlockIds } from "./record-operations.js";
23
+ import { operationsConflict, resolveRace } from "./race-resolution.js";
24
+ import { buildBlockCommitProof, mintSoloCommitProof, type BlockCommitProof } from "./commit-proof.js";
23
25
  import { RECONCILE_TIMEOUT_MS } from "./reconcile-block.js";
24
26
 
25
27
  const log = createLogger('cluster-member')
@@ -132,13 +134,40 @@ export type ExpectedClusterView = {
132
134
  * Independently derive this member's own view of a block's responsible cluster. Injected so
133
135
  * {@link ClusterMember} stays transport-agnostic — the composition root supplies it from
134
136
  * `IKeyNetwork.findCluster` + FRET (mirroring how the coordinator derives the cluster). Absent on nodes
135
- * that cannot derive a view (no FRET, unit tests): with no derived view AND no asserted
137
+ * that cannot derive a view (no FRET, unit tests): with no capability AND no asserted
136
138
  * {@link ClusterConsensusConfig.assumedClusterSize} the gate preserves legacy approve behavior, but an
137
- * asserted size still lets the gate fail closed on an unjustified downsize. See {@link ClusterMember}
138
- * admission gate.
139
+ * asserted size still lets the gate fail closed on an unjustified downsize.
140
+ *
141
+ * Wiring this capability also arms the record-shape refusals: a member that CAN derive refuses outright
142
+ * (rather than falling back) when the record names no coordinating block, or names one the record's own
143
+ * operations never touch — those are the sender's free choice, not this member's inability. See
144
+ * {@link ClusterViewDerivation} and {@link ClusterMember} admission gate.
139
145
  */
140
146
  export type DeriveExpectedClusterCallback = (blockId: BlockId) => Promise<ExpectedClusterView>;
141
147
 
148
+ /**
149
+ * Why a member does or does not have its own view of a record's cohort. The whole point of the union is
150
+ * that the caller MUST distinguish a fault of the RECEIVER (nothing to check against — stay lenient)
151
+ * from a fault of the SENDER (a record no current coordinator would build — refuse), a distinction a
152
+ * bare `undefined` erased and let a coordinator exploit: by choosing how it filled `coordinatingBlockIds`
153
+ * the coordinator chose which check every member ran.
154
+ *
155
+ * Module-internal on purpose — nothing outside this file consumes it.
156
+ */
157
+ type ClusterViewDerivation =
158
+ /** The member resolved a view. Confidence / emptiness is judged by the caller, not here. */
159
+ | { kind: 'view'; view: ExpectedClusterView }
160
+ /** No {@link DeriveExpectedClusterCallback} wired (no FRET, unit tests): nothing to check against. */
161
+ | { kind: 'no-capability' }
162
+ /**
163
+ * A usable block was named but the lookup itself failed. Receiver fault. Carries no payload: the
164
+ * error is logged where it is caught, and the gate's response does not depend on which error it was.
165
+ */
166
+ | { kind: 'underivable' }
167
+ /** The record names no block this member can legitimately derive from. Sender fault. */
168
+ | { kind: 'unusable-record'; variant: 'no-coordinating-block' }
169
+ | { kind: 'unusable-record'; variant: 'unbound-coordinating-block'; blockId: string; affected: number };
170
+
142
171
  /** Stable reject reason a member emits when a declared peer set fails the membership admission gate. */
143
172
  export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
144
173
 
@@ -182,6 +211,19 @@ interface ClusterMemberComponents {
182
211
  recomputeArbitratorSet?: RecomputeArbitratorSetCapability;
183
212
  /** Member-side cluster derivation for the membership admission gate; see {@link DeriveExpectedClusterCallback}. */
184
213
  deriveExpectedCluster?: DeriveExpectedClusterCallback;
214
+ /**
215
+ * Wall clock in unix milliseconds; defaults to `Date.now`. Injectable so a test can age a held
216
+ * reservation past {@link CONFLICT_STALE_THRESHOLD_MS} without sleeping. It governs BOTH sides of
217
+ * the reservation's `lastUpdate` — the stamp and the comparison — so the two can never end up on
218
+ * different time bases.
219
+ *
220
+ * NOTE: partial injection, by design. This clock reaches ONLY `lastUpdate`; `message.expiration`,
221
+ * the promise/resolution timeouts, the periodic expiry sweep and the executed-transaction TTL all
222
+ * still read the real `Date.now`. So an injected clock must share an epoch with real time (seed it
223
+ * from `Date.now()`, then advance) — one starting near zero makes every record look long expired
224
+ * via the un-injected expiration check. Widen the injection if a test needs to drive expiry too.
225
+ */
226
+ now?: () => number;
185
227
  }
186
228
 
187
229
  export function clusterMember(components: ClusterMemberComponents): ClusterMember {
@@ -201,13 +243,22 @@ export function clusterMember(components: ClusterMemberComponents): ClusterMembe
201
243
  components.onCommitCertificate,
202
244
  components.onInvalidate,
203
245
  components.recomputeArbitratorSet,
204
- components.deriveExpectedCluster
246
+ components.deriveExpectedCluster,
247
+ components.now
205
248
  );
206
249
  }
207
250
 
208
251
  // How long to keep executed transaction records (10 minutes)
209
252
  const ExecutedTransactionTtlMs = 10 * 60 * 1000;
210
253
 
254
+ /**
255
+ * How long a held reservation may go untouched before the conflict scan ({@link ClusterMember.findConflict})
256
+ * sweeps it. Generous relative to a round-trip: the scan frees an ABANDONED coordinator's blocks, so
257
+ * sweeping too eagerly would drop a live transaction whose next delivery is merely in flight.
258
+ * Exported so a test can advance an injected clock past it without restating the number.
259
+ */
260
+ export const CONFLICT_STALE_THRESHOLD_MS = 2000;
261
+
211
262
  // Upper bound on an awaited active reconciliation of a divergent commit. Bounds the
212
263
  // consensus path so a slow/unreachable cohort peer can't stall the cluster stream;
213
264
  // a timeout is logged and tolerated (never thrown — that would reset the stream).
@@ -284,6 +335,8 @@ export class ClusterMember implements ICluster {
284
335
  /** What a validator-configured member does with a pend carrying no `validation` payload — see
285
336
  * {@link ClusterConsensusConfig.unvalidatablePendPolicy}. Read once, like the gate parameters. */
286
337
  private readonly unvalidatablePendPolicy: UnvalidatablePendPolicy;
338
+ /** Clock behind the reservation table's `lastUpdate` — see {@link ClusterMemberComponents.now}. */
339
+ private readonly now: () => number;
287
340
 
288
341
  constructor(
289
342
  private readonly storageRepo: IRepo,
@@ -302,8 +355,10 @@ export class ClusterMember implements ICluster {
302
355
  private readonly onCommitCertificate?: CommitCertificateSink,
303
356
  private readonly onInvalidate?: InvalidationApplySink,
304
357
  private readonly recomputeArbitratorSet?: RecomputeArbitratorSetCapability,
305
- private readonly deriveExpectedCluster?: DeriveExpectedClusterCallback
358
+ private readonly deriveExpectedCluster?: DeriveExpectedClusterCallback,
359
+ now?: () => number
306
360
  ) {
361
+ this.now = now ?? ((): number => Date.now());
307
362
  this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD;
308
363
  this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
309
364
  this.clusterSizeTolerance = consensusConfig?.clusterSizeTolerance ?? 0.5;
@@ -390,6 +445,17 @@ export class ClusterMember implements ICluster {
390
445
  return this.executedCommitResults.get(messageHash);
391
446
  }
392
447
 
448
+ /**
449
+ * Self-sign a one-peer {@link BlockCommitProof} over `message` — a thin delegate to
450
+ * {@link mintSoloCommitProof} with this member's own id and key. Lives here because this class is
451
+ * the key holder: `CoordinatorRepo` (the caller, on its solo-cohort commit short-circuit) knows
452
+ * the local peer id but never sees the private key. Nothing else moves — no record, no consensus
453
+ * state; the mint is a pure signing operation over the message the caller built.
454
+ */
455
+ async mintSoloCommitProof(message: RepoMessage): Promise<BlockCommitProof> {
456
+ return mintSoloCommitProof(this.peerId.toString(), this.privateKey, message);
457
+ }
458
+
393
459
  /**
394
460
  * Handles an incoming cluster update, managing the two-phase commit process
395
461
  * and coordinating with the local storage repo
@@ -574,7 +640,7 @@ export class ClusterMember implements ICluster {
574
640
  const timeouts = this.setupTimeouts(currentRecord);
575
641
  this.activeTransactions.set(record.messageHash, {
576
642
  record: currentRecord,
577
- lastUpdate: Date.now(),
643
+ lastUpdate: this.now(),
578
644
  promiseTimeout: timeouts.promiseTimeout,
579
645
  resolutionTimeout: timeouts.resolutionTimeout
580
646
  });
@@ -1047,8 +1113,16 @@ export class ClusterMember implements ICluster {
1047
1113
  * 3. **Consistency with the derived view** — `|D △ E|` within `clusterSizeTolerance·|E|`; honest churn
1048
1114
  * of a peer or two is absorbed, a wholesale-disjoint or half-size set is not.
1049
1115
  *
1050
- * **Fail-closed posture.** When the member cannot confidently derive `E` (no capability, low FRET
1051
- * confidence exactly what a partition induces), it must refuse any *downsizing* decision but it
1116
+ * **Inadmissible records come first.** Before any of that, a member that CAN derive refuses outright a
1117
+ * record whose coordinating block is the sender's free choice rather than a fact about the record: one
1118
+ * that names no coordinating block at all, or names a block the record's own operations never touch.
1119
+ * Those are defects of the SENDER, and no current coordinator produces them; treating them as "cannot
1120
+ * derive" would hand a dishonest coordinator the choice of which check every member ran. A member with
1121
+ * no derivation capability never reaches this refusal — it has nothing to check against.
1122
+ *
1123
+ * **Fail-closed posture.** When the member cannot confidently derive `E` — no capability, a bound
1124
+ * block whose lookup failed or returned an empty/low-confidence view (low FRET confidence is exactly
1125
+ * what a partition induces) — it must refuse any *downsizing* decision — but it
1052
1126
  * needs a size reference to judge "downsize" against, and it may NOT borrow `clusterSize` for that:
1053
1127
  * `clusterSize` is the replication factor (what a cohort should aim for), not a claim about how many
1054
1128
  * peers exist, so a small deployment configured with the default 10 would refuse every write. The
@@ -1073,12 +1147,59 @@ export class ClusterMember implements ICluster {
1073
1147
  }
1074
1148
 
1075
1149
  // Explicit opt-in: knowingly transact below the safe floor (single-node / local dev). Skips the
1076
- // size/consistency gates but not self-membership above.
1150
+ // size/consistency gates AND the record-shape refusals below, but not self-membership above: it
1151
+ // already bypasses the far stronger confident predicates, so making a weaker check the one thing it
1152
+ // cannot bypass would be incoherent.
1077
1153
  if (this.allowUnvalidatedSmallCluster) {
1078
1154
  return { admit: true };
1079
1155
  }
1080
1156
 
1081
- const derived = await this.deriveExpectedClusterView(record);
1157
+ const derivation = await this.deriveExpectedClusterView(record);
1158
+
1159
+ // Split the sender's faults from this member's own. A record that names no coordinating block, or
1160
+ // names one its own operations never touch, is one no current coordinator builds (every production
1161
+ // sender goes through `ClusterCoordinator.executeClusterTransaction`, which stamps a bound id) — so
1162
+ // it is inadmissible, not merely underived. Collapsing these into the lenient fallback let a
1163
+ // dishonest coordinator choose which check every member ran, just by how it filled one field.
1164
+ // Exhaustive switch on purpose: a future `kind` must not silently join the lenient bucket.
1165
+ switch (derivation.kind) {
1166
+ case 'unusable-record': {
1167
+ log('cluster-member:admission-reject', {
1168
+ messageHash: record.messageHash,
1169
+ reason: derivation.variant,
1170
+ declaredSize: declared.length
1171
+ });
1172
+ // The count of affected block ids, not the list: this string is signed into the vote and lands
1173
+ // in dispute records, so a wide multi-block pend must not produce an unbounded reason.
1174
+ // NOTE: `blockId` itself is copied verbatim from the (untrusted) record and nothing upstream
1175
+ // bounds its length — fine while block ids are short content hashes; if a record ever carries
1176
+ // a pathological id, truncate it here rather than signing an arbitrarily large reason string.
1177
+ // A fresh record carries no signatures, so `validateRecord` does not authenticate the sender
1178
+ // before this point; what keeps it harmless is that the reason is bounded by the record the
1179
+ // sender already transmitted (no amplification), not that the path is authenticated.
1180
+ return {
1181
+ admit: false,
1182
+ reason: derivation.variant === 'no-coordinating-block'
1183
+ ? `${MEMBERSHIP_NOT_ADMITTED}:no-coordinating-block`
1184
+ : `${MEMBERSHIP_NOT_ADMITTED}:unbound-coordinating-block (blockId=${derivation.blockId}, affected=${derivation.affected})`
1185
+ };
1186
+ }
1187
+ // Receiver-side outcomes: a resolved view, no capability at all, or a bound block whose lookup
1188
+ // failed. All three keep today's behaviour, judged below.
1189
+ case 'view':
1190
+ case 'no-capability':
1191
+ case 'underivable':
1192
+ break;
1193
+ default: {
1194
+ // `never` is the compile-time half of the guard: adding a `kind` without deciding its side
1195
+ // of the sender/receiver split fails the build here. The runtime half returns a real
1196
+ // verdict — fail closed — rather than the derivation object, which is not one.
1197
+ const exhaustive: never = derivation;
1198
+ return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:${(exhaustive as { kind: string }).kind}` };
1199
+ }
1200
+ }
1201
+
1202
+ const derived = derivation.kind === 'view' ? derivation.view : undefined;
1082
1203
  // An empty derived view (kEst === 0) carries no usable reference set: measured against it every
1083
1204
  // non-empty declared set is wholly "inconsistent" (maxDiff = ceil(tol·0) = 0), which would spuriously
1084
1205
  // reject a legitimate full cluster — a stricter, worse outcome than an absent view. Treat empty as
@@ -1180,23 +1301,43 @@ export class ClusterMember implements ICluster {
1180
1301
  }
1181
1302
 
1182
1303
  /**
1183
- * Derive this member's own view of the record's block cluster via the injected capability, or
1184
- * `undefined` when it cannot (no capability, no coordinating block id, a coordinating block not bound
1185
- * to the record's own operations, or a derivation error all of which the gate treats as "not
1186
- * confident"). Derived from the record's coordinating block, the same key the coordinator used to
1187
- * select the cluster.
1304
+ * Derive this member's own view of the record's block cluster via the injected capability, reporting
1305
+ * *why* when it cannot see {@link ClusterViewDerivation}. Four outcomes, deliberately not collapsed
1306
+ * into one `undefined`: the member has no capability; the lookup failed; the record named no
1307
+ * coordinating block; the record named a block its own operations never touch. The last two are the
1308
+ * sender's choices and {@link admitMembership} refuses them; the first two are this member's own
1309
+ * limitation and stay lenient. Derived from the record's coordinating block, the same key the
1310
+ * coordinator used to select the cluster.
1188
1311
  *
1189
1312
  * Read off `record.message`, NOT a top-level record field: `messageHash` covers the message only, so
1190
1313
  * only the in-message copy is tamper-evident to a relaying peer. (There is no top-level copy any more —
1191
1314
  * see {@link ClusterRecord.message}.)
1192
1315
  */
1193
- private async deriveExpectedClusterView(record: ClusterRecord): Promise<ExpectedClusterView | undefined> {
1316
+ private async deriveExpectedClusterView(record: ClusterRecord): Promise<ClusterViewDerivation> {
1317
+ // Capability check FIRST, before the record's field is even read: a member with nothing to derive
1318
+ // against must never report a sender fault — it has no standing to judge the record's shape.
1194
1319
  if (!this.deriveExpectedCluster) {
1195
- return undefined;
1320
+ return { kind: 'no-capability' };
1196
1321
  }
1322
+ // NOTE: only `coordinatingBlockIds[0]` is read. A pend may declare the whole consolidated batch
1323
+ // here; the gate needs one block to derive a cohort from, and the coordinator's choke point puts
1324
+ // the cohort key it actually selected against at index 0.
1197
1325
  const blockId = record.message.coordinatingBlockIds?.[0];
1198
1326
  if (blockId === undefined) {
1199
- return undefined;
1327
+ // Covers both an absent field and a present-but-empty array — distinct wire shapes, same
1328
+ // defect: the record names nothing to derive from. Every production sender routes through
1329
+ // `ClusterCoordinator.executeClusterTransaction`, which stamps a bound id when the message has
1330
+ // none, so no honest record reaches here.
1331
+ //
1332
+ // No rolling-upgrade gate guards this refusal, and `membershipVersion` is NOT that gate:
1333
+ // `validateRecord` accepts v1 and unversioned records, and the choke point that stamps the
1334
+ // field onto commit/cancel records (`commit-and-cancel-records-omit-the-coordinating-block`)
1335
+ // landed without bumping the version — so a peer on a build older than that one sends
1336
+ // commit/cancel records this refuses. The decision is that cluster consensus deploys as one
1337
+ // unit and this project makes no cross-build compatibility promise; if that ever changes,
1338
+ // the gate belongs here (admit an unversioned record on the lenient path), not in the caller.
1339
+ log('cluster-member:coordinating-block-absent', { messageHash: record.messageHash });
1340
+ return { kind: 'unusable-record', variant: 'no-coordinating-block' };
1200
1341
  }
1201
1342
  // Hashing the field makes it tamper-evident to RELAYS, but the coordinator is the party this gate
1202
1343
  // exists to check and it picks the field before it computes the hash. Unbound, a Byzantine
@@ -1206,14 +1347,21 @@ export class ClusterMember implements ICluster {
1206
1347
  // record's OWN operations touch removes that free choice. `getAffectedBlockIds` is the same block
1207
1348
  // extraction conflict detection already runs on this message — one definition, so the set a
1208
1349
  // coordinating id must come from cannot drift from the set the record is judged to touch.
1209
- if (!this.getAffectedBlockIds(record.message.operations).includes(blockId)) {
1350
+ const affected = getAffectedBlockIds(record.message.operations);
1351
+ if (!affected.includes(blockId)) {
1210
1352
  log('cluster-member:coordinating-block-unbound', {
1211
1353
  messageHash: record.messageHash,
1212
1354
  coordinatingBlockId: blockId
1213
1355
  });
1214
- // Fail closed into the branch that already exists rather than throwing: a hard reject would
1215
- // change `validateRecord`'s failure surface, and "not confident" already refuses any downsize.
1216
- return undefined;
1356
+ // Refuse, do not fall back: the fallback floor is the posture for a fault of THIS member, and
1357
+ // letting a sender-chosen defect land there let the coordinator pick which check ran. Reported
1358
+ // as a reject vote rather than a throw so the member emits a signed `reject` and dispute
1359
+ // accounting keeps working — this stays out of `validateRecord`'s failure surface.
1360
+ //
1361
+ // `affected` is empty for a record with no operations, so any named block is unbound and the
1362
+ // record is refused — correct (a record with no operations is malformed), and why `affected=0`
1363
+ // can legitimately appear in the reason string.
1364
+ return { kind: 'unusable-record', variant: 'unbound-coordinating-block', blockId, affected: affected.length };
1217
1365
  }
1218
1366
  // Which block a member derived its cohort view from is the single most useful fact when an
1219
1367
  // admission decision has to be explained after the fact — and the only externally visible sign
@@ -1223,10 +1371,13 @@ export class ClusterMember implements ICluster {
1223
1371
  // NOTE: derives (findCluster) once per inbound record on the promise path — one routing lookup
1224
1372
  // per vote. If this shows up as hot, cache the derived view per (blockId, short TTL): it is a
1225
1373
  // pure read of current topology, so a few-seconds-stale view is safe for admission.
1226
- return await this.deriveExpectedCluster(blockId as BlockId);
1374
+ return { kind: 'view', view: await this.deriveExpectedCluster(blockId as BlockId) };
1227
1375
  } catch (err) {
1228
1376
  log('cluster-member:derive-expected-cluster-error', { messageHash: record.messageHash, error: (err as Error).message });
1229
- return undefined;
1377
+ // Receiver fault: a bound block whose lookup threw. Stays lenient (the `assumedClusterSize`
1378
+ // fallback) — that is the partition posture, and refusing here would make a transient routing
1379
+ // hiccup refuse every write.
1380
+ return { kind: 'underivable' };
1230
1381
  }
1231
1382
  }
1232
1383
 
@@ -2074,10 +2225,11 @@ export class ClusterMember implements ICluster {
2074
2225
  * entries are swept, and a held transaction that LOSES the race to `record` is cleared.
2075
2226
  */
2076
2227
  private findConflict(record: ClusterRecord): { blockedBy: string } | undefined {
2077
- const now = Date.now();
2078
- const staleThresholdMs = 2000; // 2 seconds - allow more time for distributed consensus
2228
+ // Same clock as the `lastUpdate` stamp (the `shouldPersist` set and `persistParticipantState`),
2229
+ // so an injected test clock ages entries instead of putting stamp and comparison on different bases.
2230
+ const now = this.now();
2079
2231
 
2080
- const incomingBlockIds = this.getAffectedBlockIds(record.message.operations);
2232
+ const incomingBlockIds = getAffectedBlockIds(record.message.operations);
2081
2233
  log('cluster-member:findConflict-check', {
2082
2234
  messageHash: record.messageHash,
2083
2235
  activeCount: this.activeTransactions.size,
@@ -2085,11 +2237,18 @@ export class ClusterMember implements ICluster {
2085
2237
  });
2086
2238
 
2087
2239
  for (const [existingHash, state] of Array.from(this.activeTransactions.entries())) {
2240
+ // Defensive only — no caller can reach it today. `getTransactionPhase` calls this scan solely
2241
+ // when `!record.promises[ourId]`, and every write into `activeTransactions` already carries our
2242
+ // vote (the `shouldPersist` set happens after the phase loop recorded it; `recoverTransactions`
2243
+ // restores what that same branch persisted; `handleExpiration` re-sets with our reject added).
2244
+ // A redelivery at a known hash is merged with the held record first (`mergeRecords`, first-seen
2245
+ // wins), so our vote is present by the time the phase is computed. Kept because the scan is on
2246
+ // the vote path, where self-blocking would be silent and permanent.
2088
2247
  if (existingHash === record.messageHash) {
2089
2248
  continue;
2090
2249
  }
2091
2250
 
2092
- const existingBlockIds = this.getAffectedBlockIds(state.record.message.operations);
2251
+ const existingBlockIds = getAffectedBlockIds(state.record.message.operations);
2093
2252
  log('cluster-member:findConflict-compare', {
2094
2253
  existing: existingHash,
2095
2254
  incoming: record.messageHash,
@@ -2097,8 +2256,9 @@ export class ClusterMember implements ICluster {
2097
2256
  incomingBlockIds
2098
2257
  });
2099
2258
 
2100
- // Clean up stale transactions that have been around too long
2101
- if (now - state.lastUpdate > staleThresholdMs) {
2259
+ // Sweep abandoned reservations BEFORE the race is decided: an entry nobody is driving any
2260
+ // more must not win a contest it should not be in and block a live rival for its whole life.
2261
+ if (now - state.lastUpdate > CONFLICT_STALE_THRESHOLD_MS) {
2102
2262
  log('cluster-member:stale-cleanup', {
2103
2263
  messageHash: existingHash,
2104
2264
  age: now - state.lastUpdate
@@ -2107,9 +2267,9 @@ export class ClusterMember implements ICluster {
2107
2267
  continue;
2108
2268
  }
2109
2269
 
2110
- if (this.operationsConflict(state.record.message.operations, record.message.operations)) {
2270
+ if (operationsConflict(state.record.message.operations, record.message.operations)) {
2111
2271
  // Use race resolution to determine winner
2112
- const resolution = this.resolveRace(state.record, record);
2272
+ const resolution = resolveRace(state.record, record);
2113
2273
 
2114
2274
  if (resolution === 'keep-existing') {
2115
2275
  log('cluster-member:race-keep-existing', {
@@ -2124,7 +2284,10 @@ export class ClusterMember implements ICluster {
2124
2284
  incoming: record.messageHash
2125
2285
  });
2126
2286
  this.clearTransaction(existingHash);
2127
- continue; // Check other conflicts
2287
+ // `continue`, not `break`: the incoming transaction may overlap several held
2288
+ // reservations, and beating one says nothing about the rest. Stopping here would let it
2289
+ // walk past a second, still-live rival it actually loses to.
2290
+ continue;
2128
2291
  }
2129
2292
  }
2130
2293
  }
@@ -2132,182 +2295,6 @@ export class ClusterMember implements ICluster {
2132
2295
  return undefined; // No blocking conflicts
2133
2296
  }
2134
2297
 
2135
- /** Number of *approve* promise votes on a record — the count the commit rule uses. */
2136
- private static approvalCount(record: ClusterRecord): number {
2137
- return Object.values(record.promises).filter(s => s.type === 'approve').length;
2138
- }
2139
-
2140
- /**
2141
- * Resolve a race between two conflicting transactions. Total and deterministic, so every honest
2142
- * member computes the identical winner (the Theorem 1 Case-2 premise). Order:
2143
- * 1. more *approve* promise signatures wins (progress monotonicity — see safety note below);
2144
- * 2. equal approval counts → higher aged priority wins (fairness — see {@link recordPriority});
2145
- * 3. still tied → higher message hash wins.
2146
- *
2147
- * The count is APPROVALS, not `promises` keys. `promises` is the vote map — a reject occupies a key
2148
- * there exactly as an approve does — so counting keys would treat a rejection as progress, letting a
2149
- * record that can never commit outrank (and therefore block, via {@link findConflict}) a fresh rival
2150
- * for the whole staleness window. Approvals is also the count the invariant below actually needs:
2151
- * the commit rule is `approvedPromises >= superMajority`, which never looks at rejections.
2152
- *
2153
- * Approval count is FIRST so this comparison never displaces a transaction that is further along.
2154
- * That restores the pre-priority safety invariant: a member commits purely on promise supermajority
2155
- * (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
2156
- * conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
2157
- * With approvals-first, once transaction X holds a promise supermajority every conflicting rival Y has
2158
- * strictly fewer approvals — Y can only match X's count by getting the intersecting quorum member to
2159
- * approve it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
2160
- * `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
2161
- * overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
2162
- * Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
2163
- * higher-priority Y with fewer approvals, letting BOTH commit (split brain) — the regression fixed by
2164
- * ticket occ-priority-first-breaks-promise-monotonicity.
2165
- *
2166
- * Priority is now a tie-break that runs only at EQUAL approval counts, which is exactly the
2167
- * concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
2168
- * on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
2169
- * fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
2170
- * defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
2171
- * is the deferred feat-occ-priority-reservation).
2172
- *
2173
- * NOTE: residual-fairness tripwire. Under approvals-first an aged transaction can still lose to a fresh
2174
- * rival that has *legitimately* gathered even one more approval — that is not the pure-coin-flip
2175
- * starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
2176
- * more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
2177
- * rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
2178
- * to this race tie-break.
2179
- *
2180
- * NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
2181
- * priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
2182
- * exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
2183
- * below the approval count, so it can only break equal-count ties it might have ~50% won anyway,
2184
- * degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
2185
- * honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
2186
- *
2187
- * NOTE: keep priority a self-contained additive message field + this one comparison key so it
2188
- * composes with — does not block — a future HLC/crdt-sync redesign of this same path
2189
- * (design-hot-log-tail-sharding-guidance).
2190
- */
2191
- private resolveRace(existing: ClusterRecord, incoming: ClusterRecord): 'keep-existing' | 'accept-incoming' {
2192
- // 1. Transaction with more APPROVALS wins — never displace a more-progressed rival (safety, see
2193
- // above). Counting `promises` keys instead would count reject votes as progress: a record holding
2194
- // one rejection would outrank an untouched rival and reserve its blocks for the whole staleness
2195
- // window, and the commit rule this ordering protects (`approvedPromises >= superMajority`) never
2196
- // looks at rejections anyway.
2197
- const existingCount = ClusterMember.approvalCount(existing);
2198
- const incomingCount = ClusterMember.approvalCount(incoming);
2199
- if (existingCount !== incomingCount) {
2200
- return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
2201
- }
2202
-
2203
- // 2. Equal approval counts → higher aged priority wins (fairness tie-break).
2204
- const existingPriority = this.recordPriority(existing);
2205
- const incomingPriority = this.recordPriority(incoming);
2206
- if (existingPriority !== incomingPriority) {
2207
- return existingPriority > incomingPriority ? 'keep-existing' : 'accept-incoming';
2208
- }
2209
-
2210
- // 3. Tie-breaker: higher message hash wins (deterministic).
2211
- return existing.messageHash > incoming.messageHash ? 'keep-existing' : 'accept-incoming';
2212
- }
2213
-
2214
- /**
2215
- * Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
2216
- * The multi-collection path carries it on `pend.validation.transaction.priority`; the single-collection
2217
- * (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
2218
- * legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
2219
- * (backward compatible: such transactions simply never age). Both carriers live inside the signed
2220
- * `message`, so priority is integrity-protected in transit; clamping here bounds a self-asserted
2221
- * out-of-range value to the cap.
2222
- *
2223
- * NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
2224
- * separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
2225
- * the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
2226
- * consulted at the promise decision (findConflict), i.e. between two still-open conflicting
2227
- * transactions, which is exactly the concurrent-contention case priority is meant to order.
2228
- */
2229
- private recordPriority(record: ClusterRecord): number {
2230
- for (const op of record.message.operations) {
2231
- if ('pend' in op) {
2232
- // Every hop optional: `validation` arrives off the wire inside a signed message whose
2233
- // hash binds its bytes, not its shape, so a malformed pair must yield priority 0 (what
2234
- // clampPriority already does for a missing or Byzantine number) rather than throw out
2235
- // of the vote path — the lost vote this fail-closed pass exists to prevent.
2236
- return clampPriority(op.pend.validation?.transaction?.priority ?? op.pend.priority);
2237
- }
2238
- }
2239
- return 0;
2240
- }
2241
-
2242
- private operationsConflict(ops1: RepoMessage['operations'], ops2: RepoMessage['operations']): boolean {
2243
- // Check if one is a commit for the same action as a pend - these don't conflict
2244
- const actionId1 = this.getActionId(ops1);
2245
- const actionId2 = this.getActionId(ops2);
2246
- if (actionId1 && actionId2 && actionId1 === actionId2) {
2247
- // Same action - commit is resolving the pend, not conflicting
2248
- return false;
2249
- }
2250
-
2251
- const blocks1 = new Set(this.getAffectedBlockIds(ops1));
2252
- const blocks2 = new Set(this.getAffectedBlockIds(ops2));
2253
-
2254
- for (const block of Array.from(blocks1)) {
2255
- if (blocks2.has(block)) {
2256
- log('cluster-member:conflict-detected', {
2257
- blocks1: Array.from(blocks1),
2258
- blocks2: Array.from(blocks2),
2259
- conflictingBlock: block
2260
- });
2261
- return true;
2262
- }
2263
- }
2264
-
2265
- return false;
2266
- }
2267
-
2268
- private getActionId(operations: RepoMessage['operations']): string | undefined {
2269
- for (const operation of operations) {
2270
- if ('pend' in operation) {
2271
- return operation.pend.actionId;
2272
- } else if ('commit' in operation) {
2273
- return operation.commit.actionId;
2274
- } else if ('cancel' in operation) {
2275
- return operation.cancel.actionRef.actionId;
2276
- }
2277
- }
2278
- return undefined;
2279
- }
2280
-
2281
- /**
2282
- * Every block id the message's own operations name. Two consumers, deliberately sharing one
2283
- * definition: conflict detection (which writes must serialize against each other) and the membership
2284
- * admission gate's binding check (the set a legitimate `coordinatingBlockIds[0]` must come from —
2285
- * {@link ClusterMember.deriveExpectedClusterView}). If the two ever disagreed, a coordinator could
2286
- * name a block the record is not judged to touch.
2287
- */
2288
- private getAffectedBlockIds(operations: RepoMessage['operations']): string[] {
2289
- const blockIds = new Set<string>();
2290
-
2291
- for (const operation of operations) {
2292
- if ('get' in operation) {
2293
- operation.get.blockIds.forEach(id => blockIds.add(id));
2294
- } else if ('pend' in operation) {
2295
- // Use blockIdsForTransforms to correctly extract block IDs from Transforms structure
2296
- blockIdsForTransforms(operation.pend.transforms).forEach(id => blockIds.add(id));
2297
- } else if ('commit' in operation) {
2298
- operation.commit.blockIds.forEach(id => blockIds.add(id));
2299
- } else if ('cancel' in operation) {
2300
- operation.cancel.actionRef.blockIds.forEach(id => blockIds.add(id));
2301
- } else if ('invalidate' in operation) {
2302
- // The invalidation writes compensating revisions to these blocks; surfacing them lets
2303
- // conflict detection serialize a concurrent commit racing the invalidation on a block.
2304
- operation.invalidate.blockIds.forEach(id => blockIds.add(id));
2305
- }
2306
- }
2307
-
2308
- return Array.from(blockIds);
2309
- }
2310
-
2311
2298
  private async propagateIfNeeded(record: ClusterRecord): Promise<void> {
2312
2299
  const promises = [];
2313
2300
  for (const peerId of Object.keys(record.peers)) {
@@ -2439,7 +2426,7 @@ export class ClusterMember implements ICluster {
2439
2426
  this.stateStore.saveParticipantState(messageHash, {
2440
2427
  messageHash,
2441
2428
  record,
2442
- lastUpdate: Date.now()
2429
+ lastUpdate: this.now()
2443
2430
  }).catch(err => log('cluster-member:persist-error', { messageHash, error: (err as Error).message }));
2444
2431
  }
2445
2432
 
@@ -1,11 +1,14 @@
1
1
  import type { ActionId, BlockId, ClusterRecord, IBlock, RepoMessage, Signature, CommitRequest } from "@optimystic/db-core";
2
2
  import {
3
- canonicalBlockHash, clusterVoteVerificationPayload, computeClusterCommitHash,
4
- computeClusterMessageHash, computeClusterPromiseHash, membershipDigestFromIds
3
+ canonicalBlockHash, clusterVoteSigningPayload, clusterVoteVerificationPayload,
4
+ computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash,
5
+ membershipDigestFromIds
5
6
  } from "@optimystic/db-core";
7
+ import type { PrivateKey } from "@libp2p/interface";
6
8
  import { peerIdFromString } from "@libp2p/peer-id";
7
9
  import { publicKeyFromRaw } from "@libp2p/crypto/keys";
8
10
  import { fromString as uint8ArrayFromString } from "uint8arrays/from-string";
11
+ import { toString as uint8ArrayToString } from "uint8arrays/to-string";
9
12
 
10
13
  /**
11
14
  * A durable, self-contained proof that a cluster cohort agreed on a commit — everything an offline
@@ -91,6 +94,39 @@ export function buildBlockCommitProof(record: ClusterRecord): BlockCommitProof |
91
94
  };
92
95
  }
93
96
 
97
+ /**
98
+ * Mint a fully-signed one-peer {@link BlockCommitProof} — the producing sibling of
99
+ * {@link verifyBlockCommitProofClaim}, kept in this file so the hash recipe the two must agree on
100
+ * lives in one place. Used by the solo-cohort commit path (`CoordinatorRepo.commit`'s
101
+ * `peerCount <= 1` short-circuit), where consensus never runs and there is no {@link ClusterRecord}
102
+ * to project: the lone member IS the whole cohort, so it signs both rounds itself over a one-peer
103
+ * membership. The artifact stays honest — "one peer, which was the whole cohort at the time,
104
+ * committed these bytes at this revision" — and verifies offline from the peer id alone, exactly
105
+ * like a consensus-produced proof: `ceil(0.75 × 1) = 1` approve satisfies the promise round and
106
+ * `1 > 1 × 0.5` the commit round under the production thresholds.
107
+ *
108
+ * The promise round is signed FIRST: the commit hash's preimage includes the promises map
109
+ * (`computeClusterCommitHash`), so the order is load-bearing, not stylistic.
110
+ */
111
+ export async function mintSoloCommitProof(
112
+ peerId: string, privateKey: PrivateKey, message: RepoMessage
113
+ ): Promise<BlockCommitProof> {
114
+ const membershipDigest = await membershipDigestFromIds([peerId]);
115
+ const messageHash = await computeClusterMessageHash(message, membershipDigest);
116
+ const signApprove = async (hash: string): Promise<Signature> => ({
117
+ type: 'approve',
118
+ signature: uint8ArrayToString(await privateKey.sign(clusterVoteSigningPayload(hash, 'approve')), 'base64url')
119
+ });
120
+ const promiseHash = await computeClusterPromiseHash(messageHash, message, membershipDigest);
121
+ const promises: Record<string, Signature> = { [peerId]: await signApprove(promiseHash) };
122
+ const commitHash = await computeClusterCommitHash(messageHash, message, promises, membershipDigest);
123
+ const commits: Record<string, Signature> = { [peerId]: await signApprove(commitHash) };
124
+ return {
125
+ v: 1, messageHash, message, promises, commits,
126
+ membershipVersion: 2, membershipDigest, peerIds: [peerId]
127
+ };
128
+ }
129
+
94
130
  /**
95
131
  * Verify that `proof` certifies the CLAIM — `claim.blockId` at `claim.rev` under `claim.actionId` —
96
132
  * without needing the block bytes. Pure and total on hostile input: never throws; every failure is a