@optimystic/db-core 0.14.1 → 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
@@ -21,6 +21,14 @@ export interface MemberContribution {
21
21
  readonly windowSeconds: number;
22
22
  /** Exact per-topic summaries this member last reported. */
23
23
  readonly topicSummaries: readonly CohortTopicSummary[];
24
+ /**
25
+ * `topicId` (base64url) → summary, derived from {@link topicSummaries} at {@link MutableCohortView.merge}
26
+ * so a per-topic reader (traffic `snapshot`) does an O(1) lookup instead of an O(summaries) `.find` per
27
+ * member per reply. First occurrence wins on a duplicate `topicId` (matching `Array.prototype.find`).
28
+ * Populated by `merge`; a contribution built without going through `merge` may omit it, in which case
29
+ * consumers fall back to scanning {@link topicSummaries}.
30
+ */
31
+ readonly topicIndex?: ReadonlyMap<string, CohortTopicSummary>;
24
32
  /** Gossip timestamp (unix ms) — the last-writer-wins key. */
25
33
  readonly timestamp: number;
26
34
  }
@@ -55,11 +63,28 @@ class MapCohortView implements MutableCohortView {
55
63
  if (held !== undefined && c.timestamp < held.timestamp) {
56
64
  return false; // stale; keep the newer contribution
57
65
  }
58
- this.byMember.set(member, c);
66
+ // Derive the per-topic index once, at write time, so per-topic readers never rescan the flat
67
+ // `topicSummaries` array per reply. A writer pays O(summaries) once per merge (gossip round).
68
+ this.byMember.set(member, { ...c, topicIndex: indexTopics(c.topicSummaries) });
59
69
  return true;
60
70
  }
61
71
  }
62
72
 
73
+ /**
74
+ * Index a member's flat topic summaries by `topicId` (base64url). First occurrence wins on a duplicate
75
+ * `topicId`, exactly matching the `Array.prototype.find` scan this replaces — so a snapshot over the index
76
+ * returns identical numbers to one over the array.
77
+ */
78
+ function indexTopics(summaries: readonly CohortTopicSummary[]): ReadonlyMap<string, CohortTopicSummary> {
79
+ const index = new Map<string, CohortTopicSummary>();
80
+ for (const s of summaries) {
81
+ if (!index.has(s.topicId)) {
82
+ index.set(s.topicId, s);
83
+ }
84
+ }
85
+ return index;
86
+ }
87
+
63
88
  /** Construct an empty {@link MutableCohortView}. */
64
89
  export function createCohortView(): MutableCohortView {
65
90
  return new MapCohortView();
@@ -5,8 +5,9 @@
5
5
  * on it (the activity callback in `docs/cohort-topic.md` §FRET integration). It is the cohort-facing
6
6
  * half of the substrate, composed from the prereq tickets' pure modules — the participant-facing half
7
7
  * lives in {@link import("./service.js").CohortTopicService}. db-p2p's FRET host decodes an inbound
8
- * frame, supplies the routing context this module can't see (`followOn`, the quorum-willing tally),
9
- * and serializes whatever reply this returns back over the wire. db-core never imports FRET here.
8
+ * frame, supplies the routing context this module can't see (`followOn` — derived from the wire
9
+ * `RegisterV1.followOn` flag, evidence-gated in step 1 plus the quorum-willing tally), and serializes
10
+ * whatever reply this returns back over the wire. db-core never imports FRET here.
10
11
  *
11
12
  * Inbound `RegisterV1` decision pipeline (§Registration mechanics, §Willingness, §Cold-start):
12
13
  *
@@ -26,7 +27,7 @@
26
27
  import type { IRingHash } from "./ports.js";
27
28
  import { bytesEqual, bytesKey } from "./registration/bytes.js";
28
29
  import type { RegistrationRecord, RegistrationStore } from "./registration/types.js";
29
- import { DEFAULT_TTL_MS } from "./registration/types.js";
30
+ import { clampTtl } from "./registration/types.js";
30
31
  import type { SlotAssigner } from "./registration/sharding.js";
31
32
  import type { RenewalCohortSide } from "./registration/renewal.js";
32
33
  import type { WillingnessCheck } from "./willingness.js";
@@ -47,7 +48,13 @@ import type { DemotionNoticeV1, PromotionNoticeV1, RegisterReplyV1, RegisterV1,
47
48
 
48
49
  /** Routing context the FRET host supplies for an inbound register (db-core can't derive it). */
49
50
  export interface RegisterContext {
50
- /** Did this register arrive as a follow-on to a parent cohort's `Promoted` redirect? */
51
+ /**
52
+ * Did this register arrive as a follow-on to a parent cohort's `Promoted` redirect? The host derives
53
+ * this from the wire `RegisterV1.followOn` flag (`followOn: reg.followOn === true`). It is participant-
54
+ * asserted and therefore evidence-gated: `runGuards` (step 1) already ran `bootstrapEvidence.verify`,
55
+ * which demands the same proof for `followOn: true` as for `bootstrap: true`, so an unbacked follow-on
56
+ * never reaches this instantiation decision (it short-circuited to `unwilling_cohort`).
57
+ */
51
58
  readonly followOn: boolean;
52
59
  /** Tree tier `d` this cohort serves the topic at (the walk position the register landed on). */
53
60
  readonly treeTier: number;
@@ -172,7 +179,10 @@ class StoreCohortMemberEngine implements CohortMemberEngine {
172
179
  return this.admitOrDecline(reg, topicId, participantId, tier, ctx, now);
173
180
  }
174
181
 
175
- // 3. Cold path: instantiate only at a legitimate growth point with a willing quorum.
182
+ // 3. Cold path: instantiate only at a legitimate growth point with a willing quorum. `ctx.followOn`
183
+ // mirrors the wire `reg.followOn` flag (the host derives it), and by the time we reach here the
184
+ // step-1 bootstrap-evidence gate has already verified a `followOn: true` carries the same proof a
185
+ // `bootstrap: true` root cold-start carries — so this branch never instantiates an unbacked follow-on.
176
186
  const quorumWilling = this.deps.quorumWilling(tier);
177
187
  if (!shouldInstantiate({ bootstrap: reg.bootstrap === true, followOn: ctx.followOn, quorumWilling })) {
178
188
  return { v: 1, result: "no_state" };
@@ -310,7 +320,7 @@ class StoreCohortMemberEngine implements CohortMemberEngine {
310
320
  ): Promise<RegisterReplyV1> {
311
321
  const { members, cohortEpoch } = this.deps.cohort();
312
322
  const { primary, backups } = this.deps.slots.assignSlots(participantId, cohortEpoch, members);
313
- const ttl = reg.ttl > 0 ? reg.ttl : DEFAULT_TTL_MS;
323
+ const ttl = clampTtl(reg.ttl);
314
324
  const record: RegistrationRecord = {
315
325
  topicId,
316
326
  participantId,
@@ -368,21 +378,32 @@ class StoreCohortMemberEngine implements CohortMemberEngine {
368
378
  ): RegisterReplyV1 | undefined {
369
379
  if (this.deps.verifyRegisterSig?.(reg) === false) {
370
380
  // Unsigned / forged participant signature: serve nothing and record nothing (the cohort
371
- // cannot trust this participantCoord). Checked before the replay/rate guards so a forged
372
- // frame cannot consume their state.
381
+ // cannot trust this participantCoord). Cheap + stateless, so checked first a forged frame
382
+ // cannot consume any downstream guard state.
373
383
  return { v: 1, result: "no_state" };
374
384
  }
375
- const correlationId = b64urlToBytes(reg.correlationId);
376
- if (this.deps.replayGuard?.accept(correlationId, participantId, reg.timestamp, now) === false) {
377
- // Stale / future-skewed / replayed: serve nothing and record nothing.
378
- return { v: 1, result: "no_state" };
385
+ // Rate-check BEFORE the replay guard records anything: a fresh-correlationId spam frame that the
386
+ // rate limiter would reject must not first insert a `seen` entry (which would let an attacker grow
387
+ // the replay guard's memory at full attack speed regardless of the rate limit). Running it before
388
+ // the potentially-expensive bootstrap verify (which may do PoW) also short-circuits floods sooner.
389
+ // NOTE: a replayed frame now consumes a rate-limiter accept before the replay guard (below) drops it,
390
+ // whereas the old order dropped replays pre-rate. Fine for spam (inbound traffic should be counted);
391
+ // only matters if a legitimate client reuses one correlationId across retransmits — it would then
392
+ // burn rate budget per retransmit. If clients adopt idempotent same-cid retry, revisit this order.
393
+ const rate = this.deps.rateLimiter?.check(participantId, topicId, now);
394
+ if (rate !== undefined && rate.ok === false) {
395
+ return { v: 1, result: "unwilling_cohort", retryAfterMs: rate.retryAfterMs };
379
396
  }
380
397
  if (this.deps.bootstrapEvidence?.verify(reg, tier) === false) {
381
398
  return this.unwillingCohort(reg, now);
382
399
  }
383
- const rate = this.deps.rateLimiter?.check(participantId, topicId, now);
384
- if (rate !== undefined && rate.ok === false) {
385
- return { v: 1, result: "unwilling_cohort", retryAfterMs: rate.retryAfterMs };
400
+ // Replay guard records LAST, so only frames that passed signature + rate + bootstrap ever insert a
401
+ // `seen` entry. An *accepted* frame is still always recorded here, so a genuine replay of a served
402
+ // correlationId is still caught; only rate-rejected frames (never served, never recorded) skip it.
403
+ const correlationId = b64urlToBytes(reg.correlationId);
404
+ if (this.deps.replayGuard?.accept(correlationId, participantId, reg.timestamp, now) === false) {
405
+ // Stale / future-skewed / replayed: serve nothing and record nothing.
406
+ return { v: 1, result: "no_state" };
386
407
  }
387
408
  return undefined;
388
409
  }
@@ -4,20 +4,21 @@
4
4
  * Per `docs/cohort-topic.md` §Membership snapshots: the cohort publishes a threshold-signed
5
5
  * `MembershipCertV1`
6
6
  *
7
- * - at stabilization, and on any change to the first `k x` members ({@link MembershipCertPublisher.onStabilized});
7
+ * - at stabilization, and on any change to the cohort identity the epoch `H(sorted all members)`,
8
+ * so *any* member change (head or tail) forces a fresh publish ({@link MembershipCertPublisher.onStabilized});
8
9
  * - refreshed every `T_membership_refresh` (default 5 min) ({@link MembershipCertPublisher.tick}).
9
10
  *
10
11
  * The publisher is db-core logic over the {@link CohortSigner} (threshold sign) and the
11
12
  * {@link IMembershipPublishSink} (serve/advertise). It never imports FRET. Members are sorted
12
- * ascending for the cert (matching the sharding order) and the first `minSigs` (= `k − x`) of that
13
- * order are the "first `k x`" whose change forces a fresh publish.
13
+ * ascending for the cert (matching the sharding order); the snapshot's `cohortEpoch` is the full
14
+ * cohort identity, and a change to it across a publish forces a fresh publish.
14
15
  */
15
16
 
16
17
  import type { IMembershipPublishSink } from "../ports.js";
17
18
  import { bytesToB64url, encodeCohortMessage } from "../wire/codec.js";
18
19
  import type { MembershipCertV1 } from "../wire/types.js";
19
20
  import { compareBytes } from "../registration/bytes.js";
20
- import { DEFAULT_MIN_SIGS, type CohortSigner } from "../sig/threshold.js";
21
+ import { type CohortSigner } from "../sig/threshold.js";
21
22
  import { membershipCertSigningPayload, type MembershipCertSignable } from "../sig/payloads.js";
22
23
 
23
24
  /** Default membership-cert refresh interval (`T_membership_refresh`). */
@@ -74,10 +75,11 @@ export interface RotationAttestation {
74
75
  /** Cohort-side membership-cert publisher. */
75
76
  export interface MembershipCertPublisher {
76
77
  /**
77
- * Drive publication on a stabilization event. Publishes (and returns the cert) when the first
78
- * `k x` members changed since the last publish, or on the first call; otherwise returns `undefined`.
79
- * Pass `rotation` to attach a predecessor-cohort rotation attestation (an epoch rotation); omit it for
80
- * the default non-rotation publish (no rotation fields are emitted).
78
+ * Drive publication on a stabilization event. Publishes (and returns the cert) when the cohort identity
79
+ * (`cohortEpoch = H(sorted members)`) changed since the last publish i.e. on **any** member change,
80
+ * head or tail or on the first call; otherwise returns `undefined`. Pass `rotation` to attach a
81
+ * predecessor-cohort rotation attestation (an epoch rotation); omit it for the default non-rotation
82
+ * publish (no rotation fields are emitted).
81
83
  */
82
84
  onStabilized(snapshot: CohortSnapshot, now: number, rotation?: RotationAttestation): Promise<MembershipCertV1 | undefined>;
83
85
  /**
@@ -91,26 +93,33 @@ export interface MembershipCertPublisherDeps {
91
93
  signer: CohortSigner;
92
94
  sink: IMembershipPublishSink;
93
95
  refreshMs?: number;
96
+ /**
97
+ * Accepted for call-site compatibility but no longer read by the publisher: the republish gate now keys
98
+ * on the full `cohortEpoch`, not the first `k − x` members, and the threshold-sign quorum lives in the
99
+ * {@link CohortSigner}. Kept so existing callers (host + db-p2p tests) compile unchanged.
100
+ */
94
101
  minSigs?: number;
95
102
  maxMessageBytes?: number;
96
103
  }
97
104
 
98
105
  class SigningMembershipCertPublisher implements MembershipCertPublisher {
99
106
  private readonly refreshMs: number;
100
- private readonly minSigs: number;
101
- /** base64url of the first `minSigs` members of the last published snapshot. */
102
- private lastFirstKx: string[] | undefined;
107
+ /** base64url of the last published snapshot's `cohortEpoch` — the full cohort-identity republish key. */
108
+ private lastEpoch: string | undefined;
103
109
  private lastPublishedAt: number | undefined;
104
110
 
105
111
  constructor(private readonly deps: MembershipCertPublisherDeps) {
106
112
  this.refreshMs = deps.refreshMs ?? DEFAULT_T_MEMBERSHIP_REFRESH_MS;
107
- this.minSigs = deps.minSigs ?? DEFAULT_MIN_SIGS;
108
113
  }
109
114
 
110
115
  async onStabilized(snapshot: CohortSnapshot, now: number, rotation?: RotationAttestation): Promise<MembershipCertV1 | undefined> {
111
- const firstKx = this.firstKx(snapshot.members);
112
- if (this.lastFirstKx !== undefined && sameOrder(this.lastFirstKx, firstKx)) {
113
- return undefined; // first k x unchanged no republish needed
116
+ // NOTE: republish (and the caller's rotation attestation) now fire on ANY epoch change — any member
117
+ // change, head or tail — not just first-(k − x) churn. Gated on an actual epoch change, never per
118
+ // tick, so cost is bounded by real churn. If a high-churn cohort ever shows excess `/sign` or publish
119
+ // load, reconsider a short debounce / batching here.
120
+ const epoch = bytesToB64url(snapshot.cohortEpoch);
121
+ if (this.lastEpoch !== undefined && this.lastEpoch === epoch) {
122
+ return undefined; // cohort identity unchanged — no republish needed
114
123
  }
115
124
  return this.publish(snapshot, now, rotation);
116
125
  }
@@ -122,11 +131,6 @@ class SigningMembershipCertPublisher implements MembershipCertPublisher {
122
131
  return this.publish(snapshot, now, rotation);
123
132
  }
124
133
 
125
- /** The first `minSigs` members of the cert's ascending order, base64url — the republish-change key. */
126
- private firstKx(members: readonly Uint8Array[]): string[] {
127
- return [...members].sort(compareBytes).slice(0, this.minSigs).map(bytesToB64url);
128
- }
129
-
130
134
  private async publish(snapshot: CohortSnapshot, now: number, rotation?: RotationAttestation): Promise<MembershipCertV1> {
131
135
  const signable = membershipCertSignable(snapshot);
132
136
  const { thresholdSig, signers } = await this.deps.signer.thresholdSign(membershipCertSigningPayload(signable));
@@ -147,24 +151,12 @@ class SigningMembershipCertPublisher implements MembershipCertPublisher {
147
151
  cert.rotationSigners = rotation.rotationSigners.map(bytesToB64url);
148
152
  }
149
153
  this.deps.sink.publish(encodeCohortMessage(cert, this.deps.maxMessageBytes));
150
- this.lastFirstKx = signable.members.slice(0, this.minSigs);
154
+ this.lastEpoch = bytesToB64url(snapshot.cohortEpoch);
151
155
  this.lastPublishedAt = now;
152
156
  return cert;
153
157
  }
154
158
  }
155
159
 
156
- function sameOrder(a: readonly string[], b: readonly string[]): boolean {
157
- if (a.length !== b.length) {
158
- return false;
159
- }
160
- for (let i = 0; i < a.length; i++) {
161
- if (a[i] !== b[i]) {
162
- return false;
163
- }
164
- }
165
- return true;
166
- }
167
-
168
160
  /** Build a cohort-side {@link MembershipCertPublisher}. */
169
161
  export function createMembershipCertPublisher(deps: MembershipCertPublisherDeps): MembershipCertPublisher {
170
162
  return new SigningMembershipCertPublisher(deps);
@@ -52,6 +52,15 @@ import type { MembershipCertV1 } from "../wire/types.js";
52
52
  import { DEFAULT_MIN_SIGS, type CohortSigner } from "../sig/threshold.js";
53
53
  import { membershipCertSigningPayload } from "../sig/payloads.js";
54
54
  import type { IMembershipSourceRouter } from "./source.js";
55
+ import { LruMap } from "../../utility/lru-map.js";
56
+
57
+ /**
58
+ * Default hard cap on distinct coords the verifier's per-coord maps retain (`byCoord`, `lastFetchAt`,
59
+ * `staleGapStrikes`). Beyond it the least-recently-used coord is evicted — bounding memory under a flood
60
+ * of verify-misses against attacker-chosen coords. Same 100k ballpark as the sibling anti-DoS caps
61
+ * ({@link import("../antidos/replay-guard.js").DEFAULT_REPLAY_GUARD_MAX_KEYS}).
62
+ */
63
+ export const DEFAULT_MEMBERSHIP_VERIFIER_MAX_COORDS = 100_000;
55
64
 
56
65
  /** Outcome of verifying a threshold-signed message against cohort membership. */
57
66
  export type VerifyResult = "verified" | "untrusted";
@@ -99,6 +108,34 @@ export interface MembershipVerifierDeps {
99
108
  anchor?: IMembershipTrustAnchor;
100
109
  /** Out-of-band-seeded genesis trust roots (the base case of every attestation chain). Defaults to `[]`. */
101
110
  trustRoots?: readonly TrustRoot[];
111
+ /**
112
+ * Number of **consecutive** gap-signalled refetches after which a **trust-locked** coord whose direct
113
+ * anchor has gone `"unknown"` re-enters the interim TOFU regime — the exit from a stale trust-lock a
114
+ * former cohort member would otherwise be stranded in until the host process restarts.
115
+ *
116
+ * The lock (a coord holding a *trusted* cached cert refuses any un-anchored refetch — no TOFU downgrade)
117
+ * has no other exit: a node that served coord `C`, self-published its cert (locking `C`), then left `C`'s
118
+ * cohort keeps distrusting every later-epoch message from `C` if it missed an intermediate rotation, since
119
+ * the refetched cert's `prevEpoch` no longer matches the stale cached epoch and the anchor no longer
120
+ * vouches for `C`. Recovery counts **only** refetched certs presenting an *explicit chain gap* — a full
121
+ * rotation attestation whose `prevEpoch ≠` the cached trusted epoch (the network provably rotated past the
122
+ * cached epoch through an epoch this node never witnessed). A forged rotation off the *current* cached
123
+ * predecessor (`prevEpoch == cachedEpoch`) never counts as a strike, so the lock's headline invariant
124
+ * (un-anchored successor of a matching predecessor stays rejected) is preserved.
125
+ *
126
+ * Defaults to `3`. Setting it to `0` (or a negative) **disables** recovery — which re-opens the stale-lock
127
+ * liveness bug, so leave it on unless a caller has an independent lock-drop mechanism (see the
128
+ * drop-the-lock-on-demotion tripwire in the ticket).
129
+ */
130
+ staleGapRecoveryStrikes?: number;
131
+ /**
132
+ * Hard LRU cap on distinct coords retained across the verifier's per-coord maps (`byCoord`,
133
+ * `lastFetchAt`, `staleGapStrikes`); the least-recently-used coord is evicted beyond it. Bounds memory
134
+ * under a flood of verify-misses against attacker-chosen coords. Defaults to
135
+ * {@link DEFAULT_MEMBERSHIP_VERIFIER_MAX_COORDS}; must be a positive integer (mirrors the sibling
136
+ * anti-DoS `maxKeys` guard).
137
+ */
138
+ maxCoords?: number;
102
139
  }
103
140
 
104
141
  /** Result of the trust gate: accept as a trusted anchor, accept as interim TOFU, or reject outright. */
@@ -119,17 +156,40 @@ interface NormalizedTrustRoot {
119
156
  }
120
157
 
121
158
  class CachingMembershipVerifier implements MembershipVerifier {
122
- private readonly byCoord = new Map<string, CachedCert>();
159
+ // LRU-capped so a flood of verify-misses against attacker-chosen coords cannot grow these maps without
160
+ // bound (coordKey is base64url of an attacker-derivable RingCoord). Each is capped INDEPENDENTLY: the
161
+ // aux maps may briefly retain a coord `byCoord` already evicted — harmless (a stale `lastFetchAt` at
162
+ // worst permits one extra refetch; a stale strike count is itself bounded).
163
+ // NOTE: `byCoord` also holds this node's OWN `cache()`-published trusted cert (the trust lock). Under an
164
+ // attacker-coord flood the LRU can evict a trusted self-published entry, re-opening that coord to TOFU on
165
+ // next sight — the trust lock is best-effort under memory pressure. This mirrors the documented penalty
166
+ // tradeoff on the replay-guard cap and is acceptable; do not file it as a separate ticket.
167
+ private readonly byCoord: LruMap<string, CachedCert>;
123
168
  /** Per-coord timestamp of the last `source.fetch()` attempt (the rate-limit clock for {@link RefetchBound}). */
124
- private readonly lastFetchAt = new Map<string, number>();
169
+ private readonly lastFetchAt: LruMap<string, number>;
170
+ /**
171
+ * Per-coord count of *consecutive* gap-signalled refetches against a trust-locked coord (base64url key).
172
+ * Reset to zero whenever a message verifies for the coord (see {@link verifyMessage}); at
173
+ * {@link staleGapRecoveryStrikes} the lock is released back to TOFU (see {@link staleGapRecovery}).
174
+ */
175
+ private readonly staleGapStrikes: LruMap<string, number>;
125
176
  private readonly minSigs: number;
126
177
  private readonly anchor: IMembershipTrustAnchor;
127
178
  private readonly trustRoots: readonly NormalizedTrustRoot[];
179
+ private readonly staleGapRecoveryStrikes: number;
128
180
 
129
181
  constructor(private readonly deps: MembershipVerifierDeps) {
130
182
  this.minSigs = deps.minSigs ?? DEFAULT_MIN_SIGS;
131
183
  this.anchor = deps.anchor ?? noAuthorityTrustAnchor;
132
184
  this.trustRoots = (deps.trustRoots ?? []).map(normalizeTrustRoot);
185
+ this.staleGapRecoveryStrikes = deps.staleGapRecoveryStrikes ?? 3;
186
+ const maxCoords = deps.maxCoords ?? DEFAULT_MEMBERSHIP_VERIFIER_MAX_COORDS;
187
+ if (!Number.isInteger(maxCoords) || maxCoords <= 0) {
188
+ throw new RangeError(`maxCoords must be a positive integer, got ${maxCoords}`);
189
+ }
190
+ this.byCoord = new LruMap(maxCoords);
191
+ this.lastFetchAt = new LruMap(maxCoords);
192
+ this.staleGapStrikes = new LruMap(maxCoords);
133
193
  }
134
194
 
135
195
  cache(cert: MembershipCertV1): void {
@@ -149,6 +209,7 @@ class CachingMembershipVerifier implements MembershipVerifier {
149
209
  cert = await this.loadFrom(source.current(expectedCoord), tier);
150
210
  }
151
211
  if (cert !== undefined && this.messageVerifies(cert, signers, payload, sig)) {
212
+ this.staleGapStrikes.delete(coordKey); // a verify resets the consecutive stale-gap strike count
152
213
  return "verified";
153
214
  }
154
215
 
@@ -162,6 +223,7 @@ class CachingMembershipVerifier implements MembershipVerifier {
162
223
  }
163
224
  const refreshed = await this.loadFrom(source.fetch(expectedCoord), tier);
164
225
  if (refreshed !== undefined && this.messageVerifies(refreshed, signers, payload, sig)) {
226
+ this.staleGapStrikes.delete(coordKey); // a verify resets the consecutive stale-gap strike count
165
227
  return "verified";
166
228
  }
167
229
  return "untrusted";
@@ -242,7 +304,61 @@ class CachingMembershipVerifier implements MembershipVerifier {
242
304
  if (this.hasRotationAttestation(cert) && this.chainGrantsTrust(cert)) {
243
305
  return "trusted";
244
306
  }
245
- return this.fallbackTrust(cert);
307
+ const fallback = this.fallbackTrust(cert);
308
+ if (fallback !== "reject") {
309
+ return fallback; // first-use TOFU (coord not locked): no stale lock to recover from
310
+ }
311
+ // fallback === "reject" ⟺ the coord is trust-locked (holds a *trusted* cert) and this un-anchored cert
312
+ // did not chain-verify — the state that strands a former cohort member forever. Consult the stale-gap
313
+ // recovery counter, which releases the lock only on a demonstrated chain gap (never a forged rotation
314
+ // off the current predecessor). See {@link staleGapRecovery}.
315
+ return this.staleGapRecovery(cert);
316
+ }
317
+
318
+ /**
319
+ * Bounded re-TOFU recovery for a coord that is **trust-locked at a stale epoch it can no longer anchor**.
320
+ * Reached from {@link certIsTrusted} only when the coord already holds a *trusted* cached cert (locked),
321
+ * the direct anchor said `"unknown"`, and `cert` did not chain-verify — i.e. {@link fallbackTrust} would
322
+ * otherwise reject it forever. Runs on the *refetch* load only: a locked coord always holds a cached cert,
323
+ * so `verifyMessage` never routes it through the `source.current()` seed path, and the strike logic keys on
324
+ * "coord is locked", so `current()` never accrues a strike.
325
+ *
326
+ * NOTE: recovery fires **only** on a demonstrated chain gap — a full rotation attestation whose
327
+ * `prevEpoch ≠` the cached trusted epoch (proof the network rotated past the cached epoch through at least
328
+ * one epoch this node never witnessed). A forged rotation off the *current* cached predecessor
329
+ * (`prevEpoch == cachedEpoch`) is NOT a gap, never counts as a strike, and stays rejected no matter how
330
+ * often it is presented — that is the lock's headline invariant. After
331
+ * {@link staleGapRecoveryStrikes} *consecutive* gap-signalled strikes the lock is released back to TOFU
332
+ * (returns `"tofu"`, so {@link loadFrom} re-caches the cert as **untrusted** — a re-TOFU'd cert must never
333
+ * launder trust into a rotation), which is no weaker than the documented TOFU baseline: a former member
334
+ * returns to the same regime a never-member is already in. Strikes accrue only on refetches that actually
335
+ * reach the source, so a {@link RefetchBound}-suppressed refetch observes no cert and recovery paces itself
336
+ * with the (bounded) refetch rate — intended, do not "fix" that pacing.
337
+ */
338
+ private staleGapRecovery(cert: MembershipCertV1): CertTrust {
339
+ const coordKey = cert.cohortCoord;
340
+ const locked = this.byCoord.get(coordKey);
341
+ // Recovery-eligible only on an explicit rotation gap: a full attestation whose prevEpoch is neither the
342
+ // cert's own epoch (a self-referential rotation) nor the cached trusted epoch (a forgery off the current
343
+ // predecessor — the case the lock exists to reject).
344
+ const isGap =
345
+ this.staleGapRecoveryStrikes > 0 &&
346
+ locked !== undefined &&
347
+ this.hasRotationAttestation(cert) &&
348
+ cert.prevEpoch !== cert.cohortEpoch &&
349
+ cert.prevEpoch !== locked.cert.cohortEpoch;
350
+ if (!isGap) {
351
+ return "reject"; // not a recovery-eligible gap → stay locked, exactly as before
352
+ }
353
+ const strikes = (this.staleGapStrikes.get(coordKey) ?? 0) + 1;
354
+ if (strikes < this.staleGapRecoveryStrikes) {
355
+ this.staleGapStrikes.set(coordKey, strikes);
356
+ return "reject"; // below threshold: keep rejecting; the inbound message stays untrusted
357
+ }
358
+ // Threshold reached: release the lock. `loadFrom` re-caches this cert as untrusted (`trusted: false`),
359
+ // and the message-verify retry runs against it, so the inbound later-epoch message finally verifies.
360
+ this.staleGapStrikes.delete(coordKey);
361
+ return "tofu";
246
362
  }
247
363
 
248
364
  /**
@@ -124,6 +124,13 @@ export interface PromotionDeps {
124
124
  treeTier: (topicId: Uint8Array) => number;
125
125
  /** The tier-`(d − 1)` parent cohort coord, target of a {@link DemotionNoticeV1}. */
126
126
  parentCoord: (topicId: Uint8Array) => Uint8Array;
127
+ /**
128
+ * The served coord `coord_d(participantCoord, topicId)` this cohort sits at (raw bytes) — stamped on
129
+ * every notice as `cohortCoord` and covered by its threshold signature, so a receiver routes the notice
130
+ * to exactly this cohort's engine and verifies it against exactly this cohort's cert. Constant for the
131
+ * engine's lifetime (the engine is instantiated at one served coord).
132
+ */
133
+ cohortCoord: () => Uint8Array;
127
134
  /** Cohort epoch (raw bytes) the notices are signed under. */
128
135
  cohortEpoch: () => Uint8Array;
129
136
  /** Threshold signer (the gossip ticket's `k − x` cohort signer). */
@@ -281,9 +288,13 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
281
288
 
282
289
  private async promote(topicId: Uint8Array, state: PromotionState, now: number): Promise<PromotionNoticeV1> {
283
290
  const fromTier = this.deps.treeTier(topicId);
291
+ // NOTE: no guard here against fromTier === DEFAULT_D_MAX_CAP (60). At that depth toTier = 61 exceeds the
292
+ // tree-tier ceiling, so validatePromotionNoticeV1 rejects the notice on every receiver. Unreachable today
293
+ // (tree tier 60 is pathological); if the tree can ever reach the cap, gate promotion below the cap here.
284
294
  const topicB64 = bytesToB64url(topicId);
285
295
  const epochB64 = bytesToB64url(this.deps.cohortEpoch());
286
- const signable = { topicId: topicB64, fromTier, toTier: fromTier + 1, effectiveAt: now, cohortEpoch: epochB64 };
296
+ const cohortB64 = bytesToB64url(this.deps.cohortCoord());
297
+ const signable = { topicId: topicB64, fromTier, toTier: fromTier + 1, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
287
298
  const { thresholdSig, signers } = await this.deps.signer.thresholdSign(promotionNoticeSigningPayload(signable));
288
299
  state.promoted = true;
289
300
  state.promotedAt = now;
@@ -293,6 +304,7 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
293
304
  topicId: topicB64,
294
305
  fromTier,
295
306
  toTier: fromTier + 1,
307
+ cohortCoord: cohortB64,
296
308
  effectiveAt: now,
297
309
  thresholdSig: bytesToB64url(thresholdSig),
298
310
  signers: signers.map(bytesToB64url),
@@ -329,10 +341,18 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
329
341
  const topicB64 = bytesToB64url(topicId);
330
342
  const epochB64 = bytesToB64url(this.deps.cohortEpoch());
331
343
  const parentB64 = bytesToB64url(this.deps.parentCoord(topicId));
332
- const signable = { topicId: topicB64, tier, parentCohortCoord: parentB64, effectiveAt: now, cohortEpoch: epochB64 };
344
+ const cohortB64 = bytesToB64url(this.deps.cohortCoord());
345
+ const signable = { topicId: topicB64, tier, parentCohortCoord: parentB64, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
333
346
  const { thresholdSig, signers } = await this.deps.signer.thresholdSign(demotionNoticeSigningPayload(signable));
334
347
  // Release forwarder state: a demoted cohort leaves promoted mode and resets its clocks so a
335
348
  // later re-growth re-evaluates cleanly.
349
+ // NOTE: demotion currently resets only the promoted-bounce clocks; it does NOT stop the node serving
350
+ // the topic — the cold-start forwarder, the direct-participant records, and the budget slot all stay,
351
+ // and the cohort keeps serving at tier d. If demotion is ever made to actually collapse local tier
352
+ // state, it must reassign or drain the topic's records FIRST, then `coldStart.remove` + release the
353
+ // budget slot + `traffic.forget`, in that order — removing the forwarder while records remain re-creates
354
+ // the off-budget-serving drift fixed in `cohort-topic-coldstart-forwarder-reconcile` (the budget stops
355
+ // bounding the served-topic set). The eviction path already reconciles all three via `TopicBudget.onEvict`.
336
356
  state.promoted = false;
337
357
  state.promotedAt = undefined;
338
358
  state.lowLoadSince = undefined;
@@ -342,6 +362,7 @@ class CohortPromotionLifecycle implements PromotionLifecycle {
342
362
  topicId: topicB64,
343
363
  tier,
344
364
  parentCohortCoord: parentB64,
365
+ cohortCoord: cohortB64,
345
366
  effectiveAt: now,
346
367
  thresholdSig: bytesToB64url(thresholdSig),
347
368
  signers: signers.map(bytesToB64url),
@@ -20,6 +20,7 @@
20
20
  * in isolation; db-core never imports FRET or libp2p here.
21
21
  */
22
22
 
23
+ import { DEFAULT_REPLAY_MAX_AGE_MS, DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS } from "../antidos/replay-guard.js";
23
24
  import { b64urlToBytes } from "../wire/codec.js";
24
25
  import type { RenewReplyV1, RenewV1 } from "../wire/types.js";
25
26
  import { bytesEqual, bytesKey, recordKey } from "./bytes.js";
@@ -280,6 +281,21 @@ export interface RenewalCohortSideDeps {
280
281
  * only touch `lastPing`).
281
282
  */
282
283
  verifyParticipantSig?: (renew: RenewV1) => boolean;
284
+ /**
285
+ * Optional freshness window for the two privileged, participant-attested paths (`withdraw` eviction and
286
+ * `reattach` promotion). The renew signature already binds `timestamp` (see
287
+ * {@link import("../wire/payloads.js").renewSigningPayload}), so an attacker cannot forge a fresher
288
+ * timestamp onto a captured frame — replay is exact-frame only. A timestamp gate is therefore a
289
+ * complete freshness regime for these frames: it rejects a stale/implausibly-future `timestamp`, and a
290
+ * per-record monotonic check (`timestamp <= rec.lastPing`) closes the sub-`maxAge` fast-replay window
291
+ * using state already on the record. Defaults to the register-path skew constants
292
+ * ({@link DEFAULT_REPLAY_MAX_AGE_MS} / {@link DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS}); db-p2p wires the same
293
+ * `{ maxAgeMs, maxFutureSkewMs }` config the register path's replay guard consumes so an operator tuning
294
+ * the skew window moves both paths together. Absent → defaults (key-less unit tests and callers work
295
+ * unchanged, matching how {@link verifyParticipantSig} is optional). Plain pings are NOT gated (a
296
+ * replayed ping is low-harm — it only re-touches `lastPing`).
297
+ */
298
+ freshness?: { maxAgeMs?: number; maxFutureSkewMs?: number };
283
299
  }
284
300
 
285
301
  /** Cohort-side TTL handling: touch on renew, redirect on rotation, sweep stale records. */
@@ -300,7 +316,45 @@ class StoreRenewalCohortSide implements RenewalCohortSide {
300
316
  */
301
317
  private readonly failoverServing = new Map<string, Uint8Array>();
302
318
 
303
- constructor(private readonly deps: RenewalCohortSideDeps) {}
319
+ /** Resolved staleness window / forward-skew tolerance for the privileged freshness gate. */
320
+ private readonly maxAgeMs: number;
321
+ private readonly maxFutureSkewMs: number;
322
+
323
+ constructor(private readonly deps: RenewalCohortSideDeps) {
324
+ this.maxAgeMs = deps.freshness?.maxAgeMs ?? DEFAULT_REPLAY_MAX_AGE_MS;
325
+ this.maxFutureSkewMs = deps.freshness?.maxFutureSkewMs ?? DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS;
326
+ }
327
+
328
+ /**
329
+ * Freshness gate for the two privileged, participant-attested branches (`withdraw`, `reattach`). Returns
330
+ * `false` — reject — when `msg.timestamp` is stale (older than `now − maxAgeMs`), implausibly future
331
+ * (newer than `now + maxFutureSkewMs`), or not strictly newer than the record's `lastPing`. The signed
332
+ * timestamp is immutable to a replayer, so this fully bounds a captured privileged frame: the skew
333
+ * window catches an old capture, and the `<= rec.lastPing` monotonic check catches a fast replay that
334
+ * still fits inside the window (e.g. a `withdraw` captured at `t0` replayed after the record was
335
+ * re-registered at `t_reregister > t0`, or a `reattach` replayed after its own accepted re-stamp
336
+ * already advanced `lastPing`).
337
+ */
338
+ private isFreshPrivileged(msg: RenewV1, rec: RegistrationRecord, now: number): boolean {
339
+ if (msg.timestamp < now - this.maxAgeMs) {
340
+ return false; // stale
341
+ }
342
+ if (msg.timestamp > now + this.maxFutureSkewMs) {
343
+ return false; // implausibly future
344
+ }
345
+ // NOTE: condition (3) compares a participant-supplied `msg.timestamp` against a server-maintained
346
+ // `rec.lastPing` (set from whichever cohort member last touched the record, using its own clock) — two
347
+ // different machines' clocks. In normal operation a genuine leave/failover post-dates the last ping, so
348
+ // `timestamp > lastPing` holds. If a participant's clock lags the server that set `lastPing`, a
349
+ // legitimate reattach can be rejected; the failure is soft (returns `primary_moved`, so the failover
350
+ // loop tries the next backup / re-runs the d_max lookup — a delayed failover, not data loss). If
351
+ // cross-node skew is ever observed to stall failovers, relax (3) to strict `<` (accept `timestamp ==
352
+ // lastPing`); the `maxAge` window still backstops the replay-after-re-registration attack.
353
+ if (msg.timestamp <= rec.lastPing) {
354
+ return false; // replay / non-monotonic against the live record
355
+ }
356
+ return true;
357
+ }
304
358
 
305
359
  onRenew(msg: RenewV1, now: number): RenewReplyV1 {
306
360
  const topicId = b64(msg.topicId);
@@ -321,6 +375,13 @@ class StoreRenewalCohortSide implements RenewalCohortSide {
321
375
  if (this.deps.verifyParticipantSig?.(msg) === false) {
322
376
  return { v: 1, result: "unknown_registration" };
323
377
  }
378
+ // Freshness gate: a signed withdraw is valid forever without this, so a captured one could be
379
+ // replayed after the victim's record TTL-expires and re-registers, evicting the *fresh* record.
380
+ // Reject a stale/replayed frame with the same opaque `unknown_registration` the forged-sig branch
381
+ // returns above — indistinguishable from an untrusted frame — and never delete.
382
+ if (!this.isFreshPrivileged(msg, rec, now)) {
383
+ return { v: 1, result: "unknown_registration" };
384
+ }
324
385
  this.deps.store.delete(topicId, participantId);
325
386
  this.failoverServing.delete(key); // mirror sweepStale: drop any crash-failover override
326
387
  this.deps.gossip.evicted(rec);
@@ -338,6 +399,12 @@ class StoreRenewalCohortSide implements RenewalCohortSide {
338
399
  if (this.deps.verifyParticipantSig?.(msg) === false) {
339
400
  return this.primaryMoved(primary, backups, cohortEpoch);
340
401
  }
402
+ // Freshness gate: a signed reattach is valid forever without this, so a captured one could be
403
+ // replayed to force bogus primary re-stamps. Reject a stale/replayed frame with the same redirect
404
+ // the forged-sig branch returns above — revealing nothing — and never promote.
405
+ if (!this.isFreshPrivileged(msg, rec, now)) {
406
+ return this.primaryMoved(primary, backups, cohortEpoch);
407
+ }
341
408
  if (bytesEqual(primary, self)) {
342
409
  // A rotation already made this member the computed primary; serve, no override needed.
343
410
  return this.touchAndServe(rec, now);
@@ -365,6 +432,11 @@ class StoreRenewalCohortSide implements RenewalCohortSide {
365
432
  }
366
433
 
367
434
  // Plain ping.
435
+ // NOTE: plain pings are deliberately NOT run through the privileged freshness gate (isFreshPrivileged).
436
+ // A replayed ping is low-harm — it can only re-touch a record's `lastPing`, never delete or usurp — and
437
+ // the strict `timestamp <= lastPing` monotonic check would risk rejecting a legitimate ping that arrives
438
+ // slightly out of order or under minor participant-clock non-monotonicity. If plain-ping replay ever
439
+ // becomes a concern (e.g. touch-driven traffic accounting is abused), gate it here too.
368
440
  const isComputedPrimary = bytesEqual(primary, self);
369
441
  const override = this.failoverServing.get(key);
370
442
  const overrideMatches = override !== undefined && bytesEqual(override, cohortEpoch);
@@ -67,9 +67,24 @@ export interface RegistrationStore {
67
67
  export const DEFAULT_TTL_MS = 90_000;
68
68
  /** Edge-tier default registration TTL (ms). */
69
69
  export const EDGE_TTL_MS = 60_000;
70
+ /** Minimum accepted registration TTL (ms). Requests below this are clamped up. */
71
+ export const MIN_TTL_MS = 10_000;
72
+ /** Maximum accepted registration TTL (ms). Requests above this are clamped down.
73
+ * 10 × DEFAULT_TTL_MS keeps the window predictable; prevents a wedged budget slot from a poison TTL. */
74
+ export const MAX_TTL_MS = 10 * DEFAULT_TTL_MS;
70
75
  /** Consecutive ping failures before a participant promotes `backups[0]`. */
71
76
  export const MAX_PING_FAILURES = 3;
72
77
 
78
+ /**
79
+ * Clamp a requested or replicated TTL into the accepted `[MIN_TTL_MS, MAX_TTL_MS]` window.
80
+ * Non-positive input falls to {@link DEFAULT_TTL_MS} first. This is the single TTL policy gate:
81
+ * both local admission (`accept()`) and gossip replication (`mergeRecords`) run every TTL through
82
+ * it, so no store — local or replica — can ever hold a record whose lifetime dodges the cap.
83
+ */
84
+ export function clampTtl(ttl: number): number {
85
+ return Math.min(Math.max(ttl > 0 ? ttl : DEFAULT_TTL_MS, MIN_TTL_MS), MAX_TTL_MS);
86
+ }
87
+
73
88
  /** `ping_interval = ttl / 3` (default 30s Core, 20s Edge), floored to whole ms. */
74
89
  export function pingIntervalMs(ttl: number): number {
75
90
  return Math.floor(ttl / 3);