@optimystic/db-core 0.14.0 → 0.16.2

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 (301) hide show
  1. package/dist/src/blocks/block-store.d.ts +18 -1
  2. package/dist/src/blocks/block-store.d.ts.map +1 -1
  3. package/dist/src/blocks/helpers.d.ts +2 -2
  4. package/dist/src/blocks/helpers.d.ts.map +1 -1
  5. package/dist/src/blocks/helpers.js +2 -2
  6. package/dist/src/blocks/helpers.js.map +1 -1
  7. package/dist/src/btree/btree.d.ts +27 -6
  8. package/dist/src/btree/btree.d.ts.map +1 -1
  9. package/dist/src/btree/btree.js +104 -61
  10. package/dist/src/btree/btree.js.map +1 -1
  11. package/dist/src/chain/chain.d.ts.map +1 -1
  12. package/dist/src/chain/chain.js +11 -1
  13. package/dist/src/chain/chain.js.map +1 -1
  14. package/dist/src/cluster/index.d.ts +1 -0
  15. package/dist/src/cluster/index.d.ts.map +1 -1
  16. package/dist/src/cluster/index.js +1 -0
  17. package/dist/src/cluster/index.js.map +1 -1
  18. package/dist/src/cluster/membership.d.ts +49 -0
  19. package/dist/src/cluster/membership.d.ts.map +1 -0
  20. package/dist/src/cluster/membership.js +75 -0
  21. package/dist/src/cluster/membership.js.map +1 -0
  22. package/dist/src/cluster/structs.d.ts +50 -1
  23. package/dist/src/cluster/structs.d.ts.map +1 -1
  24. package/dist/src/cluster/structs.js +9 -1
  25. package/dist/src/cluster/structs.js.map +1 -1
  26. package/dist/src/cohort-topic/addressing.d.ts +8 -2
  27. package/dist/src/cohort-topic/addressing.d.ts.map +1 -1
  28. package/dist/src/cohort-topic/addressing.js +11 -3
  29. package/dist/src/cohort-topic/addressing.js.map +1 -1
  30. package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts +13 -7
  31. package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts.map +1 -1
  32. package/dist/src/cohort-topic/antidos/bootstrap-evidence.js +17 -6
  33. package/dist/src/cohort-topic/antidos/bootstrap-evidence.js.map +1 -1
  34. package/dist/src/cohort-topic/antidos/replay-guard.d.ts +17 -0
  35. package/dist/src/cohort-topic/antidos/replay-guard.d.ts.map +1 -1
  36. package/dist/src/cohort-topic/antidos/replay-guard.js +33 -0
  37. package/dist/src/cohort-topic/antidos/replay-guard.js.map +1 -1
  38. package/dist/src/cohort-topic/antidos/topic-budget.d.ts +7 -0
  39. package/dist/src/cohort-topic/antidos/topic-budget.d.ts.map +1 -1
  40. package/dist/src/cohort-topic/antidos/topic-budget.js +8 -2
  41. package/dist/src/cohort-topic/antidos/topic-budget.js.map +1 -1
  42. package/dist/src/cohort-topic/coldstart.d.ts +34 -14
  43. package/dist/src/cohort-topic/coldstart.d.ts.map +1 -1
  44. package/dist/src/cohort-topic/coldstart.js +25 -11
  45. package/dist/src/cohort-topic/coldstart.js.map +1 -1
  46. package/dist/src/cohort-topic/gossip/bus.d.ts +12 -1
  47. package/dist/src/cohort-topic/gossip/bus.d.ts.map +1 -1
  48. package/dist/src/cohort-topic/gossip/bus.js +27 -1
  49. package/dist/src/cohort-topic/gossip/bus.js.map +1 -1
  50. package/dist/src/cohort-topic/gossip/view.d.ts +8 -0
  51. package/dist/src/cohort-topic/gossip/view.d.ts.map +1 -1
  52. package/dist/src/cohort-topic/gossip/view.js +17 -1
  53. package/dist/src/cohort-topic/gossip/view.js.map +1 -1
  54. package/dist/src/cohort-topic/member-engine.d.ts +10 -3
  55. package/dist/src/cohort-topic/member-engine.d.ts.map +1 -1
  56. package/dist/src/cohort-topic/member-engine.js +29 -14
  57. package/dist/src/cohort-topic/member-engine.js.map +1 -1
  58. package/dist/src/cohort-topic/membership/publisher.d.ts +14 -7
  59. package/dist/src/cohort-topic/membership/publisher.d.ts.map +1 -1
  60. package/dist/src/cohort-topic/membership/publisher.js +15 -27
  61. package/dist/src/cohort-topic/membership/publisher.js.map +1 -1
  62. package/dist/src/cohort-topic/membership/verifier.d.ts +35 -0
  63. package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
  64. package/dist/src/cohort-topic/membership/verifier.js +88 -3
  65. package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
  66. package/dist/src/cohort-topic/promotion.d.ts +7 -0
  67. package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
  68. package/dist/src/cohort-topic/promotion.js +16 -2
  69. package/dist/src/cohort-topic/promotion.js.map +1 -1
  70. package/dist/src/cohort-topic/registration/renewal.d.ts +18 -0
  71. package/dist/src/cohort-topic/registration/renewal.d.ts.map +1 -1
  72. package/dist/src/cohort-topic/registration/renewal.js +54 -0
  73. package/dist/src/cohort-topic/registration/renewal.js.map +1 -1
  74. package/dist/src/cohort-topic/registration/types.d.ts +12 -0
  75. package/dist/src/cohort-topic/registration/types.d.ts.map +1 -1
  76. package/dist/src/cohort-topic/registration/types.js +14 -0
  77. package/dist/src/cohort-topic/registration/types.js.map +1 -1
  78. package/dist/src/cohort-topic/service.d.ts +4 -3
  79. package/dist/src/cohort-topic/service.d.ts.map +1 -1
  80. package/dist/src/cohort-topic/service.js +38 -12
  81. package/dist/src/cohort-topic/service.js.map +1 -1
  82. package/dist/src/cohort-topic/sig/payloads.d.ts +23 -7
  83. package/dist/src/cohort-topic/sig/payloads.d.ts.map +1 -1
  84. package/dist/src/cohort-topic/sig/payloads.js +22 -6
  85. package/dist/src/cohort-topic/sig/payloads.js.map +1 -1
  86. package/dist/src/cohort-topic/traffic.d.ts +6 -0
  87. package/dist/src/cohort-topic/traffic.d.ts.map +1 -1
  88. package/dist/src/cohort-topic/traffic.js +16 -1
  89. package/dist/src/cohort-topic/traffic.js.map +1 -1
  90. package/dist/src/cohort-topic/walk.d.ts +22 -7
  91. package/dist/src/cohort-topic/walk.d.ts.map +1 -1
  92. package/dist/src/cohort-topic/walk.js +78 -18
  93. package/dist/src/cohort-topic/walk.js.map +1 -1
  94. package/dist/src/cohort-topic/wire/codec.d.ts +3 -1
  95. package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
  96. package/dist/src/cohort-topic/wire/codec.js +7 -1
  97. package/dist/src/cohort-topic/wire/codec.js.map +1 -1
  98. package/dist/src/cohort-topic/wire/index.d.ts +1 -1
  99. package/dist/src/cohort-topic/wire/index.d.ts.map +1 -1
  100. package/dist/src/cohort-topic/wire/index.js +1 -1
  101. package/dist/src/cohort-topic/wire/index.js.map +1 -1
  102. package/dist/src/cohort-topic/wire/payloads.d.ts +5 -4
  103. package/dist/src/cohort-topic/wire/payloads.d.ts.map +1 -1
  104. package/dist/src/cohort-topic/wire/payloads.js +14 -5
  105. package/dist/src/cohort-topic/wire/payloads.js.map +1 -1
  106. package/dist/src/cohort-topic/wire/primitives.d.ts +65 -0
  107. package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -0
  108. package/dist/src/cohort-topic/wire/primitives.js +173 -0
  109. package/dist/src/cohort-topic/wire/primitives.js.map +1 -0
  110. package/dist/src/cohort-topic/wire/types.d.ts +132 -5
  111. package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
  112. package/dist/src/cohort-topic/wire/validate.d.ts +15 -5
  113. package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
  114. package/dist/src/cohort-topic/wire/validate.js +146 -123
  115. package/dist/src/cohort-topic/wire/validate.js.map +1 -1
  116. package/dist/src/collection/collection.d.ts +23 -12
  117. package/dist/src/collection/collection.d.ts.map +1 -1
  118. package/dist/src/collection/collection.js +111 -35
  119. package/dist/src/collection/collection.js.map +1 -1
  120. package/dist/src/collection/struct.d.ts +37 -2
  121. package/dist/src/collection/struct.d.ts.map +1 -1
  122. package/dist/src/collection/struct.js +16 -1
  123. package/dist/src/collection/struct.js.map +1 -1
  124. package/dist/src/collections/tree/tree.d.ts +18 -1
  125. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  126. package/dist/src/collections/tree/tree.js +36 -6
  127. package/dist/src/collections/tree/tree.js.map +1 -1
  128. package/dist/src/index.d.ts +1 -0
  129. package/dist/src/index.d.ts.map +1 -1
  130. package/dist/src/index.js +1 -0
  131. package/dist/src/index.js.map +1 -1
  132. package/dist/src/log/log.d.ts +26 -3
  133. package/dist/src/log/log.d.ts.map +1 -1
  134. package/dist/src/log/log.js +34 -7
  135. package/dist/src/log/log.js.map +1 -1
  136. package/dist/src/log/struct.d.ts +5 -4
  137. package/dist/src/log/struct.d.ts.map +1 -1
  138. package/dist/src/log/struct.js.map +1 -1
  139. package/dist/src/matchmaking/wire.d.ts +2 -1
  140. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  141. package/dist/src/matchmaking/wire.js +13 -92
  142. package/dist/src/matchmaking/wire.js.map +1 -1
  143. package/dist/src/network/i-key-network.d.ts +9 -0
  144. package/dist/src/network/i-key-network.d.ts.map +1 -1
  145. package/dist/src/network/i-repo.d.ts +8 -0
  146. package/dist/src/network/i-repo.d.ts.map +1 -1
  147. package/dist/src/network/struct.d.ts +10 -0
  148. package/dist/src/network/struct.d.ts.map +1 -1
  149. package/dist/src/reactivity/push-state.d.ts.map +1 -1
  150. package/dist/src/reactivity/push-state.js +4 -33
  151. package/dist/src/reactivity/push-state.js.map +1 -1
  152. package/dist/src/reactivity/wire-validate.d.ts +7 -22
  153. package/dist/src/reactivity/wire-validate.d.ts.map +1 -1
  154. package/dist/src/reactivity/wire-validate.js +7 -73
  155. package/dist/src/reactivity/wire-validate.js.map +1 -1
  156. package/dist/src/reactivity/wire.js +3 -3
  157. package/dist/src/reactivity/wire.js.map +1 -1
  158. package/dist/src/testing/async-wait.d.ts +23 -0
  159. package/dist/src/testing/async-wait.d.ts.map +1 -0
  160. package/dist/src/testing/async-wait.js +42 -0
  161. package/dist/src/testing/async-wait.js.map +1 -0
  162. package/dist/src/testing/index.d.ts +3 -0
  163. package/dist/src/testing/index.d.ts.map +1 -0
  164. package/dist/src/testing/index.js +3 -0
  165. package/dist/src/testing/index.js.map +1 -0
  166. package/dist/src/testing/test-transactor.d.ts +44 -0
  167. package/dist/src/testing/test-transactor.d.ts.map +1 -0
  168. package/dist/src/testing/test-transactor.js +423 -0
  169. package/dist/src/testing/test-transactor.js.map +1 -0
  170. package/dist/src/transaction/actions-engine.d.ts +6 -3
  171. package/dist/src/transaction/actions-engine.d.ts.map +1 -1
  172. package/dist/src/transaction/actions-engine.js +8 -7
  173. package/dist/src/transaction/actions-engine.js.map +1 -1
  174. package/dist/src/transaction/coordinator.d.ts +43 -22
  175. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  176. package/dist/src/transaction/coordinator.js +443 -178
  177. package/dist/src/transaction/coordinator.js.map +1 -1
  178. package/dist/src/transaction/errors.d.ts +82 -0
  179. package/dist/src/transaction/errors.d.ts.map +1 -0
  180. package/dist/src/transaction/errors.js +93 -0
  181. package/dist/src/transaction/errors.js.map +1 -0
  182. package/dist/src/transaction/index.d.ts +6 -3
  183. package/dist/src/transaction/index.d.ts.map +1 -1
  184. package/dist/src/transaction/index.js +4 -2
  185. package/dist/src/transaction/index.js.map +1 -1
  186. package/dist/src/transaction/operations-hash.d.ts +119 -0
  187. package/dist/src/transaction/operations-hash.d.ts.map +1 -0
  188. package/dist/src/transaction/operations-hash.js +180 -0
  189. package/dist/src/transaction/operations-hash.js.map +1 -0
  190. package/dist/src/transaction/read-dependency-collector.d.ts +43 -0
  191. package/dist/src/transaction/read-dependency-collector.d.ts.map +1 -0
  192. package/dist/src/transaction/read-dependency-collector.js +69 -0
  193. package/dist/src/transaction/read-dependency-collector.js.map +1 -0
  194. package/dist/src/transaction/session.d.ts +12 -3
  195. package/dist/src/transaction/session.d.ts.map +1 -1
  196. package/dist/src/transaction/session.js +35 -9
  197. package/dist/src/transaction/session.js.map +1 -1
  198. package/dist/src/transaction/transaction.d.ts +162 -1
  199. package/dist/src/transaction/transaction.d.ts.map +1 -1
  200. package/dist/src/transaction/transaction.js +85 -3
  201. package/dist/src/transaction/transaction.js.map +1 -1
  202. package/dist/src/transaction/validator.d.ts +3 -11
  203. package/dist/src/transaction/validator.d.ts.map +1 -1
  204. package/dist/src/transaction/validator.js +69 -28
  205. package/dist/src/transaction/validator.js.map +1 -1
  206. package/dist/src/transactor/network-transactor.d.ts +35 -0
  207. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  208. package/dist/src/transactor/network-transactor.js +146 -40
  209. package/dist/src/transactor/network-transactor.js.map +1 -1
  210. package/dist/src/transactor/transactor-source.d.ts +19 -5
  211. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  212. package/dist/src/transactor/transactor-source.js +43 -11
  213. package/dist/src/transactor/transactor-source.js.map +1 -1
  214. package/dist/src/transform/atomic-proxy.d.ts +35 -5
  215. package/dist/src/transform/atomic-proxy.d.ts.map +1 -1
  216. package/dist/src/transform/atomic-proxy.js +39 -7
  217. package/dist/src/transform/atomic-proxy.js.map +1 -1
  218. package/dist/src/transform/cache-source.d.ts +36 -5
  219. package/dist/src/transform/cache-source.d.ts.map +1 -1
  220. package/dist/src/transform/cache-source.js +84 -8
  221. package/dist/src/transform/cache-source.js.map +1 -1
  222. package/dist/src/transform/helpers.d.ts.map +1 -1
  223. package/dist/src/transform/helpers.js +8 -2
  224. package/dist/src/transform/helpers.js.map +1 -1
  225. package/dist/src/transform/tracker.d.ts +17 -2
  226. package/dist/src/transform/tracker.d.ts.map +1 -1
  227. package/dist/src/transform/tracker.js +66 -11
  228. package/dist/src/transform/tracker.js.map +1 -1
  229. package/dist/src/utility/backoff.d.ts +48 -0
  230. package/dist/src/utility/backoff.d.ts.map +1 -0
  231. package/dist/src/utility/backoff.js +60 -0
  232. package/dist/src/utility/backoff.js.map +1 -0
  233. package/dist/src/utility/pending.d.ts +1 -0
  234. package/dist/src/utility/pending.d.ts.map +1 -1
  235. package/dist/src/utility/pending.js +3 -1
  236. package/dist/src/utility/pending.js.map +1 -1
  237. package/package.json +4 -4
  238. package/src/blocks/block-store.ts +19 -1
  239. package/src/blocks/helpers.ts +3 -3
  240. package/src/btree/btree.ts +108 -63
  241. package/src/chain/chain.ts +11 -1
  242. package/src/cluster/index.ts +1 -0
  243. package/src/cluster/membership.ts +85 -0
  244. package/src/cluster/structs.ts +51 -1
  245. package/src/cohort-topic/addressing.ts +12 -4
  246. package/src/cohort-topic/antidos/bootstrap-evidence.ts +21 -9
  247. package/src/cohort-topic/antidos/replay-guard.ts +37 -0
  248. package/src/cohort-topic/antidos/topic-budget.ts +17 -2
  249. package/src/cohort-topic/coldstart.ts +48 -15
  250. package/src/cohort-topic/gossip/bus.ts +39 -2
  251. package/src/cohort-topic/gossip/view.ts +26 -1
  252. package/src/cohort-topic/member-engine.ts +36 -15
  253. package/src/cohort-topic/membership/publisher.ts +25 -33
  254. package/src/cohort-topic/membership/verifier.ts +119 -3
  255. package/src/cohort-topic/promotion.ts +23 -2
  256. package/src/cohort-topic/registration/renewal.ts +73 -1
  257. package/src/cohort-topic/registration/types.ts +15 -0
  258. package/src/cohort-topic/service.ts +41 -15
  259. package/src/cohort-topic/sig/payloads.ts +29 -9
  260. package/src/cohort-topic/traffic.ts +23 -1
  261. package/src/cohort-topic/walk.ts +95 -24
  262. package/src/cohort-topic/wire/codec.ts +12 -0
  263. package/src/cohort-topic/wire/index.ts +2 -0
  264. package/src/cohort-topic/wire/payloads.ts +14 -5
  265. package/src/cohort-topic/wire/primitives.ts +188 -0
  266. package/src/cohort-topic/wire/types.ts +136 -4
  267. package/src/cohort-topic/wire/validate.ts +168 -134
  268. package/src/collection/collection.ts +123 -40
  269. package/src/collection/struct.ts +44 -2
  270. package/src/collections/tree/tree.ts +42 -5
  271. package/src/index.ts +1 -0
  272. package/src/log/log.ts +37 -9
  273. package/src/log/struct.ts +5 -4
  274. package/src/matchmaking/wire.ts +25 -102
  275. package/src/network/i-key-network.ts +10 -0
  276. package/src/network/i-repo.ts +8 -0
  277. package/src/network/struct.ts +10 -0
  278. package/src/reactivity/push-state.ts +4 -37
  279. package/src/reactivity/wire-validate.ts +7 -81
  280. package/src/reactivity/wire.ts +3 -3
  281. package/src/testing/async-wait.ts +65 -0
  282. package/src/testing/index.ts +2 -0
  283. package/src/testing/test-transactor.ts +468 -0
  284. package/src/transaction/actions-engine.ts +8 -6
  285. package/src/transaction/coordinator.ts +498 -229
  286. package/src/transaction/errors.ts +91 -0
  287. package/src/transaction/index.ts +23 -3
  288. package/src/transaction/operations-hash.ts +196 -0
  289. package/src/transaction/read-dependency-collector.ts +78 -0
  290. package/src/transaction/session.ts +37 -9
  291. package/src/transaction/transaction.ts +211 -4
  292. package/src/transaction/validator.ts +73 -48
  293. package/src/transactor/network-transactor.ts +164 -49
  294. package/src/transactor/transactor-source.ts +47 -12
  295. package/src/transform/atomic-proxy.ts +92 -49
  296. package/src/transform/cache-source.ts +87 -9
  297. package/src/transform/helpers.ts +8 -2
  298. package/src/transform/tracker.ts +69 -11
  299. package/src/utility/backoff.ts +95 -0
  300. package/src/utility/pending.ts +3 -1
  301. package/src/transaction/context.ts +0 -103
@@ -121,7 +121,9 @@ export class Chain<TEntry> {
121
121
  priorId: tail.header.id,
122
122
  nextId: undefined,
123
123
  } as ChainDataNode<TEntry>;
124
- await this.options?.newBlock?.(newTail, oldTail);
124
+ // Predecessor is the running `tail` (immediately preceding block), NOT the original
125
+ // `oldTail`: across a multi-block append each new block links to the one before it.
126
+ await this.options?.newBlock?.(newTail, tail);
125
127
  trx.insert(newTail);
126
128
  apply(trx, tail, [nextId$, 0, 0, newTail.header.id]);
127
129
  tail = newTail;
@@ -300,6 +302,14 @@ export class Chain<TEntry> {
300
302
  };
301
303
  }
302
304
 
305
+ // NOTE: chain walks (getTail/getHead/select/next/prev) read via `store.tryGet(id)` with the
306
+ // DEFAULT read purpose (`value`), so every chain node stays in the OCC conflict set. This is
307
+ // deliberate: the chain backs the log, whose tail is the commit-ordering race arbiter (OCC
308
+ // "tail binding"), and enumerations return a shape-dependent result set — both are load-bearing
309
+ // reads that MUST be retained. The B-tree point-lookup navigation exclusion (ticket 4.7) is NOT
310
+ // applied here. If chain navigation reads ever need excluding, tag ONLY intermediate hops of a
311
+ // walk that terminates in a consumed entry, and NEVER the tail/header — get the safety predicate
312
+ // right first (docs/correctness.md Theorem 5).
303
313
  async getTail(header?: ChainHeaderNode): Promise<ChainPath<TEntry> | undefined> {
304
314
  const headerBlock = header ?? await this.getHeader();
305
315
  let tail = headerBlock ? await this.store.tryGet(headerBlock.tailId) as ChainDataNode<TEntry> : undefined;
@@ -1,2 +1,3 @@
1
1
  export * from "./i-cluster.js";
2
2
  export * from "./structs.js";
3
+ export * from "./membership.js";
@@ -0,0 +1,85 @@
1
+ import type { RepoMessage } from "../network/repo-protocol.js";
2
+ import type { ClusterPeers, ClusterRecord, Signature } from "./structs.js";
3
+ import { sha256 } from "multiformats/hashes/sha2";
4
+ import { base58btc } from "multiformats/bases/base58";
5
+ import { toString as uint8ArrayToString } from "uint8arrays/to-string";
6
+
7
+ /**
8
+ * Membership-binding version a new coordinator emits. A record at this version folds a
9
+ * {@link membershipDigest} of its peer set into `messageHash` / `promiseHash` / `commitHash`, so the
10
+ * responsible peer set is covered by every signature (see `docs/correctness.md` §2). Absent or `1` marks
11
+ * a legacy *unbound* record (pre-binding history) whose hashes must verify byte-identically to before
12
+ * this change.
13
+ */
14
+ export const CURRENT_MEMBERSHIP_VERSION = 2 as const;
15
+
16
+ /** Deterministic JSON: sorts object keys so the hash is independent of key insertion order. */
17
+ function canonicalJson(value: unknown): string {
18
+ return JSON.stringify(value, (_, v) =>
19
+ v && typeof v === 'object' && !Array.isArray(v)
20
+ ? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = v[k]; return o; }, {})
21
+ : v
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Canonical membership digest for a peer set: `base64url(SHA256(canonicalJson(sorted peer-id list)))`.
27
+ *
28
+ * Derived from the sorted peer-id list ONLY — not multiaddrs or public keys. Multiaddrs churn and a
29
+ * peer's public key is already a function of its id, so folding either in would make identity unstable
30
+ * without adding agreement value. The set of ids IS the membership. The digest is therefore independent
31
+ * of peer-map key insertion order and of multiaddr / pubkey contents; adding or removing one id changes
32
+ * it. `membershipDigest({})` (the empty set) is a fixed constant.
33
+ */
34
+ export async function membershipDigest(peers: ClusterPeers): Promise<string> {
35
+ const ids = Object.keys(peers ?? {}).sort();
36
+ const bytes = new TextEncoder().encode(canonicalJson(ids));
37
+ const hash = await sha256.digest(bytes);
38
+ return uint8ArrayToString(hash.digest, 'base64url');
39
+ }
40
+
41
+ /**
42
+ * The membership digest that participates in a record's hashes, or `undefined` for a legacy (v1 /
43
+ * unversioned) record. `undefined` selects the pre-binding hashing (empty-string concat is a no-op), so
44
+ * a v1 record hashes byte-identically to before this change. A v2 record folds in its declared
45
+ * `membershipDigest`.
46
+ */
47
+ export function recordMembershipDigest(record: Pick<ClusterRecord, 'membershipVersion' | 'membershipDigest'>): string | undefined {
48
+ return record.membershipVersion === 2 ? record.membershipDigest : undefined;
49
+ }
50
+
51
+ /**
52
+ * `messageHash` = `base58btc(SHA256(canonicalJson(message) + digest))`.
53
+ *
54
+ * `digest` `undefined` → legacy v1 preimage (`canonicalJson(message)` only); passing the membership
55
+ * digest folds the peer set in (v2). Empty-string concat means the v1 image is byte-identical to the
56
+ * pre-binding implementation.
57
+ */
58
+ export async function computeClusterMessageHash(message: RepoMessage, digest?: string): Promise<string> {
59
+ const bytes = new TextEncoder().encode(canonicalJson(message) + (digest ?? ''));
60
+ const hash = await sha256.digest(bytes);
61
+ return base58btc.encode(hash.digest);
62
+ }
63
+
64
+ /**
65
+ * `promiseHash` = `base64url(SHA256(messageHash + canonicalJson(message) + digest))`.
66
+ *
67
+ * `digest` `undefined` → legacy v1 preimage; passing it folds the peer set in (v2).
68
+ */
69
+ export async function computeClusterPromiseHash(messageHash: string, message: RepoMessage, digest?: string): Promise<string> {
70
+ const bytes = new TextEncoder().encode(messageHash + canonicalJson(message) + (digest ?? ''));
71
+ const hash = await sha256.digest(bytes);
72
+ return uint8ArrayToString(hash.digest, 'base64url');
73
+ }
74
+
75
+ /**
76
+ * `commitHash` = `base64url(SHA256(messageHash + canonicalJson(message) + digest + canonicalJson(promises)))`.
77
+ *
78
+ * `digest` `undefined` → legacy v1 preimage; passing it folds the peer set in (v2). The digest sits
79
+ * between the message and the promises image, matching the v2 layout in `docs/correctness.md`.
80
+ */
81
+ export async function computeClusterCommitHash(messageHash: string, message: RepoMessage, promises: Record<string, Signature>, digest?: string): Promise<string> {
82
+ const bytes = new TextEncoder().encode(messageHash + canonicalJson(message) + (digest ?? '') + canonicalJson(promises));
83
+ const hash = await sha256.digest(bytes);
84
+ return uint8ArrayToString(hash.digest, 'base64url');
85
+ }
@@ -17,6 +17,16 @@ export type ClusterPeers = {
17
17
  export type ClusterRecord = {
18
18
  messageHash: string; // Serves as a unique identifier for the clustered transaction record
19
19
  peers: ClusterPeers;
20
+ /**
21
+ * Membership-binding version. Absent or `1` = legacy *unbound* record: the peer set is NOT covered by
22
+ * any hash (pre-binding history and its stored commit certs verify byte-identically to before). `2` =
23
+ * the sorted peer-id set (as {@link ClusterRecord.membershipDigest}) is folded into `messageHash`,
24
+ * `promiseHash`, and `commitHash`, so two different peer sets yield two different `messageHash`es.
25
+ * New coordinators always emit `2`. See `packages/db-core/src/cluster/membership.ts`.
26
+ */
27
+ membershipVersion?: 1 | 2;
28
+ /** Membership digest of {@link ClusterRecord.peers}; present iff `membershipVersion === 2`. base64url. */
29
+ membershipDigest?: string;
20
30
  message: RepoMessage;
21
31
  coordinatingBlockIds?: string[];
22
32
  promises: { [peerId: string]: Signature };
@@ -37,8 +47,18 @@ export type ClusterRecord = {
37
47
  };
38
48
  }
39
49
 
50
+ /**
51
+ * Single source of truth for the default super-majority threshold — the fraction of a cluster's peers
52
+ * that must promise before a transaction may proceed. Every component that falls back to a default when
53
+ * config is absent (cluster member, coordinator policy, node composition root) references THIS constant,
54
+ * so a member cannot silently default to a different threshold than the coordinator that commits. Explicit
55
+ * caller-supplied thresholds are unaffected; this only unifies the *absent-config* default.
56
+ * 0.75 = 3/4: chosen because the coordinator (which actually commits) already used it and the type documents it.
57
+ */
58
+ export const DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75;
59
+
40
60
  export interface ClusterConsensusConfig {
41
- /** Super-majority threshold for promises (default 0.75 = 3/4) */
61
+ /** Super-majority threshold for promises (default {@link DEFAULT_SUPER_MAJORITY_THRESHOLD} = 0.75 = 3/4) */
42
62
  superMajorityThreshold: number;
43
63
  /** Simple majority threshold for commits (default 0.51 = >50%) */
44
64
  simpleMajorityThreshold: number;
@@ -48,6 +68,20 @@ export interface ClusterConsensusConfig {
48
68
  allowClusterDownsize: boolean;
49
69
  /** Tolerance for cluster size variance as fraction (default 0.5 = 50%) */
50
70
  clusterSizeTolerance: number;
71
+ /**
72
+ * Configured full cluster size (the responsibility-K). Present so a cluster **member** has its own
73
+ * reference for "full size" when running the membership admission gate — a below-full-size declared
74
+ * peer set under low derivation confidence is refused (see {@link ClusterMember} admission gate).
75
+ * The coordinator supplies this as a required `clusterSize` via `ClusterConsensusConfig & { clusterSize: number }`.
76
+ */
77
+ clusterSize?: number;
78
+ /**
79
+ * Fraction of the member's OWN confident cluster-size estimate a declared peer set must meet to be
80
+ * admitted for voting (default 0.75). Below `⌈membershipAdmissionFraction · K_est⌉` a declared set is
81
+ * treated as an unjustified self-shrink and the member declines to approve. Distinct from
82
+ * {@link superMajorityThreshold} (the vote-counting threshold) — this gates *which set* may be voted on.
83
+ */
84
+ membershipAdmissionFraction?: number;
51
85
  /** Window for detecting partition in milliseconds (default 60000 = 1 min) */
52
86
  partitionDetectionWindow: number;
53
87
  /** Enable dispute escalation protocol (default false) */
@@ -77,10 +111,26 @@ export interface ClusterConsensusConfig {
77
111
  commitBroadcastRetryMaxAttempts?: number;
78
112
  /** Immediate in-line retries per failed peer inside the broadcast (default 1) */
79
113
  commitBroadcastImmediateRetries?: number;
114
+ /**
115
+ * Immediate in-line retries per peer while collecting promises (default 1).
116
+ * The promise phase rides the same libp2p stream the commit broadcast does;
117
+ * a circuit-relay ("limited") connection can reset that stream once a
118
+ * per-circuit cap is hit, surfacing to the coordinator as a StreamResetError.
119
+ * Unlike the commit broadcast there is no follow-up scheduled retry, so a
120
+ * single reset here would otherwise drop the peer and sink super-majority.
121
+ */
122
+ promiseImmediateRetries?: number;
80
123
  /** Read-repair behavior: 'off' (only fetch on missing — legacy), 'lazy' (fetch when local age > window), 'paranoid' (always verify against cluster on read). Default 'lazy'. */
81
124
  readRepairMode?: 'off' | 'lazy' | 'paranoid';
82
125
  /** For 'lazy' mode: read-repair triggers when (now - localEntry.lastSeenCommitMs) > this. Default 10000. */
83
126
  readRepairWindowMs?: number;
84
127
  /** Per-read probability of triggering read-repair in 'lazy' mode even within the window (0..1). Default 0 (no random check). */
85
128
  readRepairSampleRate?: number;
129
+ /**
130
+ * When FRET has no confident network-size estimate, allow an undersized cluster
131
+ * (peerCount < minAbsoluteClusterSize) to proceed anyway. Default false: with no
132
+ * confident estimate an undersized cluster is REJECTED. Turn on only for
133
+ * single-node / local dev where you knowingly run below the safe floor.
134
+ */
135
+ allowUnvalidatedSmallCluster?: boolean;
86
136
  }
@@ -5,9 +5,15 @@
5
5
  *
6
6
  * ```
7
7
  * coord_0(_, topicId) = H(0x00 ‖ topicId)
8
- * coord_d(P, topicId) = H(d ‖ prefix(P, d·log₂F) ‖ topicId) for d ≥ 1
8
+ * coord_d(P, topicId) = H(d ‖ prefix(H(P), d·log₂F) ‖ topicId) for d ≥ 1
9
9
  * ```
10
10
  *
11
+ * `P` is ring-hashed before the prefix so the shard input is uniformly distributed across
12
+ * participants (the raw peer-id string bytes share a near-constant `12D3Koo…` prefix that would
13
+ * collapse all tier-`d` shards to one coordinate). The wire field `participantCoord` keeps the
14
+ * unmodified peer id so the Ed25519 key remains recoverable; the ring-hash is applied only inside
15
+ * the addressing math.
16
+ *
11
17
  * `H` is the injected {@link IRingHash} (db-core's own SHA-256 truncated to the ring width — **not**
12
18
  * a FRET import). `prefix(P, n)` is the `n` most-significant bits of peer id `P`, left-padded if
13
19
  * shorter. `F` is the fan-out (default 16, `log₂F = 4`); tier `d` has exactly `F^d` coordinates.
@@ -24,7 +30,7 @@ export interface TierAddressing {
24
30
  readonly F: number;
25
31
  /** Tier-0 root coordinate: `H(0x00 ‖ topicId)`. Peer-independent. */
26
32
  coord0(topicId: Uint8Array): RingCoord;
27
- /** Tier-`d` coordinate for `d ≥ 1`: `H(d ‖ prefix(peerId, d·log₂F) ‖ topicId)`. */
33
+ /** Tier-`d` coordinate for `d ≥ 1`: `H(d ‖ prefix(H(P), d·log₂F) ‖ topicId)` where `H(P)` is the ring-hash of `peerId`. */
28
34
  coordD(d: number, peerId: Uint8Array, topicId: Uint8Array): RingCoord;
29
35
  /** Dispatches `d === 0` to {@link coord0}, otherwise to {@link coordD}. */
30
36
  coord(d: number, peerId: Uint8Array, topicId: Uint8Array): RingCoord;
@@ -92,8 +98,10 @@ export class HashTierAddressing implements TierAddressing {
92
98
  if (d > 255) {
93
99
  throw new RangeError(`tier d must fit in one byte (≤ 255), got ${d}`);
94
100
  }
95
- // H(d ‖ prefix(peerId, d·log₂F) ‖ topicId)
96
- const prefix = prefixBits(peerId, d * this.log2F);
101
+ // H(d ‖ prefix(H(P), d·log₂F) ‖ topicId) — ring-hash P first so the shard input is uniform
102
+ // NOTE: re-hashes peerId on every coordD call; a walk over a tier ladder recomputes H(self) per
103
+ // tier. Negligible today (walk steps are network-bound); if coord becomes hot, cache H(peerId).
104
+ const prefix = prefixBits(this.hash.H(peerId), d * this.log2F);
97
105
  const input = new Uint8Array(1 + prefix.length + topicId.length);
98
106
  input[0] = d;
99
107
  input.set(prefix, 1);
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Cohort-topic substrate — bootstrap evidence policy (anti-DoS).
3
3
  *
4
- * Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 4. A cold root that accepts a
5
- * `bootstrap: true` registration must demand evidence, so a cold topic cannot be instantiated for
6
- * free by an attacker. The accepted evidence is **tier-dependent**:
4
+ * Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 4. A cold cohort that accepts a cold-start
5
+ * registration must demand evidence, so a cold topic cannot be instantiated for free by an attacker.
6
+ * **A follow-on cold-start (`followOn: true`) is gated identically to a root bootstrap
7
+ * (`bootstrap: true`)**: the cold tier-`(d+1)` child a `Promoted` redirect points at is participant-
8
+ * asserted (the child cannot infer the redirect — the routing key decorrelates tiers and it holds no
9
+ * breadcrumb of where the participant came from), so its safety cannot rest on provenance; it rests on
10
+ * this gate paying the same anti-abuse cost the root already pays. The accepted evidence is
11
+ * **tier-dependent**:
7
12
  *
8
13
  * - **T0 / T1** — generally no proof-of-work, because these tiers correspond to committed work; a
9
14
  * *signed reference to a committed parent topic that does exist* is sufficient.
@@ -18,7 +23,7 @@
18
23
  * `bootstrapEvidence` field — a versioned `BootstrapEvidenceEnvelopeV1`
19
24
  * (`./bootstrap-evidence-envelope.js`), parsed crypto-free here and checked by the injected verifiers;
20
25
  * NOT the opaque `appPayload` slot (which the cohort copies verbatim into the registration's appState).
21
- * A registration that is not a bootstrap needs no evidence and is admitted.
26
+ * A registration that is neither a bootstrap nor a follow-on cold-start needs no evidence and is admitted.
22
27
  */
23
28
 
24
29
  import type { RegisterV1 } from "../wire/types.js";
@@ -47,9 +52,10 @@ export interface BootstrapEvidenceConfig {
47
52
  /** Tier-dependent bootstrap-evidence gate for cold-root registrations. */
48
53
  export interface BootstrapEvidence {
49
54
  /**
50
- * Whether `reg` carries acceptable bootstrap evidence for `tier`. A non-bootstrap registration is
51
- * always acceptable (nothing to prove). For a bootstrap: T0/T1 require a signed parent reference;
52
- * T2/T3 accept proof-of-work OR a reputation signature OR a signed parent reference.
55
+ * Whether `reg` carries acceptable cold-start evidence for `tier`. A registration that is neither a
56
+ * `bootstrap` nor a `followOn` is always acceptable (nothing to prove). For a cold-start (either flag):
57
+ * T0/T1 require a signed parent reference; T2/T3 accept proof-of-work OR a reputation signature OR a
58
+ * signed parent reference.
53
59
  */
54
60
  verify(reg: RegisterV1, tier: number): boolean;
55
61
  }
@@ -68,8 +74,14 @@ class TieredBootstrapEvidence implements BootstrapEvidence {
68
74
  }
69
75
 
70
76
  verify(reg: RegisterV1, tier: number): boolean {
71
- if (reg.bootstrap !== true) {
72
- return true; // only a cold-root bootstrap must carry evidence
77
+ // A follow-on cold-start is gated identically to a root bootstrap both must carry evidence.
78
+ // NOTE: follow-on cold-start is PoW-gated exactly like a root bootstrap; if a spoofed, PoW-paid
79
+ // follow-on instantiation ever shows up as real abuse, upgrade to a parent-vouched redirect —
80
+ // echo the parent cohort's threshold-signed PromotionNoticeV1 on the follow-on and verify it
81
+ // against the parent's MembershipCertV1 (needs a RegisterReplyV1 field + admission-path membership
82
+ // verify). See docs/cohort-topic.md §Anti-DoS "follow-on hardening".
83
+ if (reg.bootstrap !== true && reg.followOn !== true) {
84
+ return true; // only a cold-start (bootstrap or follow-on) must carry evidence
73
85
  }
74
86
  if (tier <= this.maxNoPowTier) {
75
87
  // T0/T1: a signed reference to a committed parent topic is sufficient (no PoW expected).
@@ -11,6 +11,17 @@
11
11
  * Because a stale registration is rejected outright, the guard only needs to remember correlation ids
12
12
  * for one `maxAgeMs` window: an id older than that would be rejected on timestamp alone, so its record
13
13
  * can be pruned. Pruning runs on access, bounding memory to the live window's worth of registrations.
14
+ *
15
+ * On top of the age-based prune, the map carries a **hard LRU `maxKeys` cap** (mirroring the sibling
16
+ * {@link import("./rate-limiter.js").RegisterRateLimiter}) so a flood of genuinely-fresh, admitted
17
+ * correlationIds cannot grow `seen` without bound before the age prune fires. When a new id would
18
+ * exceed the cap, the **oldest-inserted** entries are evicted until within cap. That victim is the
19
+ * least-bad one: replay entries are inserted once and never refreshed, so `Map` insertion order tracks
20
+ * timestamp order — the oldest entry is the one nearest to aging out of the window and being pruned as
21
+ * stale anyway. Evicting it forgives at most that entry's remaining replay-protection window: a bounded,
22
+ * documented tradeoff (unlike the rate limiter's fully penalty-free eviction), and one that only
23
+ * triggers under a flood of admitted ids — which, in the register pipeline, must also have passed the
24
+ * signature, rate, and bootstrap gates before ever reaching this guard.
14
25
  */
15
26
 
16
27
  import { bytesKey } from "../registration/bytes.js";
@@ -22,12 +33,16 @@ const log = createLogger("cohort-topic:antidos");
22
33
  export const DEFAULT_REPLAY_MAX_AGE_MS = 60_000;
23
34
  /** Default tolerated forward clock skew: a timestamp this far past `now` is rejected as implausible. */
24
35
  export const DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS = 5_000;
36
+ /** Default hard cap on remembered correlationIds; the oldest-inserted are evicted beyond this. */
37
+ export const DEFAULT_REPLAY_GUARD_MAX_KEYS = 100_000;
25
38
 
26
39
  export interface CorrelationReplayGuardConfig {
27
40
  /** A timestamp older than `now − maxAgeMs` is stale. Default {@link DEFAULT_REPLAY_MAX_AGE_MS}. */
28
41
  maxAgeMs?: number;
29
42
  /** A timestamp newer than `now + maxFutureSkewMs` is rejected. Default {@link DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS}. */
30
43
  maxFutureSkewMs?: number;
44
+ /** Hard LRU cap on remembered correlationIds; oldest-inserted (≈ oldest-timestamp) evicted beyond this. Default {@link DEFAULT_REPLAY_GUARD_MAX_KEYS}. */
45
+ maxKeys?: number;
31
46
  }
32
47
 
33
48
  /** Freshness + anti-replay gate over registration `correlationId`s and timestamps. */
@@ -39,6 +54,8 @@ export interface CorrelationReplayGuard {
39
54
  * id on first sight of a fresh registration.
40
55
  */
41
56
  accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean;
57
+ /** Remembered correlationId count (test/diagnostic introspection). */
58
+ readonly size: number;
42
59
  }
43
60
 
44
61
  /** A remembered acceptance, kept until its timestamp ages out of the window. */
@@ -51,6 +68,7 @@ class WindowedReplayGuard implements CorrelationReplayGuard {
51
68
  private readonly seen = new Map<string, SeenEntry>();
52
69
  private readonly maxAgeMs: number;
53
70
  private readonly maxFutureSkewMs: number;
71
+ private readonly maxKeys: number;
54
72
  /** `now` of the last prune, so pruning amortizes rather than scanning on every call. */
55
73
  private lastPruneAt = -Infinity;
56
74
 
@@ -63,6 +81,10 @@ class WindowedReplayGuard implements CorrelationReplayGuard {
63
81
  if (!(this.maxFutureSkewMs >= 0)) {
64
82
  throw new RangeError(`maxFutureSkewMs must be >= 0, got ${this.maxFutureSkewMs}`);
65
83
  }
84
+ this.maxKeys = config.maxKeys ?? DEFAULT_REPLAY_GUARD_MAX_KEYS;
85
+ if (!Number.isInteger(this.maxKeys) || this.maxKeys <= 0) {
86
+ throw new RangeError(`maxKeys must be a positive integer, got ${this.maxKeys}`);
87
+ }
66
88
  }
67
89
 
68
90
  accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean {
@@ -83,6 +105,17 @@ class WindowedReplayGuard implements CorrelationReplayGuard {
83
105
  log("replay-guard reject: replayed correlationId (first seen from peer=%s)", prior.peer);
84
106
  return false; // replay
85
107
  }
108
+ // New id: enforce the hard cap by evicting the oldest-inserted entries (oldest by `Map` insertion
109
+ // order) until this insertion stays within `maxKeys`. Since entries are inserted once and never
110
+ // refreshed, insertion order closely tracks timestamp order, so the victim is ≈ the oldest-timestamp
111
+ // — nearest to aging out as stale. (It is only ≈: ids from distinct peers can arrive out of
112
+ // timestamp order within the skew window, so a victim may retain slightly more window than a
113
+ // strictly-oldest pick. The forgiveness bound — at most one entry's remaining window — holds either way.)
114
+ while (this.seen.size >= this.maxKeys) {
115
+ const oldest = this.seen.keys().next().value;
116
+ if (oldest === undefined) break;
117
+ this.seen.delete(oldest);
118
+ }
86
119
  this.seen.set(key, { timestamp, peer: bytesKey(peerId) });
87
120
  return true;
88
121
  }
@@ -101,6 +134,10 @@ class WindowedReplayGuard implements CorrelationReplayGuard {
101
134
  }
102
135
  }
103
136
  }
137
+
138
+ get size(): number {
139
+ return this.seen.size;
140
+ }
104
141
  }
105
142
 
106
143
  /** Build a {@link CorrelationReplayGuard} over the configured staleness window and forward skew. */
@@ -26,6 +26,13 @@ export const DEFAULT_TOPICS_MAX = 2048;
26
26
  export interface TopicBudgetConfig {
27
27
  /** Max topics with forwarder state. Default {@link DEFAULT_TOPICS_MAX}. */
28
28
  topicsMax?: number;
29
+ /**
30
+ * Called with the evicted topic's id just before its slot is freed in {@link TopicBudget.admit}, so
31
+ * the caller can tear down the now-unbacked forwarder + traffic window that outlives the slot. Fires
32
+ * only for a genuine eviction (a zero-participant victim), never for a plain admit, an already-resident
33
+ * re-admit, or a refusal. Absent → no teardown hook.
34
+ */
35
+ onEvict?: (topicId: Uint8Array) => void;
29
36
  }
30
37
 
31
38
  /** Per-cohort cap on the number of topics with forwarder state, with LRU eviction of cold topics. */
@@ -60,11 +67,14 @@ interface ResidentState {
60
67
  participantCount: number;
61
68
  /** Monotonic access sequence (LRU tiebreaker; lower = least-recently used). */
62
69
  seq: number;
70
+ /** The original topic-id bytes, captured on admit so `onEvict` can be handed real bytes (residents key by string). */
71
+ topicId: Uint8Array;
63
72
  }
64
73
 
65
74
  class LruTopicBudget implements TopicBudget {
66
75
  private readonly residents = new Map<string, ResidentState>();
67
76
  private readonly topicsMax: number;
77
+ private readonly onEvict?: (topicId: Uint8Array) => void;
68
78
  private seqCounter = 0;
69
79
 
70
80
  constructor(config: TopicBudgetConfig = {}) {
@@ -72,6 +82,7 @@ class LruTopicBudget implements TopicBudget {
72
82
  if (!Number.isInteger(this.topicsMax) || this.topicsMax <= 0) {
73
83
  throw new RangeError(`topicsMax must be a positive integer, got ${this.topicsMax}`);
74
84
  }
85
+ this.onEvict = config.onEvict;
75
86
  }
76
87
 
77
88
  get size(): number {
@@ -94,7 +105,7 @@ class LruTopicBudget implements TopicBudget {
94
105
  return true;
95
106
  }
96
107
  if (this.residents.size < this.topicsMax) {
97
- this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter });
108
+ this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
98
109
  return true;
99
110
  }
100
111
  // Full: only a zero-participant resident may be evicted to make room for a new topic.
@@ -103,8 +114,12 @@ class LruTopicBudget implements TopicBudget {
103
114
  log("topic-budget full size=%d max=%d — refuse new topic", this.residents.size, this.topicsMax);
104
115
  return false;
105
116
  }
117
+ // Fire the teardown hook with the victim's real bytes BEFORE freeing the slot, so the callback
118
+ // (drop the now-unbacked forwarder + traffic window) sees a coherent state. Only a genuine
119
+ // eviction reaches here — `coldestEvictable()` already refused a full-of-populated budget above.
120
+ this.onEvict?.(this.residents.get(victim)!.topicId);
106
121
  this.residents.delete(victim);
107
- this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter });
122
+ this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
108
123
  log("topic-budget evicted cold topic to admit new (size=%d max=%d)", this.residents.size, this.topicsMax);
109
124
  return true;
110
125
  }
@@ -11,18 +11,22 @@
11
11
  * cold-start case) **or** it arrived as a follow-on to a parent cohort's `Promoted` redirect, AND
12
12
  * - a quorum of cohort members is willing to serve `T` at the registration's tier.
13
13
  *
14
- * The `followOn` signal is **not** carried on the wire (`RegisterV1` has only `bootstrap`); a cold
15
- * high-tier cohort returns `NoState` to a *speculative* `d_max` probe, but the single-direction walk
16
- * means the only registrations that reach a cold tier-`(d+1)` cohort are those that followed the
17
- * parent's `Promoted` redirect. Determining `followOn` for a given inbound register therefore belongs
18
- * to the db-p2p cohort host (it has the routing context); db-core takes it as an explicit input to
19
- * {@link shouldInstantiate}, keeping this module FRET-free. (See the implement handoff for the wiring
20
- * gap this leaves for the db-p2p binding.)
14
+ * The `followOn` signal **is** carried on the wire (`RegisterV1.followOn`), set only on the dedicated
15
+ * re-issue a participant sends after a parent cohort's `Promoted` redirect target answered `NoState`
16
+ * (the deeper child is cold). A cold high-tier cohort still returns `NoState` to a *speculative* `d_max`
17
+ * probe (neither flag set), but the single-direction walk means the only registrations that reach a cold
18
+ * tier-`(d+1)` cohort carrying `followOn: true` are those that followed the parent's `Promoted` redirect.
19
+ * The db-p2p cohort host derives `ctx.followOn` from the wire flag (`followOn: reg.followOn === true`) and
20
+ * db-core takes it as an explicit input to {@link shouldInstantiate}, keeping this module FRET-free. Because
21
+ * the flag is participant-forgeable, a `followOn: true` register is gated by the **same** tier-dependent
22
+ * bootstrap-evidence policy a `bootstrap: true` cold-root register passes (§Anti-DoS), so an unbacked
23
+ * follow-on never reaches this instantiation decision.
21
24
  *
22
25
  * Once instantiated, the new forwarder **registers itself with its tier-`(d − 1)` parent at first
23
- * opportunity**; until that registration is acked it {@link Forwarder.acceptsParticipants | accepts
24
- * participants} but {@link Forwarder.servesParentOps | holds} notifications/queries that need parent
25
- * involvement. The root (tree tier 0) has no parent and is serving immediately.
26
+ * opportunity** by sending a child-link the parent authenticates + records; until that link is acked
27
+ * (`linked`) it {@link Forwarder.acceptsParticipants | accepts participants} but
28
+ * {@link Forwarder.servesParentOps | holds} notifications/queries that need parent involvement. The root
29
+ * (tree tier 0) has no parent and is serving immediately.
26
30
  *
27
31
  * **Just-promoted burst (GROUNDING-resolved: bounce, don't buffer).** A cohort that has just promoted
28
32
  * but whose tier-`(d+1)` isn't fully instantiated yet, on a burst of new same-tier registrations,
@@ -34,6 +38,9 @@
34
38
  import { attachTopicTraffic } from "./traffic.js";
35
39
  import { bytesKey } from "./registration/bytes.js";
36
40
  import type { RegisterReplyV1, TopicTrafficV1 } from "./wire/types.js";
41
+ import { createLogger } from "../logger.js";
42
+
43
+ const log = createLogger('cohort-topic:coldstart');
37
44
 
38
45
  /** Inputs to the cold-start admission gate. */
39
46
  export interface ColdStartTrigger {
@@ -130,13 +137,18 @@ export function promotedRedirectReply(targetTier: number, traffic?: TopicTraffic
130
137
  return traffic !== undefined ? attachTopicTraffic(reply, traffic) : reply;
131
138
  }
132
139
 
133
- /** Registers a newly-instantiated forwarder with its tier-`(d − 1)` parent; resolves on the parent ack. */
140
+ /**
141
+ * Registers a newly-instantiated forwarder with its tier-`(d − 1)` parent; resolves only when the parent
142
+ * authenticates + records the child and returns a `linked` ack. A `rejected` reply (bad coord binding /
143
+ * failed threshold verify) or an unreachable parent rejects the promise, so the forwarder stays
144
+ * `awaiting_parent` for a later retry.
145
+ */
134
146
  export interface ParentRegistrar {
135
147
  /**
136
148
  * Register the forwarder for `topicId` (served at tree tier `tier`, i.e. `d`) with the cohort at
137
- * `parentCoord`; resolves on ack. `opTier` is the topic's *capacity* tier (T0–T3), threaded so the
138
- * transport can stamp a well-formed forwarder-link frame; absent when the caller has no op-tier
139
- * context (the cohort host always supplies it from the instantiating `RegisterV1`).
149
+ * `parentCoord`; resolves on a `linked` ack. `opTier` is the topic's *capacity* tier (T0–T3), threaded so
150
+ * the transport can stamp a well-formed child-link frame; absent when the caller has no op-tier context
151
+ * (the cohort host always supplies it from the instantiating `RegisterV1`).
140
152
  */
141
153
  registerWithParent(topicId: Uint8Array, parentCoord: Uint8Array, tier: number, opTier?: number): Promise<void>;
142
154
  }
@@ -161,6 +173,17 @@ export interface ColdStartManager {
161
173
  instantiate(topicId: Uint8Array, tier: number, parentCoord?: Uint8Array, opTier?: number): Forwarder;
162
174
  /** The tracked forwarder for `topicId`, or `undefined`. */
163
175
  get(topicId: Uint8Array): Forwarder | undefined;
176
+ /**
177
+ * Drop the forwarder for `topicId` (budget eviction / teardown). Idempotent; a no-op if the topic is
178
+ * not tracked. After this, {@link get} returns `undefined` for `topicId`, so the cohort no longer
179
+ * reports serving it via cold-start state.
180
+ */
181
+ remove(topicId: Uint8Array): void;
182
+ /**
183
+ * Whether this manager currently tracks any forwarder. The host's idle-engine eviction reads this to
184
+ * avoid reclaiming a cohort that holds a live (possibly `awaiting_parent`) cold-start forwarder.
185
+ */
186
+ hasForwarders(): boolean;
164
187
  }
165
188
 
166
189
  class TrackingColdStartManager implements ColdStartManager {
@@ -187,7 +210,7 @@ class TrackingColdStartManager implements ColdStartManager {
187
210
  .registerWithParent(topicId, parentCoord, tier, opTier)
188
211
  .then(() => forwarder.onParentAck())
189
212
  .catch((err: unknown) => {
190
- console.warn(`cohort-topic cold-start: parent registration for tier-${tier} forwarder failed`, err);
213
+ log('WARN: parent registration for tier-%s forwarder failed %o', tier, err);
191
214
  });
192
215
  }
193
216
  return forwarder;
@@ -196,6 +219,16 @@ class TrackingColdStartManager implements ColdStartManager {
196
219
  get(topicId: Uint8Array): Forwarder | undefined {
197
220
  return this.forwarders.get(bytesKey(topicId));
198
221
  }
222
+
223
+ remove(topicId: Uint8Array): void {
224
+ // Idempotent: `Map.delete` on an absent key is a safe no-op, so a double-remove (or a remove of a
225
+ // never-instantiated topic) does nothing.
226
+ this.forwarders.delete(bytesKey(topicId));
227
+ }
228
+
229
+ hasForwarders(): boolean {
230
+ return this.forwarders.size > 0;
231
+ }
199
232
  }
200
233
 
201
234
  /** Build a {@link ColdStartManager} over the injected parent registrar. */
@@ -25,9 +25,10 @@
25
25
 
26
26
  import { bytesEqual, bytesKey } from "../registration/bytes.js";
27
27
  import type { RegistrationStore } from "../registration/types.js";
28
+ import { clampTtl } from "../registration/types.js";
28
29
  import type { ICohortGossipTransport, PeerRef, RingCoord } from "../ports.js";
29
30
  import { b64urlToBytes, decodeCohortGossipV1, encodeCohortMessage } from "../wire/codec.js";
30
- import type { CohortGossipV1 } from "../wire/types.js";
31
+ import type { ChildLinkRefV1, CohortGossipV1 } from "../wire/types.js";
31
32
  import { fromGossipRecord } from "./records.js";
32
33
  import { createCohortView, type CohortView, type MutableCohortView } from "./view.js";
33
34
 
@@ -70,6 +71,17 @@ export interface CohortGossipBusDeps {
70
71
  * callback so this gossip/replication module carries no anti-DoS dependency. Absent → skipped.
71
72
  */
72
73
  onRecordsEvicted?: (topicIds: readonly Uint8Array[]) => void;
74
+ /**
75
+ * Optional hook fired for the child-cohort link/unlink deltas on an inbound frame (db-p2p merges them into
76
+ * this coord's child registry, last-writer-wins by `effectiveAt`), so every parent member converges on the
77
+ * same child set — not only the FRET-routed member that recorded the child. Fired for **our-coord** frames
78
+ * regardless of epoch match: the child set is keyed by child coord, not the parent's epoch, so a parent
79
+ * membership rotation (an epoch drift) must not skip the merge (unlike record deltas, whose slot
80
+ * assignments are epoch-scoped). The merge is a direct registry write — it does **not** re-enqueue the
81
+ * delta, so a received link/unlink is not re-gossiped (one broadcast reaches the whole cohort). Kept as a
82
+ * decoupled callback so this module carries no child-registry dependency. Absent → skipped.
83
+ */
84
+ onChildDeltas?: (childLinks: readonly ChildLinkRefV1[], childUnlinks: readonly ChildLinkRefV1[]) => void;
73
85
  }
74
86
 
75
87
  /** Intra-cohort gossip bus (merge logic over an injected transport). */
@@ -134,6 +146,14 @@ class TransportCohortGossipBus implements CohortGossipBus {
134
146
  if (epochMatches) {
135
147
  this.mergeRecords(g, now);
136
148
  }
149
+ // Child link/unlink deltas merge regardless of epoch match: the child set is keyed by child coord, not
150
+ // the parent's epoch, so a rotation (epoch drift) must not drop it. Freshness (last-writer-wins by
151
+ // effectiveAt) lives in the registry the callback writes.
152
+ const childLinks = g.childLinks ?? [];
153
+ const childUnlinks = g.childUnlinks ?? [];
154
+ if (this.deps.onChildDeltas !== undefined && (childLinks.length > 0 || childUnlinks.length > 0)) {
155
+ this.deps.onChildDeltas(childLinks, childUnlinks);
156
+ }
137
157
  }
138
158
 
139
159
  view(): CohortView {
@@ -180,6 +200,11 @@ class TransportCohortGossipBus implements CohortGossipBus {
180
200
  private mergeRecords(g: CohortGossipV1, now: number): void {
181
201
  for (const gr of g.records ?? []) {
182
202
  const incoming = fromGossipRecord(gr);
203
+ // Gossip is a second admission path into the store — a peer (buggy, unpatched, or hostile)
204
+ // can replicate a record whose TTL never passed local `accept()`'s clamp. Re-clamp here so
205
+ // no replica can hold an unbounded lifetime that wedges the topic budget. Clamp BEFORE the
206
+ // dead-check below so a poison TTL cannot dodge staleness eviction.
207
+ incoming.ttl = clampTtl(incoming.ttl);
183
208
  // A record already past its TTL is dead; merging it would resurrect a registration the
184
209
  // owner has (or soon will have) evicted. Drop it, matching `store.evictStale`'s predicate
185
210
  // so replication can never reintroduce what local eviction removes.
@@ -200,7 +225,19 @@ class TransportCohortGossipBus implements CohortGossipBus {
200
225
  let evictedTopics: Map<string, Uint8Array> | undefined;
201
226
  for (const ref of g.evicted ?? []) {
202
227
  const topicId = b64urlToBytes(ref.topicId);
203
- this.deps.store.delete(topicId, b64urlToBytes(ref.participantId));
228
+ const participantId = b64urlToBytes(ref.participantId);
229
+ // NOTE: freshness guard adds one getByParticipant per eviction ref (on top of the delete's own
230
+ // lookup); evictions are low-volume, but if this path ever shows as hot, fold the guard into a
231
+ // single conditional-delete store op.
232
+ const held = this.deps.store.getByParticipant(topicId, participantId);
233
+ // A stale eviction (older than the held record) must not delete a fresher re-registration.
234
+ // Evictions are otherwise NOT last-writer-wins like merges are, so a reordered/slow delta would
235
+ // win against a record the participant already renewed. Skip it — and, since nothing drained,
236
+ // do NOT add the topic to `evictedTopics` (no delete → no budget re-touch).
237
+ if (held !== undefined && held.lastPing > ref.lastPing) {
238
+ continue;
239
+ }
240
+ this.deps.store.delete(topicId, participantId);
204
241
  if (this.deps.onRecordsEvicted !== undefined) {
205
242
  (evictedTopics ??= new Map()).set(bytesKey(topicId), topicId);
206
243
  }