@optimystic/db-p2p 0.24.2 → 0.25.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 (249) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,203 +1,404 @@
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, quorumSize,
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', {
176
- blockId,
177
- rev: committed.rev,
178
- responders: revClaims.length,
179
- required: quorumSize(revClaims.length, deps.simpleMajorityThreshold, capacity),
180
- repairCorroborationClusterSize: deps.repairCorroborationClusterSize
181
- });
182
- return;
183
- }
184
-
185
- const hashCandidates = await hashCarriers(candidates, selected);
186
- const agreed = selectQuorumBlock(hashCandidates, deps.simpleMajorityThreshold, capacity);
187
- if (!agreed) {
188
- log('reconcile:no-content-quorum', {
189
- blockId,
190
- rev: selected.rev,
191
- carriers: hashCandidates.length,
192
- required: quorumSize(hashCandidates.length, deps.simpleMajorityThreshold, capacity),
193
- repairCorroborationClusterSize: deps.repairCorroborationClusterSize
194
- });
195
- return;
196
- }
197
-
198
- penalizeContradictingContent(deps.reputation, hashCandidates, agreed.hash, blockId);
199
-
200
- await deps.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
201
- log('reconcile:restored', { blockId, rev: selected.rev, actionId: selected.actionId });
202
- };
203
- }
1
+ import type { ActionId, ActionRev, BlockId, IBlock } from "@optimystic/db-core";
2
+ import { canonicalBlockHash } from "@optimystic/db-core";
3
+ import type { BlockArchive } from "../storage/struct.js";
4
+ import { maxArchiveRevision } from "../storage/block-archive.js";
5
+ import type { ReconcileBlockCallback } from "./cluster-repo.js";
6
+ import type { IPeerReputation } from "../reputation/types.js";
7
+ import { PenaltyReason } from "../reputation/types.js";
8
+ import {
9
+ selectQuorumRev, selectQuorumBlock, corroboratorCapacity, quorumSize,
10
+ certifiedEquivocation, certifiedContentEquivocation,
11
+ type RevClaim, type BlockHashCandidate, type QuorumRev
12
+ } from "./quorum-restore.js";
13
+ import {
14
+ certifyClaim, certifyContent, isAttributableProofFailure, proofThresholds, type ProofAnchoring
15
+ } from "./certified-claims.js";
16
+ import type { BlockCommitProof } from "./commit-proof.js";
17
+ import { createLogger } from '../logger.js';
18
+
19
+ const log = createLogger('reconcile-block');
20
+
21
+ /**
22
+ * Wall-clock bound on one whole reconcile pass (all cohort peers, both quorums, the persist).
23
+ * Shared by both callers so a slow or unreachable cohort peer stalls neither the commit path
24
+ * (`ClusterMember.withReconcileTimeout` — a stall there holds up consensus execution) nor the read
25
+ * path (`CoordinatorRepo.restoreCorroborated` — a stall there holds up a caller's `get`).
26
+ */
27
+ export const RECONCILE_TIMEOUT_MS = 5000;
28
+
29
+ /** One cohort peer's answer for a block: its highest revision, and the block bytes if it carried them. */
30
+ interface ReconcileCandidate {
31
+ peerId: string;
32
+ rev: number;
33
+ actionId: string;
34
+ /** Present only when the serving archive carried a materialized block for `rev`. */
35
+ block?: IBlock;
36
+ /**
37
+ * The cohort commit proof the serving archive attached, when it carried one. The archive keys the
38
+ * proof INSIDE the same revision entry as the `(rev, actionId)` it certifies
39
+ * (`storage/struct.ts` `ArchiveRevisions`), so a serving peer cannot pair a genuine proof with a
40
+ * different revision — mis-pairing is structurally blocked by the wire shape. Presence proves
41
+ * nothing; the certification pass below is what turns it into a verdict.
42
+ */
43
+ proof?: BlockCommitProof;
44
+ /** Injected by the certification pass: the proof verified for this `(rev, actionId)`. Never set from mere proof presence. */
45
+ revCertified?: boolean;
46
+ /** Injected by the certification pass: the proof's declared digest matches these exact bytes. */
47
+ contentCertified?: boolean;
48
+ /**
49
+ * Set on `digest-mismatch` only: the served bytes provably contradict the proof's declared
50
+ * digest, so this candidate is dropped from the content quorum (its rev claim still counts —
51
+ * that half genuinely verified).
52
+ */
53
+ contentRejected?: boolean;
54
+ }
55
+
56
+ /** Collaborators {@link createReconcileBlock} needs, injected so the logic stays transport-agnostic. */
57
+ export interface ReconcileBlockDeps {
58
+ /** This node's own peer id; excluded from the cohort targets. */
59
+ selfPeerId: string;
60
+ /** Fetch one cohort peer's archive for `blockId` — `undefined` when it is unreachable or holds nothing. */
61
+ fetchArchive: (peerId: string, blockId: BlockId) => Promise<BlockArchive | undefined>;
62
+ /**
63
+ * Persist the agreed content through the churn-replication funnel. `verifiedProof` is passed
64
+ * ONLY when it was verified against these exact bytes (`certifyContent`'s digest check) the
65
+ * receiver persists it as evidence, so an unverified proof must never reach this parameter.
66
+ */
67
+ saveReplicatedBlock: (blockId: BlockId, block: IBlock, source: ActionRev, verifiedProof?: BlockCommitProof) => Promise<void>;
68
+ /** Proportional corroboration threshold; the cohort's `simpleMajorityThreshold`. */
69
+ simpleMajorityThreshold: number;
70
+ /** Promise-round gate for proof verification; the cohort's `superMajorityThreshold`. */
71
+ superMajorityThreshold: number;
72
+ /** Optional layer-2 anchoring for accepted proofs observational only, see `cluster/certified-claims.ts`. */
73
+ anchoring?: ProofAnchoring;
74
+ /**
75
+ * Yardstick the corroboration floor is measured against — the floor for
76
+ * {@link corroboratorCapacity}. Required, not optional: unlike the membership admission gate there
77
+ * is no "unknown" handling here, so a caller that cannot state an asserted cohort size should pass
78
+ * its configured `clusterSize` (the strict direction) rather than a small placeholder. The failure
79
+ * mode of overstating it is a block that stays unrepaired — degraded, not dead; of understating it,
80
+ * a shrunken cohort view that can relax the floor to a single voter. `resolveClusterPolicy`
81
+ * (`cluster/cluster-policy.ts`) resolves it for a real node and defaults it to `clusterSize`.
82
+ */
83
+ repairCorroborationClusterSize: number;
84
+ /** Best-effort misbehavior reporting; a throwing implementation is swallowed. */
85
+ reputation?: Pick<IPeerReputation, 'reportPeer'>;
86
+ }
87
+
88
+ /**
89
+ * What one cohort peer's fetch produced. Kept as separate outcomes rather than collapsed to
90
+ * `ReconcileCandidate | undefined` so a decline can report WHICH populations it was short of: "1 of 3
91
+ * responded" and "1 holder, 2 confirmed non-holders" call for completely different operator actions.
92
+ *
93
+ * NOTE: `no-archive` deliberately conflates "the peer holds nothing" with "the peer is unreachable" —
94
+ * that conflation is in `fetchArchive`'s contract, and the production wiring
95
+ * (`libp2p-node-base.fetchArchiveFromPeer`) swallows every dial failure and timeout into the same
96
+ * `undefined`. The read path does separate the two (`CoordinatorRepo`'s `silent` set). If a
97
+ * reconcile-side decline ever needs that distinction, widen `fetchArchive` to report unreachability
98
+ * rather than trying to infer it here.
99
+ */
100
+ type PeerAnswer =
101
+ /** Served an archive covering the committed revision or better. */
102
+ | { kind: 'claim'; candidate: ReconcileCandidate }
103
+ /** Served an archive, but it covers nothing at or above the revision being healed. */
104
+ | { kind: 'behind' }
105
+ /** Served no archive: holds nothing, or is unreachable — see the note above. */
106
+ | { kind: 'no-archive' }
107
+ /** The fetch threw. */
108
+ | { kind: 'error' };
109
+
110
+ /**
111
+ * The claim a peer's archive makes: its highest revision, provided that revision is at least the
112
+ * one we committed. `undefined` when the archive carries only revisions older than the commit we are
113
+ * healing, or no usable action at its highest.
114
+ */
115
+ function toCandidate(peerId: string, archive: BlockArchive, committedRev: number): ReconcileCandidate | undefined {
116
+ const maxRev = maxArchiveRevision(archive.revisions);
117
+ if (maxRev === undefined || maxRev < committedRev) return undefined;
118
+ const data = archive.revisions[maxRev];
119
+ if (!data?.action) return undefined;
120
+ return {
121
+ peerId, rev: maxRev, actionId: data.action.actionId, block: data.block,
122
+ ...(data.proof ? { proof: data.proof } : {})
123
+ };
124
+ }
125
+
126
+ /**
127
+ * One peer's answer, isolated. `fetchArchive` is contracted to answer `undefined` for an
128
+ * unreachable peer, but a raw `Promise.all` over the cohort would let a single rejecting fetch
129
+ * discard the answers every other peer already gave — turning a heal the cohort could complete
130
+ * into a decline. One peer's failure costs only that peer's vote.
131
+ */
132
+ async function fetchAnswer(
133
+ deps: ReconcileBlockDeps,
134
+ peerId: string,
135
+ blockId: BlockId,
136
+ committedRev: number
137
+ ): Promise<PeerAnswer> {
138
+ let archive: BlockArchive | undefined;
139
+ try {
140
+ archive = await deps.fetchArchive(peerId, blockId);
141
+ } catch (err) {
142
+ log('reconcile:fetch-error', { blockId, peerId, error: (err as Error).message });
143
+ return { kind: 'error' };
144
+ }
145
+ if (!archive) return { kind: 'no-archive' };
146
+ const candidate = toCandidate(peerId, archive, committedRev);
147
+ return candidate ? { kind: 'claim', candidate } : { kind: 'behind' };
148
+ }
149
+
150
+ /**
151
+ * Run every proof-carrying candidate through the shared certification layer
152
+ * (`cluster/certified-claims.ts`) and inject the verdicts, BEFORE selection reads the claim set.
153
+ * Mirrors the read path's certifyClaim pass (`CoordinatorRepo.queryClusterForLatest`) same
154
+ * thresholds, same failure logging, same penalty discipline:
155
+ *
156
+ * - A candidate whose claim half fails stays an ORDINARY uncertified corroborator (its vote is
157
+ * never dropped a peer that could fabricate a bad proof could equally have sent none), and
158
+ * only an attributable failure (`isAttributableProofFailure`) penalizes the serving peer.
159
+ * - `digest-mismatch` is the one content-side rejection: the served bytes provably contradict
160
+ * the proof's declared digest, so the candidate is dropped from the content quorum and the
161
+ * server penalized — while its (genuinely verified) rev claim still counts.
162
+ * - `no-digest-declared` leaves the content an ordinary uncertified carrier: the cohort declared
163
+ * nothing to compare against, which is a verdict, never misbehavior.
164
+ *
165
+ * NOTE: cost is one verification pass per proof-carrying answer per reconcile, each bounded by
166
+ * MAX_PROOF_SIGNERS (256, the shared layer's cap) signature checks × cohort width — fine at
167
+ * deployment cohort sizes (~10).
168
+ */
169
+ async function certifyCandidates(deps: ReconcileBlockDeps, blockId: BlockId, candidates: ReconcileCandidate[]): Promise<void> {
170
+ // Shared with the read path, so the two cannot drift on what the members actually enforced —
171
+ // see `proofThresholds` for why the simple-majority term is not deps.simpleMajorityThreshold.
172
+ const thresholds = proofThresholds(deps.superMajorityThreshold);
173
+ await Promise.all(candidates.map(async c => {
174
+ if (!c.proof) return;
175
+ const claim = { blockId, rev: c.rev, actionId: c.actionId as ActionId };
176
+ if (c.block) {
177
+ const verdict = await certifyContent(c.proof, claim, c.block, thresholds, deps.anchoring);
178
+ if (verdict.revCertified) {
179
+ c.revCertified = true;
180
+ if (verdict.contentCertified) {
181
+ c.contentCertified = true;
182
+ } else if (verdict.failure === 'digest-mismatch') {
183
+ // The claim half genuinely passed; the bytes provably lie. Drop them from the
184
+ // content quorum and penalize the server — repair continues on the other holders.
185
+ c.contentRejected = true;
186
+ log('reconcile:content-rejected', { blockId, peerId: c.peerId, rev: c.rev });
187
+ penalizeProofService(deps.reputation, c.peerId, blockId);
188
+ }
189
+ // no-digest-declared: content stays an ordinary uncertified carrier, no penalty.
190
+ return;
191
+ }
192
+ logUncertified(blockId, c, verdict.failure);
193
+ if (isAttributableProofFailure(verdict.failure)) {
194
+ penalizeProofService(deps.reputation, c.peerId, blockId);
195
+ }
196
+ return;
197
+ }
198
+ const verdict = await certifyClaim(c.proof, claim, thresholds, deps.anchoring);
199
+ if (verdict.certified) {
200
+ c.revCertified = true;
201
+ return;
202
+ }
203
+ logUncertified(blockId, c, verdict.failure);
204
+ if (isAttributableProofFailure(verdict.failure)) {
205
+ penalizeProofService(deps.reputation, c.peerId, blockId);
206
+ }
207
+ }));
208
+ }
209
+
210
+ /** Mirror of `cluster-fetch:proof-uncertified` on the read path — same fields, reconcile-side name. */
211
+ function logUncertified(blockId: BlockId, c: ReconcileCandidate, failure: string): void {
212
+ log('reconcile:proof-uncertified', { blockId, peerId: c.peerId, rev: c.rev, failure });
213
+ }
214
+
215
+ /**
216
+ * Best-effort penalty for a peer whose served proof (or the bytes served under it) provably lies.
217
+ * Mirrors `CoordinatorRepo.penalizeProofService` — never throws.
218
+ */
219
+ function penalizeProofService(
220
+ reputation: Pick<IPeerReputation, 'reportPeer'> | undefined, peerId: string, blockId: BlockId
221
+ ): void {
222
+ if (!reputation) return;
223
+ try {
224
+ reputation.reportPeer(peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
225
+ } catch (err) {
226
+ log('reconcile:penalize-error', { blockId, error: (err as Error).message });
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Hash the block bytes of every candidate that both corroborates `selected` and actually carried
232
+ * content — minus any whose bytes were rejected against their own proof's digest
233
+ * (`contentRejected`). A carrier whose content CERTIFIED carries its `certified` flag into the
234
+ * selector and its proof alongside: that is the only proof safe to persist, because it was
235
+ * verified against these exact bytes.
236
+ *
237
+ * NOTE: this canonical-JSON-serializes and sha256s every carrier's whole block on every reconcile.
238
+ * Negligible at today's cohort widths and block sizes; if blocks grow large or cohorts wide enough
239
+ * for this to show up on a commit-path profile, hash incrementally at receive time instead.
240
+ */
241
+ async function hashCarriers(
242
+ candidates: ReconcileCandidate[], selected: QuorumRev
243
+ ): Promise<(BlockHashCandidate & { proof?: BlockCommitProof })[]> {
244
+ const carriers = candidates.filter(c =>
245
+ c.rev === selected.rev && c.actionId === selected.actionId && c.block && !c.contentRejected);
246
+ return await Promise.all(
247
+ carriers.map(async c => ({
248
+ peerId: c.peerId, hash: await canonicalBlockHash(c.block!), block: c.block!,
249
+ ...(c.contentCertified ? { certified: true } : {}),
250
+ ...(c.contentCertified && c.proof ? { proof: c.proof } : {})
251
+ }))
252
+ );
253
+ }
254
+
255
+ /** Report cohort members that served content contradicting the agreed hash. Best-effort; never throws. */
256
+ function penalizeContradictingContent(
257
+ reputation: Pick<IPeerReputation, 'reportPeer'> | undefined,
258
+ candidates: BlockHashCandidate[],
259
+ agreedHash: string,
260
+ blockId: BlockId
261
+ ): void {
262
+ if (!reputation) return;
263
+ try {
264
+ for (const c of candidates) {
265
+ if (c.hash !== agreedHash) {
266
+ reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
267
+ }
268
+ }
269
+ } catch (err) {
270
+ log('reconcile:penalize-error', { blockId, error: (err as Error).message });
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Active reconciliation for a block this member committed without a materializable base
276
+ * (cohort drift between the independent pend and commit cluster-transactions, or a refused
277
+ * `missing-base-revision` commit). Queries the commit cohort — self already excluded by
278
+ * `ClusterMember.reconcileDivergentCommit` — for the block, picks the target revision by quorum
279
+ * corroboration rather than raw `Math.max` (a lone peer inflating its rev cannot steer
280
+ * reconciliation), verifies the cohort agrees on the *content* at that revision, and persists it.
281
+ *
282
+ * Peer-attached cohort commit proofs are verified first ({@link certifyCandidates}) and both gates
283
+ * weigh the verdicts: a claim — and, separately, the bytes — that a verified proof certifies is
284
+ * accepted with no second peer at any cohort size, and the proof that certified the bytes is
285
+ * persisted alongside them so the repaired replica serves it onward.
286
+ *
287
+ * Both quorums are capped by {@link corroboratorCapacity} for the claims that still need
288
+ * corroboration: demanding two corroborators from a cohort that contains exactly one other peer is
289
+ * a permanent deadlock, not a safety property — the node can never heal and stays unreadable
290
+ * forever.
291
+ *
292
+ * **Exposure at capacity 1 (documented, not accidental).** Block ids are random 256-bit strings
293
+ * (`db-core` `structs.ts`), NOT content-addressed, so nothing on the receive path can re-derive
294
+ * the id from the bytes: `canonicalBlockHash` is a cross-peer *agreement* hash, never a check
295
+ * against `blockId`. A sole cohort peer's content is therefore believed on its word. That adds no
296
+ * trust the cohort had not already extended — the same peer's `(rev, actionId)` claim is likewise
297
+ * uncorroborable at that size (see `selectQuorumRev`'s capacity note), and a two-member cohort has
298
+ * no honest majority to appeal to in the first place. It is closed for a candidate that carries a
299
+ * verified cohort commit proof — {@link certifyCandidates} binds the proof's declared digest to the
300
+ * served bytes, which is a check against the *cohort's own signatures* rather than against other
301
+ * peers, so a certified carrier wins the content gate outright and this exposure never applies to
302
+ * it. It remains open for a proof-less candidate, and for the residual that layer 1 proves only
303
+ * that the listed signers signed (`feat-cluster-membership-threshold-cert-anchoring`).
304
+ *
305
+ * Declines are cheap and retryable: nothing is persisted, nothing is marked, and the next commit
306
+ * or churn/rebalance pass tries again.
307
+ */
308
+ export function createReconcileBlock(deps: ReconcileBlockDeps): ReconcileBlockCallback {
309
+ return async (blockId, committed, cohortPeerIds) => {
310
+ const targets = cohortPeerIds.filter(id => id !== deps.selfPeerId);
311
+ if (targets.length === 0) return;
312
+
313
+ const answers = await Promise.all(
314
+ targets.map(peerId => fetchAnswer(deps, peerId, blockId, committed.rev))
315
+ );
316
+ const candidates = answers.flatMap(a => a.kind === 'claim' ? [a.candidate] : []);
317
+ const tally = (kind: PeerAnswer['kind']) => answers.filter(a => a.kind === kind).length;
318
+ const capacity = corroboratorCapacity(targets.length, deps.repairCorroborationClusterSize);
319
+
320
+ await certifyCandidates(deps, blockId, candidates);
321
+
322
+ const revClaims: RevClaim[] = candidates.map(({ peerId, rev, actionId, revCertified }) => ({
323
+ peerId, rev, actionId, ...(revCertified ? { certified: true } : {})
324
+ }));
325
+ const selected = selectQuorumRev(revClaims, deps.simpleMajorityThreshold, capacity);
326
+ if (!selected) {
327
+ // Asked ONLY on a decline: a certified-vs-certified conflict can coexist with a successful
328
+ // selection (a corroborated pair strictly above the top certified rev wins), so a
329
+ // non-undefined answer here explains THIS decline, nothing more. Two verified proofs for
330
+ // distinct actions at one revision means the cohort (or whoever holds its keys) provably
331
+ // signed both sides — an incident, not a shortage of answers; neither claimant is
332
+ // penalized, because which side is wrong is exactly what this node cannot know.
333
+ const equivocation = certifiedEquivocation(revClaims);
334
+ if (equivocation) {
335
+ log('reconcile:certified-equivocation', {
336
+ blockId, rev: equivocation.rev, actionIds: equivocation.actionIds
337
+ });
338
+ }
339
+ // Leave the block behind; churn/rebalance and the next commit retry.
340
+ log('reconcile:no-rev-quorum', {
341
+ blockId,
342
+ rev: committed.rev,
343
+ cohortPeers: targets.length,
344
+ holders: revClaims.length,
345
+ behind: tally('behind'),
346
+ noArchive: tally('no-archive'),
347
+ fetchErrors: tally('error'),
348
+ required: quorumSize(revClaims.length, deps.simpleMajorityThreshold, capacity),
349
+ repairCorroborationClusterSize: deps.repairCorroborationClusterSize
350
+ });
351
+ return;
352
+ }
353
+
354
+ if (selected.certified) {
355
+ // Which rule won matters when reading a repair log: a certified selection may rest on a
356
+ // SINGLE claimant whose corroboration is the cohort's signature set, not other voters.
357
+ log('reconcile:certified-selected', {
358
+ blockId, rev: selected.rev, claimants: selected.supporters.length
359
+ });
360
+ }
361
+
362
+ const hashCandidates = await hashCarriers(candidates, selected);
363
+ const agreed = selectQuorumBlock(hashCandidates, deps.simpleMajorityThreshold, capacity);
364
+ if (!agreed) {
365
+ // Two certified hashes at one (rev, actionId) means the cohort's keys signed two digests
366
+ // into one revision — a provable compromise an operator must be able to tell apart from a
367
+ // routine carrier shortfall; without this line the two declines log identically.
368
+ const equivocation = certifiedContentEquivocation(hashCandidates);
369
+ if (equivocation) {
370
+ log('reconcile:certified-content-equivocation', {
371
+ blockId, rev: selected.rev, hashes: equivocation.hashes
372
+ });
373
+ }
374
+ log('reconcile:no-content-quorum', {
375
+ blockId,
376
+ rev: selected.rev,
377
+ carriers: hashCandidates.length,
378
+ required: quorumSize(hashCandidates.length, deps.simpleMajorityThreshold, capacity),
379
+ repairCorroborationClusterSize: deps.repairCorroborationClusterSize
380
+ });
381
+ return;
382
+ }
383
+
384
+ // Detection is sound: selectQuorumBlock's certified branch fires iff exactly one distinct
385
+ // certified hash exists, so a defined `agreed` with a certified carrier at `agreed.hash` ⇔
386
+ // the certified rule won.
387
+ const certifiedCarrier = hashCandidates.find(c => c.certified === true && c.hash === agreed.hash);
388
+
389
+ // Contradicting-content penalties run ONLY on a corroborated win, mirroring the read path's
390
+ // rule (CoordinatorRepo.penalizeContradictingRevClaims): an unanchored proof must not be able
391
+ // to convict the honest cohort — anyone holding N keys can mint a proof that verifies, and
392
+ // penalizing dissenters against it would hand a forged proof a reputation lever. Revisit when
393
+ // certification is anchored to the block's derived cohort
394
+ // (`feat-cluster-membership-threshold-cert-anchoring`).
395
+ if (!certifiedCarrier) {
396
+ penalizeContradictingContent(deps.reputation, hashCandidates, agreed.hash, blockId);
397
+ }
398
+
399
+ // A corroboration-won heal with no certified carrier persists no proof — today's behavior.
400
+ await deps.saveReplicatedBlock(
401
+ blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev }, certifiedCarrier?.proof);
402
+ log('reconcile:restored', { blockId, rev: selected.rev, actionId: selected.actionId });
403
+ };
404
+ }