@optimystic/db-p2p 0.25.1 → 0.26.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 (77) hide show
  1. package/dist/src/cluster/cluster-repo.d.ts +47 -91
  2. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  3. package/dist/src/cluster/cluster-repo.js +138 -200
  4. package/dist/src/cluster/cluster-repo.js.map +1 -1
  5. package/dist/src/cluster/race-resolution.d.ts +89 -0
  6. package/dist/src/cluster/race-resolution.d.ts.map +1 -0
  7. package/dist/src/cluster/race-resolution.js +140 -0
  8. package/dist/src/cluster/race-resolution.js.map +1 -0
  9. package/dist/src/cluster/record-operations.d.ts +25 -0
  10. package/dist/src/cluster/record-operations.d.ts.map +1 -0
  11. package/dist/src/cluster/record-operations.js +56 -0
  12. package/dist/src/cluster/record-operations.js.map +1 -0
  13. package/dist/src/cohort-topic/host.d.ts +133 -51
  14. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/host.js +290 -83
  16. package/dist/src/cohort-topic/host.js.map +1 -1
  17. package/dist/src/libp2p-node-base.d.ts +7 -4
  18. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  19. package/dist/src/libp2p-node-base.js +5 -5
  20. package/dist/src/libp2p-node-base.js.map +1 -1
  21. package/dist/src/storage/block-latch.d.ts +5 -4
  22. package/dist/src/storage/block-latch.d.ts.map +1 -1
  23. package/dist/src/storage/block-latch.js +5 -4
  24. package/dist/src/storage/block-latch.js.map +1 -1
  25. package/dist/src/storage/block-storage.d.ts +1 -1
  26. package/dist/src/storage/block-storage.d.ts.map +1 -1
  27. package/dist/src/storage/block-storage.js +11 -3
  28. package/dist/src/storage/block-storage.js.map +1 -1
  29. package/dist/src/storage/cached-raw-storage.d.ts +17 -1
  30. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  31. package/dist/src/storage/cached-raw-storage.js +8 -1
  32. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  33. package/dist/src/storage/cached-store-driver.d.ts +7 -0
  34. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  35. package/dist/src/storage/cached-store-driver.js +20 -0
  36. package/dist/src/storage/cached-store-driver.js.map +1 -1
  37. package/dist/src/storage/i-block-storage.d.ts +45 -2
  38. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  39. package/dist/src/storage/i-block-storage.js +29 -0
  40. package/dist/src/storage/i-block-storage.js.map +1 -1
  41. package/dist/src/storage/i-raw-storage.d.ts +16 -0
  42. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  43. package/dist/src/storage/kv-raw-storage.d.ts +5 -1
  44. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  45. package/dist/src/storage/kv-raw-storage.js +8 -1
  46. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  47. package/dist/src/storage/raw-store-driver.d.ts +15 -0
  48. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  49. package/dist/src/storage/shared-cache-pool.d.ts +5 -0
  50. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  51. package/dist/src/storage/shared-cache-pool.js +6 -1
  52. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  53. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  54. package/dist/src/storage/storage-repo.js +141 -99
  55. package/dist/src/storage/storage-repo.js.map +1 -1
  56. package/dist/src/storage/with-read-cache.d.ts +9 -5
  57. package/dist/src/storage/with-read-cache.d.ts.map +1 -1
  58. package/dist/src/storage/with-read-cache.js +16 -6
  59. package/dist/src/storage/with-read-cache.js.map +1 -1
  60. package/package.json +2 -2
  61. package/{README.md → readme.md} +2 -2
  62. package/src/cluster/cluster-repo.ts +187 -211
  63. package/src/cluster/race-resolution.ts +158 -0
  64. package/src/cluster/record-operations.ts +59 -0
  65. package/src/cohort-topic/host.ts +423 -118
  66. package/src/libp2p-node-base.ts +12 -9
  67. package/src/storage/block-latch.ts +5 -4
  68. package/src/storage/block-storage.ts +11 -3
  69. package/src/storage/cached-raw-storage.ts +21 -1
  70. package/src/storage/cached-store-driver.ts +23 -0
  71. package/src/storage/i-block-storage.ts +47 -2
  72. package/src/storage/i-raw-storage.ts +17 -0
  73. package/src/storage/kv-raw-storage.ts +8 -1
  74. package/src/storage/raw-store-driver.ts +26 -0
  75. package/src/storage/shared-cache-pool.ts +6 -1
  76. package/src/storage/storage-repo.ts +143 -103
  77. package/src/storage/with-read-cache.ts +16 -6
@@ -1,5 +1,5 @@
1
1
  import { blockIdsForTransforms, isOwnRevision, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
2
- import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority, clusterVoteSigningPayload, clusterVoteVerificationPayload } from "@optimystic/db-core";
2
+ import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clusterVoteSigningPayload, clusterVoteVerificationPayload } from "@optimystic/db-core";
3
3
  import { verifyInvalidationCertificate } from "../dispute/invalidation.js";
4
4
  import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
5
5
  import { ClusterClient } from "./client.js";
@@ -11,6 +11,8 @@ import { createLogger } from '../logger.js';
11
11
  import { PenaltyReason } from "../reputation/types.js";
12
12
  import { isMissingBaseRevisionFailure } from "../storage/storage-repo.js";
13
13
  import { checkPendValidation } from "../pend-validation.js";
14
+ import { getAffectedBlockIds } from "./record-operations.js";
15
+ import { operationsConflict, resolveRace } from "./race-resolution.js";
14
16
  import { buildBlockCommitProof } from "./commit-proof.js";
15
17
  import { RECONCILE_TIMEOUT_MS } from "./reconcile-block.js";
16
18
  const log = createLogger('cluster-member');
@@ -44,10 +46,17 @@ export const CONTENT_DIGEST_MISMATCH = 'content-digest-mismatch';
44
46
  */
45
47
  export { PEND_NOT_VALIDATABLE, VALIDATOR_FAULT } from "../pend-validation.js";
46
48
  export function clusterMember(components) {
47
- return new ClusterMember(components.storageRepo, components.peerNetwork, components.peerId, components.privateKey, components.protocolPrefix, components.partitionDetector, components.fretService, components.validator, components.reputation, components.consensusConfig, components.stateStore, components.reconcileBlock, components.onCommitCertificate, components.onInvalidate, components.recomputeArbitratorSet, components.deriveExpectedCluster);
49
+ return new ClusterMember(components.storageRepo, components.peerNetwork, components.peerId, components.privateKey, components.protocolPrefix, components.partitionDetector, components.fretService, components.validator, components.reputation, components.consensusConfig, components.stateStore, components.reconcileBlock, components.onCommitCertificate, components.onInvalidate, components.recomputeArbitratorSet, components.deriveExpectedCluster, components.now);
48
50
  }
49
51
  // How long to keep executed transaction records (10 minutes)
50
52
  const ExecutedTransactionTtlMs = 10 * 60 * 1000;
53
+ /**
54
+ * How long a held reservation may go untouched before the conflict scan ({@link ClusterMember.findConflict})
55
+ * sweeps it. Generous relative to a round-trip: the scan frees an ABANDONED coordinator's blocks, so
56
+ * sweeping too eagerly would drop a live transaction whose next delivery is merely in flight.
57
+ * Exported so a test can advance an injected clock past it without restating the number.
58
+ */
59
+ export const CONFLICT_STALE_THRESHOLD_MS = 2000;
51
60
  // Upper bound on an awaited active reconciliation of a divergent commit. Bounds the
52
61
  // consensus path so a slow/unreachable cohort peer can't stall the cluster stream;
53
62
  // a timeout is logged and tolerated (never thrown — that would reset the stream).
@@ -134,9 +143,11 @@ export class ClusterMember {
134
143
  /** What a validator-configured member does with a pend carrying no `validation` payload — see
135
144
  * {@link ClusterConsensusConfig.unvalidatablePendPolicy}. Read once, like the gate parameters. */
136
145
  unvalidatablePendPolicy;
146
+ /** Clock behind the reservation table's `lastUpdate` — see {@link ClusterMemberComponents.now}. */
147
+ now;
137
148
  constructor(storageRepo, peerNetwork, peerId, privateKey, protocolPrefix,
138
149
  // Reserved for partition-healing (backlog ticket 6.5-partition-healing); held but not yet consumed.
139
- _partitionDetector, fretService, validator, reputation, consensusConfig, stateStore, reconcileBlock, onCommitCertificate, onInvalidate, recomputeArbitratorSet, deriveExpectedCluster) {
150
+ _partitionDetector, fretService, validator, reputation, consensusConfig, stateStore, reconcileBlock, onCommitCertificate, onInvalidate, recomputeArbitratorSet, deriveExpectedCluster, now) {
140
151
  this.storageRepo = storageRepo;
141
152
  this.peerNetwork = peerNetwork;
142
153
  this.peerId = peerId;
@@ -151,6 +162,7 @@ export class ClusterMember {
151
162
  this.onInvalidate = onInvalidate;
152
163
  this.recomputeArbitratorSet = recomputeArbitratorSet;
153
164
  this.deriveExpectedCluster = deriveExpectedCluster;
165
+ this.now = now ?? (() => Date.now());
154
166
  this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD;
155
167
  this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
156
168
  this.clusterSizeTolerance = consensusConfig?.clusterSizeTolerance ?? 0.5;
@@ -406,7 +418,7 @@ export class ClusterMember {
406
418
  const timeouts = this.setupTimeouts(currentRecord);
407
419
  this.activeTransactions.set(record.messageHash, {
408
420
  record: currentRecord,
409
- lastUpdate: Date.now(),
421
+ lastUpdate: this.now(),
410
422
  promiseTimeout: timeouts.promiseTimeout,
411
423
  resolutionTimeout: timeouts.resolutionTimeout
412
424
  });
@@ -829,8 +841,16 @@ export class ClusterMember {
829
841
  * 3. **Consistency with the derived view** — `|D △ E|` within `clusterSizeTolerance·|E|`; honest churn
830
842
  * of a peer or two is absorbed, a wholesale-disjoint or half-size set is not.
831
843
  *
832
- * **Fail-closed posture.** When the member cannot confidently derive `E` (no capability, low FRET
833
- * confidence exactly what a partition induces), it must refuse any *downsizing* decision but it
844
+ * **Inadmissible records come first.** Before any of that, a member that CAN derive refuses outright a
845
+ * record whose coordinating block is the sender's free choice rather than a fact about the record: one
846
+ * that names no coordinating block at all, or names a block the record's own operations never touch.
847
+ * Those are defects of the SENDER, and no current coordinator produces them; treating them as "cannot
848
+ * derive" would hand a dishonest coordinator the choice of which check every member ran. A member with
849
+ * no derivation capability never reaches this refusal — it has nothing to check against.
850
+ *
851
+ * **Fail-closed posture.** When the member cannot confidently derive `E` — no capability, a bound
852
+ * block whose lookup failed or returned an empty/low-confidence view (low FRET confidence is exactly
853
+ * what a partition induces) — it must refuse any *downsizing* decision — but it
834
854
  * needs a size reference to judge "downsize" against, and it may NOT borrow `clusterSize` for that:
835
855
  * `clusterSize` is the replication factor (what a cohort should aim for), not a claim about how many
836
856
  * peers exist, so a small deployment configured with the default 10 would refuse every write. The
@@ -853,11 +873,56 @@ export class ClusterMember {
853
873
  return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:self-not-member` };
854
874
  }
855
875
  // Explicit opt-in: knowingly transact below the safe floor (single-node / local dev). Skips the
856
- // size/consistency gates but not self-membership above.
876
+ // size/consistency gates AND the record-shape refusals below, but not self-membership above: it
877
+ // already bypasses the far stronger confident predicates, so making a weaker check the one thing it
878
+ // cannot bypass would be incoherent.
857
879
  if (this.allowUnvalidatedSmallCluster) {
858
880
  return { admit: true };
859
881
  }
860
- const derived = await this.deriveExpectedClusterView(record);
882
+ const derivation = await this.deriveExpectedClusterView(record);
883
+ // Split the sender's faults from this member's own. A record that names no coordinating block, or
884
+ // names one its own operations never touch, is one no current coordinator builds (every production
885
+ // sender goes through `ClusterCoordinator.executeClusterTransaction`, which stamps a bound id) — so
886
+ // it is inadmissible, not merely underived. Collapsing these into the lenient fallback let a
887
+ // dishonest coordinator choose which check every member ran, just by how it filled one field.
888
+ // Exhaustive switch on purpose: a future `kind` must not silently join the lenient bucket.
889
+ switch (derivation.kind) {
890
+ case 'unusable-record': {
891
+ log('cluster-member:admission-reject', {
892
+ messageHash: record.messageHash,
893
+ reason: derivation.variant,
894
+ declaredSize: declared.length
895
+ });
896
+ // The count of affected block ids, not the list: this string is signed into the vote and lands
897
+ // in dispute records, so a wide multi-block pend must not produce an unbounded reason.
898
+ // NOTE: `blockId` itself is copied verbatim from the (untrusted) record and nothing upstream
899
+ // bounds its length — fine while block ids are short content hashes; if a record ever carries
900
+ // a pathological id, truncate it here rather than signing an arbitrarily large reason string.
901
+ // A fresh record carries no signatures, so `validateRecord` does not authenticate the sender
902
+ // before this point; what keeps it harmless is that the reason is bounded by the record the
903
+ // sender already transmitted (no amplification), not that the path is authenticated.
904
+ return {
905
+ admit: false,
906
+ reason: derivation.variant === 'no-coordinating-block'
907
+ ? `${MEMBERSHIP_NOT_ADMITTED}:no-coordinating-block`
908
+ : `${MEMBERSHIP_NOT_ADMITTED}:unbound-coordinating-block (blockId=${derivation.blockId}, affected=${derivation.affected})`
909
+ };
910
+ }
911
+ // Receiver-side outcomes: a resolved view, no capability at all, or a bound block whose lookup
912
+ // failed. All three keep today's behaviour, judged below.
913
+ case 'view':
914
+ case 'no-capability':
915
+ case 'underivable':
916
+ break;
917
+ default: {
918
+ // `never` is the compile-time half of the guard: adding a `kind` without deciding its side
919
+ // of the sender/receiver split fails the build here. The runtime half returns a real
920
+ // verdict — fail closed — rather than the derivation object, which is not one.
921
+ const exhaustive = derivation;
922
+ return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:${exhaustive.kind}` };
923
+ }
924
+ }
925
+ const derived = derivation.kind === 'view' ? derivation.view : undefined;
861
926
  // An empty derived view (kEst === 0) carries no usable reference set: measured against it every
862
927
  // non-empty declared set is wholly "inconsistent" (maxDiff = ceil(tol·0) = 0), which would spuriously
863
928
  // reject a legitimate full cluster — a stricter, worse outcome than an absent view. Treat empty as
@@ -952,23 +1017,43 @@ export class ClusterMember {
952
1017
  return Math.max(this.minAbsoluteClusterSize, Number.isFinite(scaled) ? scaled : 0);
953
1018
  }
954
1019
  /**
955
- * Derive this member's own view of the record's block cluster via the injected capability, or
956
- * `undefined` when it cannot (no capability, no coordinating block id, a coordinating block not bound
957
- * to the record's own operations, or a derivation error all of which the gate treats as "not
958
- * confident"). Derived from the record's coordinating block, the same key the coordinator used to
959
- * select the cluster.
1020
+ * Derive this member's own view of the record's block cluster via the injected capability, reporting
1021
+ * *why* when it cannot see {@link ClusterViewDerivation}. Four outcomes, deliberately not collapsed
1022
+ * into one `undefined`: the member has no capability; the lookup failed; the record named no
1023
+ * coordinating block; the record named a block its own operations never touch. The last two are the
1024
+ * sender's choices and {@link admitMembership} refuses them; the first two are this member's own
1025
+ * limitation and stay lenient. Derived from the record's coordinating block, the same key the
1026
+ * coordinator used to select the cluster.
960
1027
  *
961
1028
  * Read off `record.message`, NOT a top-level record field: `messageHash` covers the message only, so
962
1029
  * only the in-message copy is tamper-evident to a relaying peer. (There is no top-level copy any more —
963
1030
  * see {@link ClusterRecord.message}.)
964
1031
  */
965
1032
  async deriveExpectedClusterView(record) {
1033
+ // Capability check FIRST, before the record's field is even read: a member with nothing to derive
1034
+ // against must never report a sender fault — it has no standing to judge the record's shape.
966
1035
  if (!this.deriveExpectedCluster) {
967
- return undefined;
1036
+ return { kind: 'no-capability' };
968
1037
  }
1038
+ // NOTE: only `coordinatingBlockIds[0]` is read. A pend may declare the whole consolidated batch
1039
+ // here; the gate needs one block to derive a cohort from, and the coordinator's choke point puts
1040
+ // the cohort key it actually selected against at index 0.
969
1041
  const blockId = record.message.coordinatingBlockIds?.[0];
970
1042
  if (blockId === undefined) {
971
- return undefined;
1043
+ // Covers both an absent field and a present-but-empty array — distinct wire shapes, same
1044
+ // defect: the record names nothing to derive from. Every production sender routes through
1045
+ // `ClusterCoordinator.executeClusterTransaction`, which stamps a bound id when the message has
1046
+ // none, so no honest record reaches here.
1047
+ //
1048
+ // No rolling-upgrade gate guards this refusal, and `membershipVersion` is NOT that gate:
1049
+ // `validateRecord` accepts v1 and unversioned records, and the choke point that stamps the
1050
+ // field onto commit/cancel records (`commit-and-cancel-records-omit-the-coordinating-block`)
1051
+ // landed without bumping the version — so a peer on a build older than that one sends
1052
+ // commit/cancel records this refuses. The decision is that cluster consensus deploys as one
1053
+ // unit and this project makes no cross-build compatibility promise; if that ever changes,
1054
+ // the gate belongs here (admit an unversioned record on the lenient path), not in the caller.
1055
+ log('cluster-member:coordinating-block-absent', { messageHash: record.messageHash });
1056
+ return { kind: 'unusable-record', variant: 'no-coordinating-block' };
972
1057
  }
973
1058
  // Hashing the field makes it tamper-evident to RELAYS, but the coordinator is the party this gate
974
1059
  // exists to check and it picks the field before it computes the hash. Unbound, a Byzantine
@@ -978,14 +1063,21 @@ export class ClusterMember {
978
1063
  // record's OWN operations touch removes that free choice. `getAffectedBlockIds` is the same block
979
1064
  // extraction conflict detection already runs on this message — one definition, so the set a
980
1065
  // coordinating id must come from cannot drift from the set the record is judged to touch.
981
- if (!this.getAffectedBlockIds(record.message.operations).includes(blockId)) {
1066
+ const affected = getAffectedBlockIds(record.message.operations);
1067
+ if (!affected.includes(blockId)) {
982
1068
  log('cluster-member:coordinating-block-unbound', {
983
1069
  messageHash: record.messageHash,
984
1070
  coordinatingBlockId: blockId
985
1071
  });
986
- // Fail closed into the branch that already exists rather than throwing: a hard reject would
987
- // change `validateRecord`'s failure surface, and "not confident" already refuses any downsize.
988
- return undefined;
1072
+ // Refuse, do not fall back: the fallback floor is the posture for a fault of THIS member, and
1073
+ // letting a sender-chosen defect land there let the coordinator pick which check ran. Reported
1074
+ // as a reject vote rather than a throw so the member emits a signed `reject` and dispute
1075
+ // accounting keeps working — this stays out of `validateRecord`'s failure surface.
1076
+ //
1077
+ // `affected` is empty for a record with no operations, so any named block is unbound and the
1078
+ // record is refused — correct (a record with no operations is malformed), and why `affected=0`
1079
+ // can legitimately appear in the reason string.
1080
+ return { kind: 'unusable-record', variant: 'unbound-coordinating-block', blockId, affected: affected.length };
989
1081
  }
990
1082
  // Which block a member derived its cohort view from is the single most useful fact when an
991
1083
  // admission decision has to be explained after the fact — and the only externally visible sign
@@ -995,11 +1087,14 @@ export class ClusterMember {
995
1087
  // NOTE: derives (findCluster) once per inbound record on the promise path — one routing lookup
996
1088
  // per vote. If this shows up as hot, cache the derived view per (blockId, short TTL): it is a
997
1089
  // pure read of current topology, so a few-seconds-stale view is safe for admission.
998
- return await this.deriveExpectedCluster(blockId);
1090
+ return { kind: 'view', view: await this.deriveExpectedCluster(blockId) };
999
1091
  }
1000
1092
  catch (err) {
1001
1093
  log('cluster-member:derive-expected-cluster-error', { messageHash: record.messageHash, error: err.message });
1002
- return undefined;
1094
+ // Receiver fault: a bound block whose lookup threw. Stays lenient (the `assumedClusterSize`
1095
+ // fallback) — that is the partition posture, and refusing here would make a transient routing
1096
+ // hiccup refuse every write.
1097
+ return { kind: 'underivable' };
1003
1098
  }
1004
1099
  }
1005
1100
  /** |A △ B| over two id lists (order-independent set symmetric difference). */
@@ -1811,27 +1906,36 @@ export class ClusterMember {
1811
1906
  * entries are swept, and a held transaction that LOSES the race to `record` is cleared.
1812
1907
  */
1813
1908
  findConflict(record) {
1814
- const now = Date.now();
1815
- const staleThresholdMs = 2000; // 2 seconds - allow more time for distributed consensus
1816
- const incomingBlockIds = this.getAffectedBlockIds(record.message.operations);
1909
+ // Same clock as the `lastUpdate` stamp (the `shouldPersist` set and `persistParticipantState`),
1910
+ // so an injected test clock ages entries instead of putting stamp and comparison on different bases.
1911
+ const now = this.now();
1912
+ const incomingBlockIds = getAffectedBlockIds(record.message.operations);
1817
1913
  log('cluster-member:findConflict-check', {
1818
1914
  messageHash: record.messageHash,
1819
1915
  activeCount: this.activeTransactions.size,
1820
1916
  incomingBlockIds
1821
1917
  });
1822
1918
  for (const [existingHash, state] of Array.from(this.activeTransactions.entries())) {
1919
+ // Defensive only — no caller can reach it today. `getTransactionPhase` calls this scan solely
1920
+ // when `!record.promises[ourId]`, and every write into `activeTransactions` already carries our
1921
+ // vote (the `shouldPersist` set happens after the phase loop recorded it; `recoverTransactions`
1922
+ // restores what that same branch persisted; `handleExpiration` re-sets with our reject added).
1923
+ // A redelivery at a known hash is merged with the held record first (`mergeRecords`, first-seen
1924
+ // wins), so our vote is present by the time the phase is computed. Kept because the scan is on
1925
+ // the vote path, where self-blocking would be silent and permanent.
1823
1926
  if (existingHash === record.messageHash) {
1824
1927
  continue;
1825
1928
  }
1826
- const existingBlockIds = this.getAffectedBlockIds(state.record.message.operations);
1929
+ const existingBlockIds = getAffectedBlockIds(state.record.message.operations);
1827
1930
  log('cluster-member:findConflict-compare', {
1828
1931
  existing: existingHash,
1829
1932
  incoming: record.messageHash,
1830
1933
  existingBlockIds,
1831
1934
  incomingBlockIds
1832
1935
  });
1833
- // Clean up stale transactions that have been around too long
1834
- if (now - state.lastUpdate > staleThresholdMs) {
1936
+ // Sweep abandoned reservations BEFORE the race is decided: an entry nobody is driving any
1937
+ // more must not win a contest it should not be in and block a live rival for its whole life.
1938
+ if (now - state.lastUpdate > CONFLICT_STALE_THRESHOLD_MS) {
1835
1939
  log('cluster-member:stale-cleanup', {
1836
1940
  messageHash: existingHash,
1837
1941
  age: now - state.lastUpdate
@@ -1839,9 +1943,9 @@ export class ClusterMember {
1839
1943
  this.clearTransaction(existingHash);
1840
1944
  continue;
1841
1945
  }
1842
- if (this.operationsConflict(state.record.message.operations, record.message.operations)) {
1946
+ if (operationsConflict(state.record.message.operations, record.message.operations)) {
1843
1947
  // Use race resolution to determine winner
1844
- const resolution = this.resolveRace(state.record, record);
1948
+ const resolution = resolveRace(state.record, record);
1845
1949
  if (resolution === 'keep-existing') {
1846
1950
  log('cluster-member:race-keep-existing', {
1847
1951
  existing: existingHash,
@@ -1856,181 +1960,15 @@ export class ClusterMember {
1856
1960
  incoming: record.messageHash
1857
1961
  });
1858
1962
  this.clearTransaction(existingHash);
1859
- continue; // Check other conflicts
1963
+ // `continue`, not `break`: the incoming transaction may overlap several held
1964
+ // reservations, and beating one says nothing about the rest. Stopping here would let it
1965
+ // walk past a second, still-live rival it actually loses to.
1966
+ continue;
1860
1967
  }
1861
1968
  }
1862
1969
  }
1863
1970
  return undefined; // No blocking conflicts
1864
1971
  }
1865
- /** Number of *approve* promise votes on a record — the count the commit rule uses. */
1866
- static approvalCount(record) {
1867
- return Object.values(record.promises).filter(s => s.type === 'approve').length;
1868
- }
1869
- /**
1870
- * Resolve a race between two conflicting transactions. Total and deterministic, so every honest
1871
- * member computes the identical winner (the Theorem 1 Case-2 premise). Order:
1872
- * 1. more *approve* promise signatures wins (progress monotonicity — see safety note below);
1873
- * 2. equal approval counts → higher aged priority wins (fairness — see {@link recordPriority});
1874
- * 3. still tied → higher message hash wins.
1875
- *
1876
- * The count is APPROVALS, not `promises` keys. `promises` is the vote map — a reject occupies a key
1877
- * there exactly as an approve does — so counting keys would treat a rejection as progress, letting a
1878
- * record that can never commit outrank (and therefore block, via {@link findConflict}) a fresh rival
1879
- * for the whole staleness window. Approvals is also the count the invariant below actually needs:
1880
- * the commit rule is `approvedPromises >= superMajority`, which never looks at rejections.
1881
- *
1882
- * Approval count is FIRST so this comparison never displaces a transaction that is further along.
1883
- * That restores the pre-priority safety invariant: a member commits purely on promise supermajority
1884
- * (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
1885
- * conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
1886
- * With approvals-first, once transaction X holds a promise supermajority every conflicting rival Y has
1887
- * strictly fewer approvals — Y can only match X's count by getting the intersecting quorum member to
1888
- * approve it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
1889
- * `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
1890
- * overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
1891
- * Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
1892
- * higher-priority Y with fewer approvals, letting BOTH commit (split brain) — the regression fixed by
1893
- * ticket occ-priority-first-breaks-promise-monotonicity.
1894
- *
1895
- * Priority is now a tie-break that runs only at EQUAL approval counts, which is exactly the
1896
- * concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
1897
- * on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
1898
- * fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
1899
- * defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
1900
- * is the deferred feat-occ-priority-reservation).
1901
- *
1902
- * NOTE: residual-fairness tripwire. Under approvals-first an aged transaction can still lose to a fresh
1903
- * rival that has *legitimately* gathered even one more approval — that is not the pure-coin-flip
1904
- * starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
1905
- * more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
1906
- * rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
1907
- * to this race tie-break.
1908
- *
1909
- * NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
1910
- * priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
1911
- * exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
1912
- * below the approval count, so it can only break equal-count ties it might have ~50% won anyway,
1913
- * degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
1914
- * honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
1915
- *
1916
- * NOTE: keep priority a self-contained additive message field + this one comparison key so it
1917
- * composes with — does not block — a future HLC/crdt-sync redesign of this same path
1918
- * (design-hot-log-tail-sharding-guidance).
1919
- */
1920
- resolveRace(existing, incoming) {
1921
- // 1. Transaction with more APPROVALS wins — never displace a more-progressed rival (safety, see
1922
- // above). Counting `promises` keys instead would count reject votes as progress: a record holding
1923
- // one rejection would outrank an untouched rival and reserve its blocks for the whole staleness
1924
- // window, and the commit rule this ordering protects (`approvedPromises >= superMajority`) never
1925
- // looks at rejections anyway.
1926
- const existingCount = ClusterMember.approvalCount(existing);
1927
- const incomingCount = ClusterMember.approvalCount(incoming);
1928
- if (existingCount !== incomingCount) {
1929
- return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
1930
- }
1931
- // 2. Equal approval counts → higher aged priority wins (fairness tie-break).
1932
- const existingPriority = this.recordPriority(existing);
1933
- const incomingPriority = this.recordPriority(incoming);
1934
- if (existingPriority !== incomingPriority) {
1935
- return existingPriority > incomingPriority ? 'keep-existing' : 'accept-incoming';
1936
- }
1937
- // 3. Tie-breaker: higher message hash wins (deterministic).
1938
- return existing.messageHash > incoming.messageHash ? 'keep-existing' : 'accept-incoming';
1939
- }
1940
- /**
1941
- * Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
1942
- * The multi-collection path carries it on `pend.validation.transaction.priority`; the single-collection
1943
- * (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
1944
- * legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
1945
- * (backward compatible: such transactions simply never age). Both carriers live inside the signed
1946
- * `message`, so priority is integrity-protected in transit; clamping here bounds a self-asserted
1947
- * out-of-range value to the cap.
1948
- *
1949
- * NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
1950
- * separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
1951
- * the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
1952
- * consulted at the promise decision (findConflict), i.e. between two still-open conflicting
1953
- * transactions, which is exactly the concurrent-contention case priority is meant to order.
1954
- */
1955
- recordPriority(record) {
1956
- for (const op of record.message.operations) {
1957
- if ('pend' in op) {
1958
- // Every hop optional: `validation` arrives off the wire inside a signed message whose
1959
- // hash binds its bytes, not its shape, so a malformed pair must yield priority 0 (what
1960
- // clampPriority already does for a missing or Byzantine number) rather than throw out
1961
- // of the vote path — the lost vote this fail-closed pass exists to prevent.
1962
- return clampPriority(op.pend.validation?.transaction?.priority ?? op.pend.priority);
1963
- }
1964
- }
1965
- return 0;
1966
- }
1967
- operationsConflict(ops1, ops2) {
1968
- // Check if one is a commit for the same action as a pend - these don't conflict
1969
- const actionId1 = this.getActionId(ops1);
1970
- const actionId2 = this.getActionId(ops2);
1971
- if (actionId1 && actionId2 && actionId1 === actionId2) {
1972
- // Same action - commit is resolving the pend, not conflicting
1973
- return false;
1974
- }
1975
- const blocks1 = new Set(this.getAffectedBlockIds(ops1));
1976
- const blocks2 = new Set(this.getAffectedBlockIds(ops2));
1977
- for (const block of Array.from(blocks1)) {
1978
- if (blocks2.has(block)) {
1979
- log('cluster-member:conflict-detected', {
1980
- blocks1: Array.from(blocks1),
1981
- blocks2: Array.from(blocks2),
1982
- conflictingBlock: block
1983
- });
1984
- return true;
1985
- }
1986
- }
1987
- return false;
1988
- }
1989
- getActionId(operations) {
1990
- for (const operation of operations) {
1991
- if ('pend' in operation) {
1992
- return operation.pend.actionId;
1993
- }
1994
- else if ('commit' in operation) {
1995
- return operation.commit.actionId;
1996
- }
1997
- else if ('cancel' in operation) {
1998
- return operation.cancel.actionRef.actionId;
1999
- }
2000
- }
2001
- return undefined;
2002
- }
2003
- /**
2004
- * Every block id the message's own operations name. Two consumers, deliberately sharing one
2005
- * definition: conflict detection (which writes must serialize against each other) and the membership
2006
- * admission gate's binding check (the set a legitimate `coordinatingBlockIds[0]` must come from —
2007
- * {@link ClusterMember.deriveExpectedClusterView}). If the two ever disagreed, a coordinator could
2008
- * name a block the record is not judged to touch.
2009
- */
2010
- getAffectedBlockIds(operations) {
2011
- const blockIds = new Set();
2012
- for (const operation of operations) {
2013
- if ('get' in operation) {
2014
- operation.get.blockIds.forEach(id => blockIds.add(id));
2015
- }
2016
- else if ('pend' in operation) {
2017
- // Use blockIdsForTransforms to correctly extract block IDs from Transforms structure
2018
- blockIdsForTransforms(operation.pend.transforms).forEach(id => blockIds.add(id));
2019
- }
2020
- else if ('commit' in operation) {
2021
- operation.commit.blockIds.forEach(id => blockIds.add(id));
2022
- }
2023
- else if ('cancel' in operation) {
2024
- operation.cancel.actionRef.blockIds.forEach(id => blockIds.add(id));
2025
- }
2026
- else if ('invalidate' in operation) {
2027
- // The invalidation writes compensating revisions to these blocks; surfacing them lets
2028
- // conflict detection serialize a concurrent commit racing the invalidation on a block.
2029
- operation.invalidate.blockIds.forEach(id => blockIds.add(id));
2030
- }
2031
- }
2032
- return Array.from(blockIds);
2033
- }
2034
1972
  async propagateIfNeeded(record) {
2035
1973
  const promises = [];
2036
1974
  for (const peerId of Object.keys(record.peers)) {
@@ -2154,7 +2092,7 @@ export class ClusterMember {
2154
2092
  this.stateStore.saveParticipantState(messageHash, {
2155
2093
  messageHash,
2156
2094
  record,
2157
- lastUpdate: Date.now()
2095
+ lastUpdate: this.now()
2158
2096
  }).catch(err => log('cluster-member:persist-error', { messageHash, error: err.message }));
2159
2097
  }
2160
2098
  /**