@serfab/cadre-core 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/README.md +4 -3
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +81 -142
  6. package/dist/cadre-invite-protocol.js +168 -458
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +1089 -475
  12. package/dist/cadre-node.js +2170 -1037
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +88 -32
  18. package/dist/control-cohort.js +91 -33
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +856 -318
  21. package/dist/control-database.js +1422 -701
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-formation-recorder.d.ts +46 -43
  24. package/dist/control-formation-recorder.js +77 -78
  25. package/dist/control-formation-recorder.js.map +1 -1
  26. package/dist/control-protocol-guard.d.ts +11 -4
  27. package/dist/control-protocol-guard.js +20 -5
  28. package/dist/control-protocol-guard.js.map +1 -1
  29. package/dist/control-schema-version.d.ts +97 -0
  30. package/dist/control-schema-version.js +225 -0
  31. package/dist/control-schema-version.js.map +1 -0
  32. package/dist/control-schema.d.ts +45 -5
  33. package/dist/control-schema.js +262 -78
  34. package/dist/control-schema.js.map +1 -1
  35. package/dist/control-write-retry.d.ts +45 -10
  36. package/dist/control-write-retry.js +92 -22
  37. package/dist/control-write-retry.js.map +1 -1
  38. package/dist/delegate-admission.d.ts +13 -0
  39. package/dist/delegate-admission.js +27 -0
  40. package/dist/delegate-admission.js.map +1 -1
  41. package/dist/ed25519-key.d.ts +10 -7
  42. package/dist/ed25519-key.js +23 -20
  43. package/dist/ed25519-key.js.map +1 -1
  44. package/dist/edge-node/config.d.ts +122 -0
  45. package/dist/edge-node/config.js +199 -0
  46. package/dist/edge-node/config.js.map +1 -0
  47. package/dist/edge-node/dissolve.d.ts +95 -0
  48. package/dist/edge-node/dissolve.js +184 -0
  49. package/dist/edge-node/dissolve.js.map +1 -0
  50. package/dist/edge-node/edge-join.d.ts +44 -0
  51. package/dist/edge-node/edge-join.js +227 -0
  52. package/dist/edge-node/edge-join.js.map +1 -0
  53. package/dist/edge-node/edge-node.d.ts +295 -0
  54. package/dist/edge-node/edge-node.js +704 -0
  55. package/dist/edge-node/edge-node.js.map +1 -0
  56. package/dist/edge-node/index.d.ts +18 -0
  57. package/dist/edge-node/index.js +18 -0
  58. package/dist/edge-node/index.js.map +1 -0
  59. package/dist/edge-node/join-credential.d.ts +11 -0
  60. package/dist/edge-node/join-credential.js +11 -0
  61. package/dist/edge-node/join-credential.js.map +1 -0
  62. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  63. package/dist/edge-node/lazy-raw-storage.js +134 -0
  64. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  65. package/dist/edge-node/node-local.d.ts +60 -0
  66. package/dist/edge-node/node-local.js +58 -0
  67. package/dist/edge-node/node-local.js.map +1 -0
  68. package/dist/edge-node/options.d.ts +83 -0
  69. package/dist/edge-node/options.js +110 -0
  70. package/dist/edge-node/options.js.map +1 -0
  71. package/dist/edge-node/owner-grant.d.ts +41 -0
  72. package/dist/edge-node/owner-grant.js +38 -0
  73. package/dist/edge-node/owner-grant.js.map +1 -0
  74. package/dist/edge-node/platform.d.ts +140 -0
  75. package/dist/edge-node/platform.js +12 -0
  76. package/dist/edge-node/platform.js.map +1 -0
  77. package/dist/edge-node/strand-move.d.ts +115 -0
  78. package/dist/edge-node/strand-move.js +223 -0
  79. package/dist/edge-node/strand-move.js.map +1 -0
  80. package/dist/edge-node/strands.d.ts +28 -0
  81. package/dist/edge-node/strands.js +60 -0
  82. package/dist/edge-node/strands.js.map +1 -0
  83. package/dist/enrolled-machine-store.d.ts +3 -3
  84. package/dist/enrolled-machine-store.js +3 -3
  85. package/dist/formation-token.d.ts +48 -0
  86. package/dist/formation-token.js +26 -0
  87. package/dist/formation-token.js.map +1 -0
  88. package/dist/index.d.ts +27 -17
  89. package/dist/index.js +39 -17
  90. package/dist/index.js.map +1 -1
  91. package/dist/joined-strand-store.d.ts +29 -12
  92. package/dist/joined-strand-store.js +66 -27
  93. package/dist/joined-strand-store.js.map +1 -1
  94. package/dist/key-store.d.ts +9 -4
  95. package/dist/key-store.js +9 -4
  96. package/dist/key-store.js.map +1 -1
  97. package/dist/link-budget.d.ts +9 -7
  98. package/dist/link-budget.js +9 -7
  99. package/dist/link-budget.js.map +1 -1
  100. package/dist/machine-removal.d.ts +19 -39
  101. package/dist/machine-removal.js +24 -98
  102. package/dist/machine-removal.js.map +1 -1
  103. package/dist/member-redemption.d.ts +256 -0
  104. package/dist/member-redemption.js +490 -0
  105. package/dist/member-redemption.js.map +1 -0
  106. package/dist/membership-view.d.ts +91 -0
  107. package/dist/membership-view.js +110 -0
  108. package/dist/membership-view.js.map +1 -0
  109. package/dist/owner-anchor-sync.d.ts +4 -4
  110. package/dist/owner-anchor-sync.js +16 -15
  111. package/dist/owner-anchor-sync.js.map +1 -1
  112. package/dist/owner-grant-protocol.d.ts +251 -0
  113. package/dist/owner-grant-protocol.js +344 -0
  114. package/dist/owner-grant-protocol.js.map +1 -0
  115. package/dist/owner-key-export.d.ts +70 -0
  116. package/dist/owner-key-export.js +279 -0
  117. package/dist/owner-key-export.js.map +1 -0
  118. package/dist/owner-key-flows.d.ts +55 -0
  119. package/dist/owner-key-flows.js +60 -0
  120. package/dist/owner-key-flows.js.map +1 -0
  121. package/dist/owner-key-store.d.ts +31 -0
  122. package/dist/owner-key-store.js +81 -0
  123. package/dist/owner-key-store.js.map +1 -0
  124. package/dist/owner-keys.d.ts +78 -0
  125. package/dist/owner-keys.js +133 -0
  126. package/dist/owner-keys.js.map +1 -0
  127. package/dist/owner-signer.d.ts +54 -0
  128. package/dist/owner-signer.js +51 -0
  129. package/dist/owner-signer.js.map +1 -0
  130. package/dist/peer-addr-book.d.ts +10 -1
  131. package/dist/peer-addr-book.js +4 -3
  132. package/dist/peer-addr-book.js.map +1 -1
  133. package/dist/peer-authorization.d.ts +83 -18
  134. package/dist/peer-authorization.js +123 -53
  135. package/dist/peer-authorization.js.map +1 -1
  136. package/dist/peer-join-backfill.d.ts +89 -20
  137. package/dist/peer-join-backfill.js +265 -108
  138. package/dist/peer-join-backfill.js.map +1 -1
  139. package/dist/pending-join-runner.d.ts +48 -18
  140. package/dist/pending-join-runner.js +74 -56
  141. package/dist/pending-join-runner.js.map +1 -1
  142. package/dist/provider/rent-node.js +1 -1
  143. package/dist/provider/rent-node.js.map +1 -1
  144. package/dist/seed-bootstrap.d.ts +162 -133
  145. package/dist/seed-bootstrap.js +300 -283
  146. package/dist/seed-bootstrap.js.map +1 -1
  147. package/dist/seed-trust-policy.js +4 -4
  148. package/dist/strand-addr-protocol.d.ts +4 -1
  149. package/dist/strand-addr-protocol.js +8 -2
  150. package/dist/strand-addr-protocol.js.map +1 -1
  151. package/dist/strand-cohort.d.ts +3 -0
  152. package/dist/strand-cohort.js.map +1 -1
  153. package/dist/strand-database.d.ts +24 -2
  154. package/dist/strand-database.js +29 -6
  155. package/dist/strand-database.js.map +1 -1
  156. package/dist/strand-first-sync-gate.d.ts +176 -88
  157. package/dist/strand-first-sync-gate.js +288 -121
  158. package/dist/strand-first-sync-gate.js.map +1 -1
  159. package/dist/strand-formation-deadlines.d.ts +9 -9
  160. package/dist/strand-formation-deadlines.js +9 -9
  161. package/dist/strand-formation-manager.d.ts +23 -17
  162. package/dist/strand-formation-manager.js +46 -44
  163. package/dist/strand-formation-manager.js.map +1 -1
  164. package/dist/strand-formation-protocol.d.ts +5 -8
  165. package/dist/strand-formation-protocol.js +101 -16
  166. package/dist/strand-formation-protocol.js.map +1 -1
  167. package/dist/strand-instance-manager.d.ts +27 -18
  168. package/dist/strand-instance-manager.js +119 -50
  169. package/dist/strand-instance-manager.js.map +1 -1
  170. package/dist/strand-membership-reconciler.d.ts +16 -13
  171. package/dist/strand-membership-reconciler.js +30 -22
  172. package/dist/strand-membership-reconciler.js.map +1 -1
  173. package/dist/strand-membership-writer.d.ts +45 -22
  174. package/dist/strand-membership-writer.js +226 -131
  175. package/dist/strand-membership-writer.js.map +1 -1
  176. package/dist/strand-move.d.ts +6 -5
  177. package/dist/strand-move.js +36 -32
  178. package/dist/strand-move.js.map +1 -1
  179. package/dist/strand-peer-redial.js +2 -3
  180. package/dist/strand-peer-redial.js.map +1 -1
  181. package/dist/strand-revocation-enforcer.d.ts +134 -60
  182. package/dist/strand-revocation-enforcer.js +144 -71
  183. package/dist/strand-revocation-enforcer.js.map +1 -1
  184. package/dist/strand-solicitation.d.ts +50 -39
  185. package/dist/strand-solicitation.js +10 -3
  186. package/dist/strand-solicitation.js.map +1 -1
  187. package/dist/strand-watcher.d.ts +80 -13
  188. package/dist/strand-watcher.js +152 -48
  189. package/dist/strand-watcher.js.map +1 -1
  190. package/dist/table-change-watch.d.ts +63 -0
  191. package/dist/table-change-watch.js +209 -0
  192. package/dist/table-change-watch.js.map +1 -0
  193. package/dist/timeout-scheduler.d.ts +4 -3
  194. package/dist/timeout-scheduler.js +4 -3
  195. package/dist/timeout-scheduler.js.map +1 -1
  196. package/dist/types.d.ts +374 -150
  197. package/dist/types.js +4 -4
  198. package/dist/types.js.map +1 -1
  199. package/package.json +17 -8
  200. package/src/bootstrap-peer-store-file.ts +44 -44
  201. package/src/bootstrap-peer-store.ts +214 -214
  202. package/src/cadre-invite-protocol.ts +740 -1052
  203. package/src/cadre-join-request.ts +24 -3
  204. package/src/cadre-node.ts +2402 -1079
  205. package/src/control-authorization.ts +47 -14
  206. package/src/control-cohort.ts +204 -128
  207. package/src/control-database.ts +1749 -820
  208. package/src/control-formation-recorder.ts +359 -353
  209. package/src/control-protocol-guard.ts +23 -6
  210. package/src/control-schema-version.ts +301 -0
  211. package/src/control-schema.ts +273 -78
  212. package/src/control-write-retry.ts +641 -567
  213. package/src/delegate-admission.ts +351 -314
  214. package/src/ed25519-key.ts +174 -171
  215. package/src/edge-node/config.ts +277 -0
  216. package/src/edge-node/dissolve.ts +251 -0
  217. package/src/edge-node/edge-join.ts +319 -0
  218. package/src/edge-node/edge-node.ts +1045 -0
  219. package/src/edge-node/index.ts +59 -0
  220. package/src/edge-node/join-credential.ts +16 -0
  221. package/src/edge-node/lazy-raw-storage.ts +178 -0
  222. package/src/edge-node/node-local.ts +84 -0
  223. package/src/edge-node/options.ts +179 -0
  224. package/src/edge-node/owner-grant.ts +63 -0
  225. package/src/edge-node/platform.ts +151 -0
  226. package/src/edge-node/strand-move.ts +270 -0
  227. package/src/edge-node/strands.ts +72 -0
  228. package/src/enrolled-machine-store.ts +3 -3
  229. package/src/formation-token.ts +63 -0
  230. package/src/index.ts +899 -789
  231. package/src/joined-strand-store.ts +73 -28
  232. package/src/key-store.ts +10 -4
  233. package/src/link-budget.ts +9 -7
  234. package/src/machine-removal.ts +26 -111
  235. package/src/member-redemption.ts +649 -0
  236. package/src/membership-view.ts +144 -0
  237. package/src/owner-anchor-sync.ts +19 -18
  238. package/src/owner-grant-protocol.ts +547 -0
  239. package/src/owner-key-export.ts +348 -0
  240. package/src/owner-key-flows.ts +109 -0
  241. package/src/owner-key-store.ts +85 -0
  242. package/src/owner-keys.ts +178 -0
  243. package/src/owner-signer.ts +94 -0
  244. package/src/peer-addr-book.ts +4 -3
  245. package/src/peer-authorization.ts +558 -488
  246. package/src/peer-join-backfill.ts +971 -761
  247. package/src/pending-join-runner.ts +76 -53
  248. package/src/provider/rent-node.ts +1 -1
  249. package/src/seed-bootstrap.ts +343 -349
  250. package/src/seed-trust-policy.ts +4 -4
  251. package/src/strand-addr-protocol.ts +547 -538
  252. package/src/strand-cohort.ts +4 -0
  253. package/src/strand-database.ts +352 -319
  254. package/src/strand-first-sync-gate.ts +334 -133
  255. package/src/strand-formation-deadlines.ts +227 -227
  256. package/src/strand-formation-manager.ts +731 -725
  257. package/src/strand-formation-protocol.ts +122 -24
  258. package/src/strand-instance-manager.ts +2092 -2026
  259. package/src/strand-membership-reconciler.ts +33 -25
  260. package/src/strand-membership-writer.ts +2093 -1974
  261. package/src/strand-move.ts +50 -38
  262. package/src/strand-peer-redial.ts +2 -3
  263. package/src/strand-revocation-enforcer.ts +740 -654
  264. package/src/strand-solicitation.ts +61 -34
  265. package/src/strand-watcher.ts +175 -56
  266. package/src/table-change-watch.ts +285 -0
  267. package/src/timeout-scheduler.ts +29 -28
  268. package/src/types.ts +391 -152
@@ -4,12 +4,18 @@ import { peerJoinPushBudget } from './link-budget.js';
4
4
  // Peer-join whole-store block catch-up, shared by the STRAND networks and the CONTROL
5
5
  // network. On a new peer's `peer:identify` result — libp2p's identify protocol runs once per
6
6
  // connection and reports the protocols the remote supports — naming this network's own
7
- // block-transfer protocol, it schedules a debounced push of every committed, materialized
8
- // block in the local raw store to that peer, so a machine that joined after blocks were
9
- // committed still ends up physically holding them. Optimystic has no cohort-join catch-up
10
- // of its own; a block committed while its writer was alone has exactly one holder forever
11
- // without this (named collection-header blocks are written once, at collection creation,
12
- // and their revision never moves again — so no later commit ever carries them anywhere).
7
+ // block-transfer protocol, it schedules a push of every committed, materialized block in the
8
+ // local raw store that the peer does not already hold (see "What is pushed" below), so a
9
+ // machine that joined after blocks were committed still ends up physically holding them.
10
+ // Optimystic has no cohort-join catch-up of its own; a block committed while its writer was
11
+ // alone has exactly one holder forever without this (named collection-header blocks are
12
+ // written once, at collection creation, and their revision never moves again — so no later
13
+ // commit ever carries them anywhere).
14
+ //
15
+ // A peer this instance has never started a run for is run at once; later schedules for it wait
16
+ // out `debounceMs`. The joining machine's first-sync gate waits for this push rather than
17
+ // fetching the same blocks one read at a time, so on first contact a settle window only delays
18
+ // the joiner. Identify churn during that first run is replayed once the run ends.
13
19
  //
14
20
  // A peer whose identify does not name the protocol — a bare circuit relay, a bootstrap node,
15
21
  // or (on the control network) a stranger the inbound gate admits without membership — is
@@ -20,7 +26,7 @@ import { peerJoinPushBudget } from './link-budget.js';
20
26
  // printing exactly that warning about their own relay, forever, on every reconnect. See
21
27
  // {@link speaksBlockTransfer}.
22
28
  //
23
- // NOTE: this copies the WHOLE local store to every newly connected peer, which is right
29
+ // NOTE: this offers the WHOLE local store to every newly connected peer, which is right
24
30
  // while a network is one party's handful of machines (see docs/architecture.md →
25
31
  // Replication cluster size — the control network replicates to the whole party by intent,
26
32
  // and "On a strand, the machine count is not a party count"). If these meshes ever get
@@ -32,10 +38,11 @@ import { peerJoinPushBudget } from './link-budget.js';
32
38
  // than running two.
33
39
  //
34
40
  // NOTE: strand nodes pass db-p2p no `kvStore`, so Optimystic's under-replication ledger (the
35
- // copies a node owes peers) dies at every restart. This module's whole-store push on connect is
36
- // what delivers a write a machine made alone after it restarts (`joiner-first-commit-over-slow-relay`,
37
- // restart arm). If this module is narrowed (the cohort filter above) or deleted, give strand
38
- // nodes a persistent kvStore first.
41
+ // copies a node owes peers) dies at every restart. This module's push on connect is what
42
+ // delivers a write a machine made alone after it restarts (`joiner-first-commit-over-slow-relay`,
43
+ // restart arm): the peer did not sign that commit, so the block is still offered to it. If this
44
+ // module is narrowed (the cohort filter above) or deleted, give strand nodes a persistent
45
+ // kvStore first.
39
46
  //
40
47
  // Membership — the one place the two networks differ, expressed as the optional
41
48
  // `authorizePeer` dep:
@@ -50,11 +57,12 @@ import { peerJoinPushBudget } from './link-budget.js';
50
57
  // invitation, configured bootstrap/relay peers — see docs/architecture.md, the
51
58
  // control-network inbound connection gate). Pushing the whole control store to such a
52
59
  // peer would hand a stranger the party's entire membership, addresses and strand list.
53
- // `CadreNode` passes `isAuthorizedMember`; the gate is consulted at PUSH time, not at
54
- // schedule time, and fails closed on a thrown check. A denied run is not memoized, so
55
- // the peer is retried on its next `peer:identify` (a reconnect re-runs identify) — or
56
- // sooner, via `scheduleConnectedPeers()` on a membership change (the production join order is
57
- // connect-then-authorize, so the denial at dial time is the expected first pass).
60
+ // `CadreNode` passes a lookup in its in-memory authorized-member snapshot; the gate is
61
+ // consulted at PUSH time, not at schedule time, and fails closed on a thrown check. A denied
62
+ // run is not memoized, so the peer is retried on its next `peer:identify` (a reconnect
63
+ // re-runs identify) — or sooner, via `scheduleConnectedPeers()` on a membership change (the
64
+ // production join order is connect-then-authorize, so the denial at dial time is the
65
+ // expected first pass).
58
66
  //
59
67
  // Both per-push deadlines are DERIVED from the declared link round trip
60
68
  // (`link-budget.ts`), not fixed milliseconds. That is not tuning — it is what makes this
@@ -89,21 +97,17 @@ import { peerJoinPushBudget } from './link-budget.js';
89
97
  // budgets its dial at `transferTimeoutMs ?? 30000` — the one dial budget in the stack
90
98
  // above the measured relayed setup cost — so it succeeded where this one could not.
91
99
  //
92
- // A NON-CLEAN run is NOT on its own enough to re-arm, and the distinction is what keeps the
93
- // backoff from becoming a worse problem than the one it fixes. `clean` also goes false for
94
- // outcomes no amount of retrying can change: the membership gate DENIED the peer; this
95
- // network's raw storage implements no `listBlockIds`, so the catch-up is inert; or the
96
- // receiver reported blocks in `missing`, which it does per block for a payload it cannot
97
- // parse and for a revision whose retained commit proof this node does not hold (a push
98
- // carrying no proof is refused outright by a receiver running the default
99
- // `requirePushCertificate: true`, and an unretained proof is the ordinary case — see
100
- // {@link Chunk.proofs}). Re-arming on those would re-push the WHOLE store to that peer every
101
- // `maxRetryBackoffMs` for as long as the node runs, and report the failure below as a link
102
- // budget problem when nothing about the link is wrong. All three still leave the peer
103
- // un-memoized, so its next `peer:identify` retries — the behaviour that predates the
104
- // backoff, and the right one for a verdict rather than a timeout. Denial in particular is
105
- // re-driven on purpose by `scheduleConnectedPeers()` the moment the membership commit lands,
106
- // because the control network's join order is connect-then-authorize.
100
+ // A retry re-sends only the blocks the peer has not yet accepted (see "What is pushed"), so a
101
+ // run that died part-way through the store resumes where it stopped.
102
+ //
103
+ // Only a failed push re-arms. Two other outcomes also leave a run short of catching the peer
104
+ // up, and no amount of retrying changes them: the membership gate DENIED the peer, or this
105
+ // network's raw storage implements no `listBlockIds`, so the catch-up is inert. Re-arming on
106
+ // those would retry every `maxRetryBackoffMs` for as long as the node runs, and report the
107
+ // failure below as a link budget problem when nothing about the link is wrong. Neither marks
108
+ // the peer caught up, so its next `peer:identify` retries. Denial in particular is re-driven on
109
+ // purpose by `scheduleConnectedPeers()` the moment the membership commit lands, because the
110
+ // control network's join order is connect-then-authorize.
107
111
  //
108
112
  // A re-arm is also skipped when the peer is no longer CONNECTED. This module's trigger is
109
113
  // `peer:identify`, so a peer that went away already has one: re-arming instead would leave
@@ -114,6 +118,43 @@ import { peerJoinPushBudget } from './link-budget.js';
114
118
  // nothing at all: the connection simply never appears and only a DEBUG log mentions a dial
115
119
  // timeout.
116
120
  //
121
+ // WHAT IS PUSHED: each block once per peer. Per peer, this module remembers for the runtime the
122
+ // revision of each block the peer accepted and the revision it refused ({@link PeerMemo}). A
123
+ // refusal is the receiver's `missing` list: a payload it cannot parse, a block it failed to
124
+ // persist, or a revision whose retained commit proof this node does not hold (a receiver running the default
125
+ // `requirePushCertificate: true` refuses a push carrying no proof, and an unretained proof is
126
+ // the ordinary case — see {@link Chunk.proofs}). A refusal is a per-block verdict: logged once
127
+ // when it happens, and the block is offered to that peer again only once its local revision
128
+ // moves past the refused one. A run skips a block, counting it in `alreadyHeld`, when:
129
+ //
130
+ // - the peer accepted that revision or a later one from this node;
131
+ // - the peer refused that revision;
132
+ // - the commit proof retained for that revision carries the peer's own commit-round approval.
133
+ // A block's proof lists the cohort members that signed the commit round for exactly that
134
+ // revision, so a founder's solo commit is never pushed back to it by a joiner now holding it,
135
+ // and a revision two machines committed together is not pushed between them.
136
+ //
137
+ // The proof rule stands in for "do not push a block back to the peer it came from": Optimystic
138
+ // reports a push's sender only to its own rebalance monitor and exposes it to no embedder.
139
+ //
140
+ // NOTE: accepted tradeoff — a signer that crashed between signing and applying, or later lost its
141
+ // store, is not re-sent that revision by this path; read repair covers its reads, as for any block
142
+ // a cohort member lacks. Revisit if Optimystic tells embedders who holds what (it takes reports,
143
+ // `reportBlockHeld`, but gives none) or a measurement shows pushes this rule misses.
144
+ //
145
+ // NOTE: the reply does not say why a block is missing, so a receiver's transient persist failure
146
+ // is remembered as a refusal too and not retried this runtime; read repair covers that block's
147
+ // reads. If receivers log `persist:fail` in practice, ask Optimystic for a per-block reason in
148
+ // the push reply and remember only the verdicts a retry cannot change.
149
+ //
150
+ // A peer is marked caught up (`done`) once a run finishes with no push that threw, the gate
151
+ // admitted it and the store is listable. A refusal does not hold that back, since it is
152
+ // remembered per block. It stays caught up until its last connection closes
153
+ // (`peer:disconnect`): `peer:identify` churn for a connected, caught-up peer is dropped before
154
+ // any work, and a reconnect re-runs past the debounce, pushing only what the memo and the
155
+ // proofs do not cover. {@link PeerJoinBackfill.forceCatchUpPeer} runs past `done` too, with the
156
+ // same skips.
157
+ //
117
158
  // NOTE: the protocol check happens once — either when a connection's `peer:identify` fires,
118
159
  // or, for a peer already connected when this instance starts, when `scheduleConnectedPeers()`
119
160
  // reads its protocols back from the peer store — and is never repeated once a peer is
@@ -132,10 +173,11 @@ import { peerJoinPushBudget } from './link-budget.js';
132
173
  //
133
174
  // NOTE: a peer that disconnects INSIDE a backoff wait still costs the one attempt that wait
134
175
  // was already armed for — the connectivity check is made when the re-arm is decided, not when
135
- // the timer fires, and this module subscribes to `peer:identify` only. One dial, not a
136
- // recurring one; if a node ever holds many transient peers, subscribe to `connection:close`
137
- // and clear the pending timer there.
176
+ // the timer fires, and the `peer:disconnect` handler leaves backoff state alone (see
177
+ // `forgetCaughtUp`). One dial, not a recurring one; if a node ever holds many transient peers,
178
+ // clear the pending timer in that handler.
138
179
  const log = debug('sereus:cadre:peer-join-backfill');
180
+ const blockEncoder = new TextEncoder();
139
181
  /**
140
182
  * The block-transfer protocol's hard cap on one length-prefixed message. Mirrors
141
183
  * `MAX_BLOCK_MESSAGE_BYTES` in `@optimystic/db-p2p`'s `protocol-limits.ts` (8 MiB), which
@@ -176,7 +218,24 @@ export const DEFAULT_PEER_JOIN_BACKFILL = {
176
218
  maxRetryBackoffMs: 60000
177
219
  };
178
220
  function emptyResult() {
179
- return { offered: 0, accepted: 0, rejected: [], uncommitted: 0, unmaterialized: 0, capped: 0, oversized: [], denied: false };
221
+ return { offered: 0, alreadyHeld: 0, accepted: 0, rejected: [], uncommitted: 0, unmaterialized: 0, capped: 0, oversized: [], denied: false };
222
+ }
223
+ /** Whether the peer's replies already settled revision `rev` of `blockId`, either way. */
224
+ function memoSettled(memo, blockId, rev) {
225
+ return (memo.accepted.get(blockId) ?? -Infinity) >= rev
226
+ || (memo.rejected.get(blockId) ?? -Infinity) >= rev;
227
+ }
228
+ /** Whether `peerId` approved the commit round that `proof` certifies. */
229
+ function signedBy(proof, peerId) {
230
+ return proof?.commits[peerId]?.type === 'approve';
231
+ }
232
+ /** Record one push reply in the memo: each block as accepted or refused at the revision pushed. */
233
+ function recordReply(memo, revs, ids, missing) {
234
+ for (const id of ids) {
235
+ const rev = revs[id].rev;
236
+ const verdicts = missing.has(id) ? memo.rejected : memo.accepted;
237
+ verdicts.set(id, Math.max(verdicts.get(id) ?? -Infinity, rev));
238
+ }
180
239
  }
181
240
  /**
182
241
  * Base64 wire size of a raw buffer — `pushBlocks` base64-encodes each block into the JSON
@@ -193,8 +252,8 @@ function base64WireBytes(rawBytes) {
193
252
  *
194
253
  * Best-effort throughout: nothing here throws into a libp2p event handler or into a
195
254
  * runtime bring-up; a failed chunk is logged and the run continues. A peer is marked
196
- * fully caught up ONLY after a run with no thrown chunk and an empty `missing` list, so
197
- * the next `peer:identify` from a peer whose catch-up failed retries it.
255
+ * caught up only after a run with no thrown chunk, so a peer whose catch-up failed is
256
+ * retried; each block is pushed to each peer once (see "What is pushed" in the module comment).
198
257
  */
199
258
  export class PeerJoinBackfill {
200
259
  constructor(deps, config) {
@@ -202,10 +261,22 @@ export class PeerJoinBackfill {
202
261
  this.started = false;
203
262
  this.stopped = false;
204
263
  /**
205
- * Peers fully caught up this runtime (never rescheduled until the runtime is rebuilt; only
206
- * {@link forceCatchUpPeer} pushes to one again).
264
+ * Peers caught up since their current connection opened. Cleared for a peer when its last
265
+ * connection closes, so a reconnect re-runs; {@link forceCatchUpPeer} runs past it.
207
266
  */
208
267
  this.done = new Set();
268
+ /**
269
+ * Peers that disconnected while their run was in flight, so that run does not mark them
270
+ * {@link done} after the disconnect cleared it. Cleared when the run ends.
271
+ */
272
+ this.disconnectedInFlight = new Set();
273
+ /** Per-peer, per-block verdicts from push replies; see {@link PeerMemo}. */
274
+ this.memos = new Map();
275
+ /**
276
+ * Peers a run has been started for. A peer not in it is run at once on its first schedule;
277
+ * later schedules wait out `debounceMs`.
278
+ */
279
+ this.contacted = new Set();
209
280
  /**
210
281
  * Peers with a catch-up currently running, each with that run (suppresses concurrent
211
282
  * duplicates; {@link forceCatchUpPeer} waits on the run).
@@ -213,9 +284,9 @@ export class PeerJoinBackfill {
213
284
  this.inFlight = new Map();
214
285
  /**
215
286
  * Peers whose (re-)schedule arrived while their OWN run was in flight, replayed once
216
- * that run finishes. Load-bearing for the gated path: the gate's authorization check is
217
- * a control-database read, so the window between it and the run's end is wide enough for
218
- * the very membership commit that would have authorized the peer to land inside it —
287
+ * that run finishes. Load-bearing for the gated path: the gate is judged once, at the start
288
+ * of a run, and the run's pushes take long enough for the very membership commit that would
289
+ * have authorized the peer to land after that check —
219
290
  * dropping that schedule (rather than deferring it) leaves the denied peer waiting for a
220
291
  * reconnect, which is exactly what the re-arm exists to avoid.
221
292
  */
@@ -242,18 +313,23 @@ export class PeerJoinBackfill {
242
313
  this.schedulePeer(peerId);
243
314
  }
244
315
  };
316
+ this.onPeerDisconnect = (evt) => {
317
+ this.forgetCaughtUp(evt.detail.toString());
318
+ };
245
319
  }
246
320
  /**
247
321
  * Subscribe to `peer:identify` AND schedule a catch-up for already-connected peers whose
248
322
  * stored protocols already name this network's block-transfer protocol — a peer that does
249
- * not speak it (a bare circuit relay, a bootstrap node) is never scheduled at all. See
250
- * {@link speaksBlockTransfer} and the module comment.
323
+ * not speak it (a bare circuit relay, a bootstrap node) is never scheduled at all. Also
324
+ * subscribes to `peer:disconnect`, so a peer caught up before it went away is run again on
325
+ * its return. See {@link speaksBlockTransfer} and the module comment.
251
326
  */
252
327
  start() {
253
328
  if (this.started || this.stopped || !this.config.enabled)
254
329
  return;
255
330
  this.started = true;
256
331
  this.deps.libp2p.addEventListener('peer:identify', this.onPeerIdentify);
332
+ this.deps.libp2p.addEventListener('peer:disconnect', this.onPeerDisconnect);
257
333
  // A runtime rebuilt over live connections (resumeStrand) never sees their peer:identify —
258
334
  // it already fired before this instance existed, but its result is persisted in the peer
259
335
  // store — so walk what is already connected once.
@@ -268,6 +344,7 @@ export class PeerJoinBackfill {
268
344
  this.stopped = true;
269
345
  if (this.started) {
270
346
  this.deps.libp2p.removeEventListener('peer:identify', this.onPeerIdentify);
347
+ this.deps.libp2p.removeEventListener('peer:disconnect', this.onPeerDisconnect);
271
348
  }
272
349
  for (const timer of this.timers.values()) {
273
350
  clearTimeout(timer);
@@ -277,6 +354,24 @@ export class PeerJoinBackfill {
277
354
  this.retryAfter.clear();
278
355
  log('[%s] stopped', this.deps.label);
279
356
  }
357
+ /**
358
+ * A peer's last connection closed: it is no longer caught up, so its next `peer:identify`
359
+ * re-runs. Only `done` is cleared. The memo stays (it keeps the re-run to what moved),
360
+ * `contacted` stays (a link flapping inside one settle window costs one run), and the
361
+ * backoff state stays (clearing it re-dials an unreachable peer on every reconnect; see the
362
+ * module comment's retry paragraph).
363
+ *
364
+ * When the returning peer wrote while away with a cohort that named this node, its own
365
+ * re-run pushes those blocks here and reports them through `reportBlockHeld`, so its
366
+ * under-replication drain does not push them again. Revisit if a measurement shows reconnect
367
+ * re-runs dominating push traffic.
368
+ */
369
+ forgetCaughtUp(key) {
370
+ this.done.delete(key);
371
+ if (this.inFlight.has(key)) {
372
+ this.disconnectedInFlight.add(key);
373
+ }
374
+ }
280
375
  /**
281
376
  * (Re-)schedule a debounced catch-up for every currently-connected peer whose protocols, as
282
377
  * already stored in the libp2p peer store, name this network's block-transfer protocol. A
@@ -318,7 +413,11 @@ export class PeerJoinBackfill {
318
413
  this.schedulePeer(peerId);
319
414
  }
320
415
  }
321
- /** Debounced entry point for connection churn: one run per peer per settle window. */
416
+ /**
417
+ * Entry point for connection churn: one run per peer per settle window, except that a peer
418
+ * never run for is run at once (on a zero-delay timer, so a burst of identify events in one
419
+ * turn still collapses, and no work happens inside the libp2p event dispatch).
420
+ */
322
421
  schedulePeer(peerId) {
323
422
  const key = peerId.toString();
324
423
  if (this.stopped || this.done.has(key))
@@ -339,10 +438,11 @@ export class PeerJoinBackfill {
339
438
  const existing = this.timers.get(key);
340
439
  if (existing)
341
440
  clearTimeout(existing);
441
+ const delayMs = this.contacted.has(key) ? this.config.debounceMs : 0;
342
442
  this.timers.set(key, setTimeout(() => {
343
443
  this.timers.delete(key);
344
444
  void this.catchUpPeer(peerId);
345
- }, this.config.debounceMs));
445
+ }, delayMs));
346
446
  }
347
447
  /**
348
448
  * Run one peer's catch-up now, bypassing the debounce. Never rejects. Returns an
@@ -357,14 +457,16 @@ export class PeerJoinBackfill {
357
457
  return await this.trackRun(peerId);
358
458
  }
359
459
  /**
360
- * Run one peer's catch-up now and wait for it, whatever this instance remembers about the
361
- * peer: wait out a run already in flight for it, then push the whole store again even when
362
- * the peer is memoized as caught up. Never rejects; an all-zero result only when stopped.
460
+ * Run one peer's catch-up now and wait for it, even when the peer is memoized as caught up:
461
+ * wait out a run already in flight for it, then run again. The run still skips every block the
462
+ * peer is known to hold (it accepted or refused that revision, or signed it), so for a peer that
463
+ * holds the whole store it pushes nothing and returns `alreadyHeld` equal to the store size.
464
+ * Never rejects; an all-zero result only when stopped.
363
465
  *
364
466
  * For a caller that must know the peer holds this store before it proceeds, where
365
467
  * {@link catchUpPeer}'s empty answer would hide a run that was denied (a `peer:identify`
366
- * run can pass the gate a moment before the caller authorized the peer) or a memo older
367
- * than the commits since.
468
+ * run can pass the gate a moment before the caller authorized the peer) or commits made
469
+ * since the peer was caught up.
368
470
  */
369
471
  async forceCatchUpPeer(peerId) {
370
472
  const key = peerId.toString();
@@ -382,6 +484,7 @@ export class PeerJoinBackfill {
382
484
  */
383
485
  async trackRun(peerId) {
384
486
  const key = peerId.toString();
487
+ this.contacted.add(key);
385
488
  const run = this.settleRun(peerId);
386
489
  this.inFlight.set(key, run);
387
490
  try {
@@ -389,6 +492,7 @@ export class PeerJoinBackfill {
389
492
  }
390
493
  finally {
391
494
  this.inFlight.delete(key);
495
+ this.disconnectedInFlight.delete(key);
392
496
  // Replay a schedule that arrived mid-run. `schedulePeer` re-checks `done`, so a run
393
497
  // that finished clean re-arms nothing; a denied or failed one gets its retry.
394
498
  if (this.rearmAfterFlight.delete(key)) {
@@ -401,28 +505,32 @@ export class PeerJoinBackfill {
401
505
  const key = peerId.toString();
402
506
  try {
403
507
  const { result, clean, pushFailed } = await this.runCatchUp(peerId);
404
- // NOTE: a run that hit `maxBlocks` is still "clean" and still memoizes the peer, so
405
- // the tail past the ceiling never reaches it. Deliberate: enumeration is not
406
- // resumable, so not memoizing would re-push the same prefix on every reconnect
407
- // without ever advancing. Loud in the log below (capped > 0). If a store can
408
- // realistically exceed maxBlocks, the fix is a resumable cursor, not either policy.
508
+ // NOTE: a run that hit `maxBlocks` is still "clean" and still marks the peer caught up,
509
+ // so no `peer:identify` run reaches the tail past the ceiling. Loud in the log below
510
+ // (capped > 0). Blocks the peer is known to hold are not offered and do not count toward
511
+ // the ceiling, so a forced run resumes past the prefix already sent. If a store can
512
+ // realistically exceed maxBlocks, leave a capped run out of `done` so each identify run
513
+ // sends the next `maxBlocks`.
409
514
  if (clean && !this.stopped) {
410
- this.done.add(key);
515
+ // A disconnect mid-run cleared `done` already; re-adding it would drop the reconnect.
516
+ // Not judged by `isConnected`: hand-driven runs target peers with no connection.
517
+ if (!this.disconnectedInFlight.has(key)) {
518
+ this.done.add(key);
519
+ }
411
520
  this.failures.delete(key);
412
521
  this.retryAfter.delete(key);
413
522
  this.warned.delete(key);
414
523
  }
415
524
  else if (pushFailed && !this.stopped && this.started && this.isConnected(peerId)) {
416
- // `pushFailed`, not `!clean`: a denial, an inert store and a receiver's per-block
417
- // rejection are all verdicts a retry cannot change, and re-arming on them would
418
- // re-push the whole store to that peer forever. See the module comment.
525
+ // `pushFailed`, not `!clean`: a denial and an inert store are verdicts a retry cannot
526
+ // change, and re-arming on them would retry forever. See the module comment.
419
527
  //
420
528
  // Gated on `started` too: the re-arm exists to REPLACE a peer:identify-driven retry,
421
529
  // so a caller driving `catchUpPeer` by hand against a backfill that was never started
422
530
  // owns its own retry policy and must not be left holding a background timer.
423
531
  this.scheduleRetryWithBackoff(peerId);
424
532
  }
425
- log('[%s] catch-up peer=%s offered=%d accepted=%d rejected=%d uncommitted=%d unmaterialized=%d capped=%d oversized=%d denied=%s done=%s', this.deps.label, key, result.offered, result.accepted, result.rejected.length, result.uncommitted, result.unmaterialized, result.capped, result.oversized.length, result.denied, clean);
533
+ log('[%s] catch-up peer=%s offered=%d alreadyHeld=%d accepted=%d rejected=%d uncommitted=%d unmaterialized=%d capped=%d oversized=%d denied=%s done=%s', this.deps.label, key, result.offered, result.alreadyHeld, result.accepted, result.rejected.length, result.uncommitted, result.unmaterialized, result.capped, result.oversized.length, result.denied, clean);
426
534
  return result;
427
535
  }
428
536
  catch (error) {
@@ -474,14 +582,44 @@ export class PeerJoinBackfill {
474
582
  + 'these budgets and libp2p\'s own connection limits are derived from network.linkRoundTripMs (see link-budget.ts); '
475
583
  + 'a link slower than that declaration, or a peer that declared a faster one, cannot open a relayed connection at all.');
476
584
  }
585
+ /** This peer's memo, created empty on first use. */
586
+ memoFor(key) {
587
+ let memo = this.memos.get(key);
588
+ if (!memo) {
589
+ memo = { accepted: new Map(), rejected: new Map() };
590
+ this.memos.set(key, memo);
591
+ }
592
+ return memo;
593
+ }
594
+ /**
595
+ * Tell the node which blocks of one push reply the peer accepted, at the revision pushed.
596
+ * Best-effort: a failed report is logged and costs only the drain's duplicate push.
597
+ */
598
+ async reportAccepted(key, revs, ids, missing) {
599
+ const report = this.deps.reportBlockHeld;
600
+ if (!report)
601
+ return;
602
+ for (const id of ids) {
603
+ if (missing.has(id))
604
+ continue;
605
+ try {
606
+ await report(id, revs[id].rev, key);
607
+ }
608
+ catch (error) {
609
+ log('[%s] reportBlockHeld for peer=%s block=%s failed: %s', this.deps.label, key, id, error.message);
610
+ }
611
+ }
612
+ }
477
613
  /**
478
- * The actual copy. `clean` = every chunk pushed and the remote persisted every block.
614
+ * The actual copy. `clean` = the gate admitted the peer, the store is listable and no push
615
+ * threw (a block the receiver refused is remembered per block instead, see {@link PeerMemo}).
479
616
  * `pushFailed` = at least one push THREW, which is the only non-clean outcome a retry can
480
- * change; the others are verdicts (denied, inert store, blocks the receiver refused).
617
+ * change; the others are verdicts (denied, inert store).
481
618
  */
482
619
  async runCatchUp(peerId) {
483
620
  const result = emptyResult();
484
621
  const { storage } = this.deps;
622
+ const key = peerId.toString();
485
623
  // The membership gate, judged at push time (never at schedule time — authorization can
486
624
  // change during the debounce window in either direction). Fails CLOSED on a throw: a
487
625
  // gate that cannot answer must not leak the store. A denied run is not clean, so the
@@ -507,7 +645,7 @@ export class PeerJoinBackfill {
507
645
  return { result, clean: false, pushFailed: false };
508
646
  }
509
647
  const client = this.createPushClient(peerId);
510
- const encoder = new TextEncoder();
648
+ const memo = this.memoFor(key);
511
649
  let chunk = { ids: [], buffers: [], meta: {}, proofs: {}, bytes: 0 };
512
650
  let chunkFailed = false;
513
651
  let anyChunkDelivered = false;
@@ -532,6 +670,8 @@ export class PeerJoinBackfill {
532
670
  });
533
671
  anyChunkDelivered = true;
534
672
  const missing = new Set(response.missing);
673
+ recordReply(memo, meta, ids, missing);
674
+ await this.reportAccepted(key, meta, ids, missing);
535
675
  for (const id of ids) {
536
676
  if (missing.has(id)) {
537
677
  result.rejected.push(id);
@@ -541,12 +681,12 @@ export class PeerJoinBackfill {
541
681
  }
542
682
  }
543
683
  if (response.missing.length > 0) {
544
- log('[%s] peer=%s rejected %d block(s): %o', this.deps.label, peerId.toString(), response.missing.length, response.missing);
684
+ log('[%s] peer=%s rejected %d block(s), not offered to it again at these revisions: %o', this.deps.label, key, response.missing.length, response.missing);
545
685
  }
546
686
  }
547
687
  catch (error) {
548
688
  chunkFailed = true;
549
- log('[%s] push to peer=%s failed for %d block(s): %s', this.deps.label, peerId.toString(), ids.length, error.message);
689
+ log('[%s] push to peer=%s failed for %d block(s): %s', this.deps.label, key, ids.length, error.message);
550
690
  }
551
691
  };
552
692
  for await (const blockId of storage.listBlockIds()) {
@@ -558,65 +698,82 @@ export class PeerJoinBackfill {
558
698
  result.capped += 1;
559
699
  continue;
560
700
  }
561
- const metadata = await storage.getMetadata(blockId);
562
- const latest = metadata?.latest;
563
- if (!latest) {
564
- result.uncommitted += 1;
701
+ const offer = await this.readOffer(blockId, key, memo, result);
702
+ if (!offer)
565
703
  continue;
566
- }
567
- // NOTE: a block whose latest revision is a DELETE materializes to nothing (a
568
- // tombstone), so it is skipped here — a peer that joins after such a delete never
569
- // receives the tombstone from this path and relies on read repair for it. Fine
570
- // while nothing deletes whole blocks pre-join; if the whole-store coverage gate in
571
- // strand-membership-closed-strand-e2e ever reports a tombstone residue, teach this
572
- // to push the promoted delete transform instead of skipping.
573
- const block = await storage.getMaterializedBlock(blockId, latest.actionId);
574
- if (!block) {
575
- result.unmaterialized += 1;
576
- log('[%s] block=%s has latest rev %d but no materialized content; skipped', this.deps.label, blockId, latest.rev);
577
- continue;
578
- }
579
- const buffer = encoder.encode(JSON.stringify(block));
580
- // NOTE: MAX_BLOCK_MESSAGE_BYTES caps the whole framed request, not one block, and
581
- // this test ignores the request envelope (ids array, blockMeta, JSON punctuation).
582
- // A lone block within a few KiB of the cap therefore still ships and is rejected by
583
- // the receiver's length-prefix decoder — a permanent chunk failure for that peer. No
584
- // block comes close today; if one ever can, subtract a measured envelope allowance
585
- // here rather than raising the cap.
586
- if (base64WireBytes(buffer.length) >= MAX_BLOCK_MESSAGE_BYTES) {
587
- result.oversized.push(blockId);
588
- log('[%s] block=%s is %d bytes (%d on the wire) — exceeds the %d-byte protocol cap alone; skipped', this.deps.label, blockId, buffer.length, base64WireBytes(buffer.length), MAX_BLOCK_MESSAGE_BYTES);
589
- continue;
590
- }
591
704
  // Flush before adding when this block would overflow either budget. A single block
592
705
  // larger than maxChunkBytes still ships — alone, in its own chunk.
593
706
  if (chunk.ids.length > 0
594
- && (chunk.ids.length + 1 > this.config.maxChunkBlocks || chunk.bytes + buffer.length > this.config.maxChunkBytes)) {
707
+ && (chunk.ids.length + 1 > this.config.maxChunkBlocks || chunk.bytes + offer.buffer.length > this.config.maxChunkBytes)) {
595
708
  await flush();
596
709
  if (this.stopped || peerUnreachable())
597
710
  break;
598
711
  }
599
712
  chunk.ids.push(blockId);
600
- chunk.buffers.push(buffer);
601
- chunk.meta[blockId] = { rev: latest.rev, actionId: latest.actionId };
602
- // Read the proof for EXACTLY the revision being pushed, from the same `latest` the meta
603
- // above was written from. Absent is normal (nothing retained a proof for that revision);
604
- // the block still ships with its meta and the receiver decides.
605
- const proof = await storage.getBlockProof(blockId, latest.rev);
606
- if (proof) {
607
- chunk.proofs[blockId] = proof;
713
+ chunk.buffers.push(offer.buffer);
714
+ chunk.meta[blockId] = { rev: offer.latest.rev, actionId: offer.latest.actionId };
715
+ if (offer.proof) {
716
+ chunk.proofs[blockId] = offer.proof;
608
717
  }
609
- chunk.bytes += buffer.length;
718
+ chunk.bytes += offer.buffer.length;
610
719
  result.offered += 1;
611
720
  }
612
721
  if (!this.stopped && !peerUnreachable()) {
613
722
  await flush();
614
723
  }
615
724
  if (result.capped > 0) {
616
- log('[%s] peer=%s catch-up CAPPED at %d blocks; %d block id(s) not attempted', this.deps.label, peerId.toString(), this.config.maxBlocks, result.capped);
725
+ log('[%s] peer=%s catch-up CAPPED at %d blocks; %d block id(s) not attempted', this.deps.label, key, this.config.maxBlocks, result.capped);
726
+ }
727
+ return { result, clean: !chunkFailed, pushFailed: chunkFailed };
728
+ }
729
+ /**
730
+ * Read one block for the push to `key`, or count why it is skipped in `result` and return
731
+ * nothing: not committed, already held by the peer (memo or its own commit signature), not
732
+ * materialized, or too large for one message. The proof is read for EXACTLY the revision the
733
+ * meta will name, from the same `latest`; absent is normal (nothing retained a proof for that
734
+ * revision), and the block still ships with its meta for the receiver to decide.
735
+ */
736
+ async readOffer(blockId, key, memo, result) {
737
+ const { storage } = this.deps;
738
+ const latest = (await storage.getMetadata(blockId))?.latest;
739
+ if (!latest) {
740
+ result.uncommitted += 1;
741
+ return undefined;
742
+ }
743
+ if (memoSettled(memo, blockId, latest.rev)) {
744
+ result.alreadyHeld += 1;
745
+ return undefined;
746
+ }
747
+ const proof = await storage.getBlockProof(blockId, latest.rev);
748
+ if (signedBy(proof, key)) {
749
+ result.alreadyHeld += 1;
750
+ return undefined;
751
+ }
752
+ // NOTE: a block whose latest revision is a DELETE materializes to nothing (a
753
+ // tombstone), so it is skipped here — a peer that joins after such a delete never
754
+ // receives the tombstone from this path and relies on read repair for it. Fine
755
+ // while nothing deletes whole blocks pre-join; if the whole-store coverage gate in
756
+ // strand-membership-closed-strand-e2e ever reports a tombstone residue, teach this
757
+ // to push the promoted delete transform instead of skipping.
758
+ const block = await storage.getMaterializedBlock(blockId, latest.actionId);
759
+ if (!block) {
760
+ result.unmaterialized += 1;
761
+ log('[%s] block=%s has latest rev %d but no materialized content; skipped', this.deps.label, blockId, latest.rev);
762
+ return undefined;
763
+ }
764
+ const buffer = blockEncoder.encode(JSON.stringify(block));
765
+ // NOTE: MAX_BLOCK_MESSAGE_BYTES caps the whole framed request, not one block, and
766
+ // this test ignores the request envelope (ids array, blockMeta, JSON punctuation).
767
+ // A lone block within a few KiB of the cap therefore still ships and is rejected by
768
+ // the receiver's length-prefix decoder — a permanent chunk failure for that peer. No
769
+ // block comes close today; if one ever can, subtract a measured envelope allowance
770
+ // here rather than raising the cap.
771
+ if (base64WireBytes(buffer.length) >= MAX_BLOCK_MESSAGE_BYTES) {
772
+ result.oversized.push(blockId);
773
+ log('[%s] block=%s is %d bytes (%d on the wire) — exceeds the %d-byte protocol cap alone; skipped', this.deps.label, blockId, buffer.length, base64WireBytes(buffer.length), MAX_BLOCK_MESSAGE_BYTES);
774
+ return undefined;
617
775
  }
618
- const clean = !chunkFailed && result.rejected.length === 0;
619
- return { result, clean, pushFailed: chunkFailed };
776
+ return { latest, buffer, proof };
620
777
  }
621
778
  }
622
779
  //# sourceMappingURL=peer-join-backfill.js.map