@optimystic/db-core 0.14.1 → 0.16.3

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
@@ -154,9 +154,10 @@ export interface CohortTopicServiceDeps {
154
154
  /** Monotonic-ish wall clock (unix ms); injectable for tests. Default `Date.now`. */
155
155
  readonly clock?: () => number;
156
156
  /**
157
- * Optional cold-start bootstrap-evidence builder (db-p2p supplies it). Invoked **only** on the
158
- * bootstrap re-issue (when the walk sets `bootstrap: true`), with the register's own canonical
159
- * fields — the same `(topicId, tier, participantCoord, timestamp)` tuple a verifier binds via
157
+ * Optional cold-start bootstrap-evidence builder (db-p2p supplies it). Invoked on **either** cold-start
158
+ * re-issue the root `bootstrap: true` or the deeper-tier `followOn: true` (both gated by the same
159
+ * evidence policy) with the register's own canonical fields, the same
160
+ * `(topicId, tier, participantCoord, timestamp)` tuple a verifier binds via
160
161
  * `bootstrapBoundImage`. It returns the **raw** envelope JSON bytes — `utf8(JSON.stringify(env))`,
161
162
  * NOT the already-base64url string from `serializeBootstrapEvidenceEnvelope` — or `undefined` to
162
163
  * attach none; the service base64url-encodes those bytes into `RegisterV1.bootstrapEvidence`
@@ -230,9 +231,10 @@ class WalkRegisterService implements CohortTopicService {
230
231
  }
231
232
 
232
233
  async renew(handle: RegistrationHandle): Promise<void> {
233
- // Withdrawn handles drop out of the live set; renewing one is a no-op so withdraw() actually
234
- // stops the local ping loop (rather than the app silently continuing to refresh it).
235
- if (!this.renewals.has(recordKey(handle.topicId, this.participantId))) {
234
+ // Act only if this handle is still the live entry. A stale handle (superseded by a second
235
+ // register() for the same pair) must not drive the new registration's ping loop, and a
236
+ // withdrawn handle must not silently re-start it.
237
+ if (this.renewals.get(recordKey(handle.topicId, this.participantId)) !== handle.renewal) {
236
238
  return;
237
239
  }
238
240
  await handle.renewal.pingLoop();
@@ -246,11 +248,16 @@ class WalkRegisterService implements CohortTopicService {
246
248
  // The delete happens FIRST so a concurrent renew() already no-ops before the tombstone is sent. If
247
249
  // the tombstone send fails (primary unreachable), the cohort soft-state TTL-expires as the
248
250
  // fallback — withdraw never throws on a transport failure. Idempotent: a second withdraw finds
249
- // `renewal === undefined` and no-ops.
251
+ // `renewal !== handle.renewal` (undefined ≠ any object) and no-ops.
252
+ // Guard: act only if this handle is still the live entry. A stale handle must not evict the
253
+ // new registration or send a tombstone on its behalf.
250
254
  const key = recordKey(handle.topicId, this.participantId);
251
255
  const renewal = this.renewals.get(key);
256
+ if (renewal !== handle.renewal) {
257
+ return;
258
+ }
252
259
  this.renewals.delete(key);
253
- await renewal?.withdraw();
260
+ await renewal.withdraw();
254
261
  }
255
262
 
256
263
  cohortGossip(): CohortGossipBus {
@@ -268,11 +275,11 @@ class WalkRegisterService implements CohortTopicService {
268
275
  throw new CohortBackoffError(outcome.kind === "retry_later" ? outcome.afterMs : 0);
269
276
  }
270
277
  const hint = this.hintFromReply(req.topicId, req.tier, outcome.reply);
271
- const renewal = this.startRenewal(req, hint, outcome.reply);
278
+ const renewal = this.startRenewal(req, hint, outcome.correlationId);
272
279
  return { ...hint, renewal };
273
280
  }
274
281
 
275
- private startRenewal(req: RegisterRequest, hint: CohortHint, reply: RegisterReplyV1): RenewalParticipant {
282
+ private startRenewal(req: RegisterRequest, hint: CohortHint, correlationId: string): RenewalParticipant {
276
283
  const ttl = req.ttl ?? this.ttl;
277
284
  const initial: RegistrationRecord = {
278
285
  topicId: req.topicId,
@@ -290,9 +297,21 @@ class WalkRegisterService implements CohortTopicService {
290
297
  transport,
291
298
  clock: this.clock,
292
299
  sign: (body: UnsignedRenew): Promise<string> => this.deps.signer.signRenew(body),
293
- correlationId: reply.cohortEpoch ?? bytesToB64url(this.participantId),
300
+ // Echo the accepted RegisterV1's correlationId: RenewV1 `correlationId` "matches original
301
+ // RegisterV1" (docs §Wire, RenewV1), so a future renew-path freshness/replay guard can correlate
302
+ // a renew back to the registration it renews.
303
+ // NOTE: every periodic renew for this registration shares this ONE correlationId (as it always
304
+ // has — this only makes the shared id equal the register's, not an independent nonce). A future
305
+ // renew replay guard must therefore key renews by (correlationId, timestamp-window), NOT "drop
306
+ // any repeated correlationId" — else it would reject the 2nd+ legitimate renew.
307
+ correlationId,
294
308
  initialCohortEpoch: hint.cohortEpoch,
295
309
  });
310
+ // NOTE: a second register() for the same (topicId, participantId) overwrites the map entry here,
311
+ // orphaning the prior renewal. renew()/withdraw() on the stale handle now no-op (identity guard),
312
+ // but no tombstone is sent for the superseded record — the cohort frees it via TTL expiry. Harmless
313
+ // for occasional re-registers; if callers ever churn-register the same pair, send a withdraw for the
314
+ // displaced renewal here so the cohort reclaims immediately instead of holding up to a full TTL.
296
315
  this.renewals.set(recordKey(req.topicId, this.participantId), renewal);
297
316
  return renewal;
298
317
  }
@@ -351,12 +370,19 @@ class WalkRegisterService implements CohortTopicService {
351
370
  correlationId: this.freshCorrelationId(),
352
371
  };
353
372
  if (params.probe) {
354
- // A read-only lookup probe. Mutually exclusive with bootstrap (the walk never sets
355
- // `bootstrap` on a probe), so the cold-start-evidence branch below cannot run for a probe.
373
+ // A read-only lookup probe. Mutually exclusive with bootstrap/followOn (the walk never sets
374
+ // either on a probe), so the cold-start-evidence branch below cannot run for a probe.
356
375
  body.probe = true;
357
376
  }
358
- if (params.bootstrap) {
359
- body.bootstrap = true;
377
+ // A cold-start re-issue — root (`bootstrap`) or deeper-tier follow-on (`followOn`). Both are
378
+ // gated by the identical evidence policy (§Anti-DoS), so both mint and attach the same envelope
379
+ // bound to the body's own canonical fields. Mutually exclusive with each other and with probe.
380
+ if (params.bootstrap || params.followOn) {
381
+ if (params.followOn) {
382
+ body.followOn = true;
383
+ } else {
384
+ body.bootstrap = true;
385
+ }
360
386
  // Attach cold-start evidence BEFORE signing (so the signature covers it). Bind the
361
387
  // builder to the body's own canonical fields — the exact tuple a verifier reconstructs.
362
388
  const evidence = await this.deps.buildBootstrapEvidence?.({
@@ -3,14 +3,22 @@
3
3
  *
4
4
  * A threshold signature is only verifiable if signer and verifier agree on the exact bytes signed.
5
5
  * These builders produce that deterministic byte image for each threshold-signed message
6
- * (`MembershipCertV1`, `PromotionNoticeV1`, `DemotionNoticeV1`), covering exactly the semantic
7
- * fields per `docs/cohort-topic.md` §Wire formats — never the `thresholdSig`/`signers` envelope.
6
+ * (`MembershipCertV1`, `PromotionNoticeV1`, `DemotionNoticeV1`, `ChildLinkV1`), covering exactly the
7
+ * semantic fields per `docs/cohort-topic.md` §Wire formats — never the `thresholdSig`/`signers` envelope.
8
8
  *
9
9
  * Determinism comes from encoding an explicitly-ordered JSON array (array order is stable, unlike
10
10
  * object key order) as UTF-8. The publisher signs this image; the verifier recomputes and checks it.
11
+ *
12
+ * The notice images carry `cohortCoord` (the served coord the deciding cohort sits at) so the coord the
13
+ * receiver routes by is bound into the signed bytes — rewriting it to hijack a sibling cohort breaks
14
+ * verification. It is inserted **just before the trailing `cohortEpoch`**, deliberately keeping `cohortEpoch`
15
+ * the LAST element: the `/sign` endorser (`handleSignRequest`) reads a notice's embedded epoch positionally
16
+ * as `image[image.length - 1]`, so `cohortEpoch` must stay last. This is a hard, coordinated change to the
17
+ * canonical signed bytes, acceptable pre-release (no deployed nodes speak this protocol, so there is no
18
+ * version to negotiate).
11
19
  */
12
20
 
13
- import type { DemotionNoticeV1, MembershipCertV1, PromotionNoticeV1 } from "../wire/types.js";
21
+ import type { ChildLinkV1, DemotionNoticeV1, MembershipCertV1, PromotionNoticeV1 } from "../wire/types.js";
14
22
 
15
23
  const utf8 = new TextEncoder();
16
24
 
@@ -23,17 +31,29 @@ export function membershipCertSigningPayload(cert: MembershipCertSignable): Uint
23
31
  }
24
32
 
25
33
  /** Fields of a `PromotionNoticeV1` covered by its threshold signature. */
26
- export type PromotionSignable = Pick<PromotionNoticeV1, "topicId" | "fromTier" | "toTier" | "effectiveAt" | "cohortEpoch">;
34
+ export type PromotionSignable = Pick<PromotionNoticeV1, "topicId" | "fromTier" | "toTier" | "effectiveAt" | "cohortEpoch" | "cohortCoord">;
27
35
 
28
- /** Canonical signed byte image of a promotion notice. */
36
+ /** Canonical signed byte image of a promotion notice. `cohortEpoch` stays last (the `/sign` endorser reads it positionally). */
29
37
  export function promotionNoticeSigningPayload(n: PromotionSignable): Uint8Array {
30
- return utf8.encode(JSON.stringify(["PromotionNoticeV1", n.topicId, n.fromTier, n.toTier, n.effectiveAt, n.cohortEpoch]));
38
+ return utf8.encode(JSON.stringify(["PromotionNoticeV1", n.topicId, n.fromTier, n.toTier, n.effectiveAt, n.cohortCoord, n.cohortEpoch]));
31
39
  }
32
40
 
33
41
  /** Fields of a `DemotionNoticeV1` covered by its threshold signature. */
34
- export type DemotionSignable = Pick<DemotionNoticeV1, "topicId" | "tier" | "parentCohortCoord" | "effectiveAt" | "cohortEpoch">;
42
+ export type DemotionSignable = Pick<DemotionNoticeV1, "topicId" | "tier" | "parentCohortCoord" | "effectiveAt" | "cohortEpoch" | "cohortCoord">;
35
43
 
36
- /** Canonical signed byte image of a demotion notice. */
44
+ /** Canonical signed byte image of a demotion notice. `cohortEpoch` stays last (the `/sign` endorser reads it positionally). */
37
45
  export function demotionNoticeSigningPayload(n: DemotionSignable): Uint8Array {
38
- return utf8.encode(JSON.stringify(["DemotionNoticeV1", n.topicId, n.tier, n.parentCohortCoord, n.effectiveAt, n.cohortEpoch]));
46
+ return utf8.encode(JSON.stringify(["DemotionNoticeV1", n.topicId, n.tier, n.parentCohortCoord, n.effectiveAt, n.cohortCoord, n.cohortEpoch]));
47
+ }
48
+
49
+ /** Fields of a `ChildLinkV1` covered by the child cohort's threshold signature. */
50
+ export type ChildLinkSignable = Pick<ChildLinkV1, "topicId" | "childCohortCoord" | "childParticipantCoord" | "childTier" | "tier" | "effectiveAt" | "cohortEpoch">;
51
+
52
+ /**
53
+ * Canonical signed byte image of a child-link frame. `cohortEpoch` stays **last** so the `/sign` endorser
54
+ * (`handleSignRequest`) reads the embedded epoch positionally as `image[image.length - 1]`, exactly as it
55
+ * does for a promotion / demotion notice.
56
+ */
57
+ export function childLinkSigningPayload(n: ChildLinkSignable): Uint8Array {
58
+ return utf8.encode(JSON.stringify(["ChildLinkV1", n.topicId, n.childCohortCoord, n.childParticipantCoord, n.childTier, n.tier, n.effectiveAt, n.cohortEpoch]));
39
59
  }
@@ -70,6 +70,12 @@ export interface TrafficCounters {
70
70
  snapshot(topicId: Uint8Array): TopicTrafficV1;
71
71
  /** Reset all counters to zero (call on `cohortEpoch` change). */
72
72
  reset(): void;
73
+ /**
74
+ * Drop all local windowed counts and the last-published summary for `topicId` (the cohort no longer
75
+ * serves it — budget eviction / teardown). Idempotent; a no-op if never observed. Siblings' gossiped
76
+ * contributions age out of {@link snapshot} on their own as their summaries stop naming the topic.
77
+ */
78
+ forget(topicId: Uint8Array): void;
73
79
  }
74
80
 
75
81
  export interface TrafficCountersDeps {
@@ -128,7 +134,11 @@ class WindowedTrafficCounters implements TrafficCounters {
128
134
  let childCohortCount = 0;
129
135
  for (const [member, contribution] of this.deps.view.all()) {
130
136
  if (member === this.deps.selfMember) continue; // own contribution comes from lastPublished
131
- const summary = contribution.topicSummaries.find((s) => s.topicId === topicB64);
137
+ // O(1) per-topic lookup via the merge-time index; fall back to a scan only for a contribution
138
+ // not built through `merge` (which populates `topicIndex`). Net: snapshot is O(members + summaries).
139
+ const summary = contribution.topicIndex !== undefined
140
+ ? contribution.topicIndex.get(topicB64)
141
+ : contribution.topicSummaries.find((s) => s.topicId === topicB64);
132
142
  if (summary === undefined) continue;
133
143
  arrivals += summary.arrivalsPerMin;
134
144
  queries += summary.queriesPerMin;
@@ -136,6 +146,10 @@ class WindowedTrafficCounters implements TrafficCounters {
136
146
  childCohortCount = summary.childCohortCount;
137
147
  }
138
148
  }
149
+ // NOTE: `childCohortCount` (the max of siblings' gossiped counts above) is dormant whenever the
150
+ // registry override is wired — the override returns a number (0 included), so `?? childCohortCount`
151
+ // never falls through. It becomes the effective value only if the override is unwired; the child-set
152
+ // replication follow-on converges siblings by populating each engine's registry, not via this max.
139
153
  const childOverride = this.deps.childCohortCount?.(topicId);
140
154
  return {
141
155
  windowSeconds: this.windowMs / 1000,
@@ -151,6 +165,14 @@ class WindowedTrafficCounters implements TrafficCounters {
151
165
  this.lastPublished.clear();
152
166
  }
153
167
 
168
+ forget(topicId: Uint8Array): void {
169
+ // Idempotent: `Map.delete` on an absent key is a safe no-op. Clears both the raw windowed events
170
+ // and the frozen last-published summary so a re-instantiated topic starts from zero.
171
+ const key = bytesKey(topicId);
172
+ this.windows.delete(key);
173
+ this.lastPublished.delete(key);
174
+ }
175
+
154
176
  private windowFor(topicId: Uint8Array): TopicWindow {
155
177
  const key = bytesKey(topicId);
156
178
  let w = this.windows.get(key);
@@ -10,7 +10,12 @@
10
10
  * Reply handling (§Lookup):
11
11
  * - **`accepted`** → done; return the reply (carries `primary`/`backups`/`cohortEpoch` to cache).
12
12
  * - **`no_state`** at tier `d` → step one tier toward the root (`d − 1`); no traffic signal. At the
13
- * root (`d − 1 < 0`) re-issue once at tier 0 with `bootstrap: true` (cold-start request).
13
+ * root (`d − 1 < 0`) re-issue once at tier 0 with `bootstrap: true` (cold-start request). **If this
14
+ * `no_state` is the redirect target of a `Promoted` this walk just followed** (`followedPromoted`),
15
+ * the deeper child is cold: a register re-issues **once** at the same tier with `followOn: true` (a
16
+ * deeper-tier cold-start, gated by the same evidence a `bootstrap` pays), then backs off if that too
17
+ * returns `no_state`; a probe never instantiates, so it backs off immediately. This is what lets a
18
+ * join that lands on a freshly-promoted-but-not-yet-grown branch converge instead of oscillating.
14
19
  * - **`promoted(targetTier)`** → recompute `coord_targetTier(self, topicId)` and register there — the
15
20
  * one outward move, taken only on this explicit redirect.
16
21
  * - **`unwilling_member(candidates)`** → retry the **same** coord at a named alternative member
@@ -29,6 +34,7 @@
29
34
  import type { ITopicRouter, PeerRef } from "./ports.js";
30
35
  import type { TierAddressing } from "./addressing.js";
31
36
  import type { DMaxComputer } from "./dmax.js";
37
+ import { DEFAULT_D_MAX_CAP } from "./dmax.js";
32
38
  import { backoffRetryMs } from "./willingness.js";
33
39
  import { b64urlToBytes, decodeRegisterReplyV1, encodeCohortMessage } from "./wire/codec.js";
34
40
  import type { RegisterReplyV1, RegisterV1 } from "./wire/types.js";
@@ -37,6 +43,12 @@ import type { RegisterReplyV1, RegisterV1 } from "./wire/types.js";
37
43
  export interface AcceptedWalkOutcome {
38
44
  readonly kind: "accepted";
39
45
  readonly reply: RegisterReplyV1;
46
+ /**
47
+ * The accepted register probe's own `correlationId`. The participant echoes it on every renew for this
48
+ * registration so `RenewV1.correlationId` "matches original `RegisterV1`" (docs §Wire, RenewV1). Each
49
+ * probe carries a distinct correlationId; this is the one the cohort admitted.
50
+ */
51
+ readonly correlationId: string;
40
52
  }
41
53
 
42
54
  /**
@@ -65,18 +77,22 @@ export type WalkOutcome = AcceptedWalkOutcome | PromotedWalkOutcome | RetryLater
65
77
  /** Builds (and signs) the {@link RegisterV1} for one probe; owns participant identity + crypto. */
66
78
  export interface RegisterMessageFactory {
67
79
  /**
68
- * Produce a signed `RegisterV1` for this participant at walk position `treeTier`. `bootstrap` is
69
- * set only on the root cold-start re-issue. `appPayload` is the opaque application slot. On the
70
- * bootstrap re-issue the factory may additionally mint and attach the signed `bootstrapEvidence`
71
- * envelope (§Anti-DoS) via the injected builder seam before signing keyed off `bootstrap`, so no
72
- * extra build parameter is needed (the walk decides `bootstrap` internally, not the application).
80
+ * Produce a signed `RegisterV1` for this participant at walk position `treeTier`. `bootstrap` is set
81
+ * only on the root cold-start re-issue; `followOn` only on the dedicated re-issue after a `Promoted`
82
+ * redirect target answered `NoState` (§Cold-start). `appPayload` is the opaque application slot. On
83
+ * either cold-start re-issue the factory mints and attaches the signed `bootstrapEvidence` envelope
84
+ * (§Anti-DoS a follow-on is gated identically to a bootstrap) via the injected builder seam before
85
+ * signing — keyed off `bootstrap`/`followOn`, so no extra parameter is needed (the walk decides both
86
+ * internally, not the application). `bootstrap`, `followOn`, and `probe` are mutually exclusive.
73
87
  */
74
88
  build(params: {
75
89
  topicId: Uint8Array;
76
90
  tier: number;
77
91
  treeTier: number;
78
92
  bootstrap: boolean;
79
- /** Read-only lookup probe: the factory stamps `RegisterV1.probe` and never mints bootstrap evidence. */
93
+ /** Follow-on cold-start re-issue after a `Promoted` redirect target answered `NoState` (`treeTier >= 1`). */
94
+ followOn: boolean;
95
+ /** Read-only lookup probe: the factory stamps `RegisterV1.probe` and never mints cold-start evidence. */
80
96
  probe: boolean;
81
97
  appPayload?: Uint8Array;
82
98
  }): Promise<RegisterV1>;
@@ -128,6 +144,16 @@ export interface WalkEngine {
128
144
  register(topicId: Uint8Array, tier: number, appPayload?: Uint8Array, opts?: { probe?: boolean }): Promise<WalkOutcome>;
129
145
  }
130
146
 
147
+ /**
148
+ * A walk tier is valid iff it is an integer in `0..DEFAULT_D_MAX_CAP` — the substrate's own walk-depth
149
+ * ceiling. A `promoted` reply's explicit `targetTier` is untrusted: an out-of-range value (non-integer,
150
+ * negative, or above the ceiling) cannot name a real cohort and would reach `addressing.coord()` →
151
+ * `coordD`, which throws a raw `RangeError`. Matches the range the wire `treeTier` validator enforces.
152
+ */
153
+ function isValidTreeTier(value: number): boolean {
154
+ return Number.isInteger(value) && value >= 0 && value <= DEFAULT_D_MAX_CAP;
155
+ }
156
+
131
157
  class RouterWalkEngine implements WalkEngine {
132
158
  private readonly wantK: number;
133
159
  private readonly minSigs: number;
@@ -153,10 +179,19 @@ class RouterWalkEngine implements WalkEngine {
153
179
 
154
180
  let d = dMax;
155
181
  let bootstrap = false;
156
- let memberAttempts = 0;
182
+ let followOn = false;
183
+ // Member ids (base64url) already dialed on this walk's `unwilling_member` retries. Tracking WHICH
184
+ // members were tried — not a positional counter — lets each fresh candidate list be consumed from
185
+ // its best (index-0) member; a counter would permanently skip index 0 of every list after the first.
186
+ const triedMembers = new Set<string>();
157
187
  let dialTarget: PeerRef | undefined;
158
188
  let steps = 0;
159
- let probeFollowedPromoted = false;
189
+ // True once this walk has followed a `Promoted` redirect outward (either mode). On a subsequent
190
+ // `NoState` the redirect target is cold: a probe backs off (never instantiates), a register
191
+ // re-issues once with `followOn: true` to instantiate the child, then backs off.
192
+ let followedPromoted = false;
193
+ // True once the register path has spent its single `followOn: true` re-issue at the cold child.
194
+ let followOnReissued = false;
160
195
 
161
196
  for (;;) {
162
197
  if (++steps > maxSteps) {
@@ -165,7 +200,7 @@ class RouterWalkEngine implements WalkEngine {
165
200
  return { kind: "retry_later", afterMs: backoffRetryMs(0) };
166
201
  }
167
202
 
168
- const reg = await this.deps.factory.build({ topicId, tier, treeTier: d, bootstrap, probe, appPayload });
203
+ const reg = await this.deps.factory.build({ topicId, tier, treeTier: d, bootstrap, followOn, probe, appPayload });
169
204
  const activity = encodeCohortMessage(reg, this.maxMessageBytes);
170
205
  const raw = dialTarget !== undefined
171
206
  ? await this.deps.router.dialMember(dialTarget, activity)
@@ -177,16 +212,30 @@ class RouterWalkEngine implements WalkEngine {
177
212
 
178
213
  switch (reply.result) {
179
214
  case "accepted": {
180
- return { kind: "accepted", reply };
215
+ // Surface the accepted probe's correlationId so the participant's renewals can echo it
216
+ // (RenewV1 correlationId "matches original RegisterV1"). `reg` is the frame just admitted.
217
+ return { kind: "accepted", reply, correlationId: reg.correlationId };
181
218
  }
182
219
  case "no_state": {
183
220
  // Step toward the root. The cohort served nothing here; no spatial sibling state.
184
221
  dialTarget = undefined;
185
- memberAttempts = 0;
186
- if (probe && probeFollowedPromoted) {
187
- // The promoted child is cold (not yet instantiated). A probe never instantiates it,
188
- // so back off immediately walking inward to the promoting ancestor would just
189
- // re-trigger the Promoted redirect and loop.
222
+ triedMembers.clear(); // a spatial move to a new coord starts sibling retries fresh
223
+ if (followedPromoted) {
224
+ // The `Promoted` redirect target is cold (not yet instantiated). Walking inward to the
225
+ // promoting ancestor would just re-trigger the redirect and oscillate, so handle it here.
226
+ if (probe) {
227
+ // A probe never instantiates — back off immediately (mirror of the register re-issue).
228
+ return { kind: "retry_later", afterMs: backoffRetryMs(0) };
229
+ }
230
+ if (!followOnReissued) {
231
+ // Re-issue ONCE at the SAME child tier as a follow-on cold-start: RegisterV1{ followOn:
232
+ // true } + minted evidence. The mirror of the root NoState → bootstrap:true re-issue.
233
+ followOn = true;
234
+ followOnReissued = true;
235
+ break; // re-register at the same coord/tier, now carrying followOn
236
+ }
237
+ // The follow-on re-issue still got NoState → the cold child's quorum is unwilling to
238
+ // instantiate. Back off in time; do NOT loop inward.
190
239
  return { kind: "retry_later", afterMs: backoffRetryMs(0) };
191
240
  }
192
241
  const next = d - 1;
@@ -212,12 +261,30 @@ class RouterWalkEngine implements WalkEngine {
212
261
  }
213
262
  case "promoted": {
214
263
  dialTarget = undefined;
215
- memberAttempts = 0;
264
+ triedMembers.clear(); // spatial move to the redirect target: sibling retries start fresh
216
265
  bootstrap = false;
217
266
  const targetTier = reply.targetTier ?? d + 1;
218
- if (probe) {
219
- probeFollowedPromoted = true;
267
+ // The cohort names the tier to jump outward to. When it supplied `targetTier` EXPLICITLY it is
268
+ // untrusted: an out-of-range value would reach `coord()` → `coordD` and throw a raw RangeError,
269
+ // an unclassified crash out of register()/lookup() rather than a clean outcome. Bound it before
270
+ // BOTH adoption sites (the followPromoted-false surface below and the `d = targetTier` hop) and
271
+ // back off in time instead. Only the EXPLICIT attacker value is the hazard; the `d + 1`
272
+ // fallback is left unchecked because `d` is walk-bounded to `dMax + maxSteps` (≈190 under the
273
+ // default `maxSteps`), comfortably under coordD's 255 range.
274
+ // NOTE: `maxSteps` is operator-configurable — a value above ~195 plus an adversarial chain of
275
+ // no-`targetTier` `promoted` replies (each bumps `d` by +1) could push the `d + 1` fallback
276
+ // past coordD's range and reintroduce the RangeError. If maxSteps is ever raised that high,
277
+ // bound the fallback here too.
278
+ if (reply.targetTier !== undefined && !isValidTreeTier(targetTier)) {
279
+ return { kind: "retry_later", afterMs: backoffRetryMs(0) };
220
280
  }
281
+ // Following a (fresh) redirect: mark it, and reset the follow-on latch so a cold child at
282
+ // THIS target gets its own single follow-on re-issue. The honest flow re-registers the
283
+ // child with a PLAIN frame first (followOn false) and only escalates to followOn on its
284
+ // NoState — so clear the flag here; the NoState branch re-arms it.
285
+ followedPromoted = true;
286
+ followOn = false;
287
+ followOnReissued = false;
221
288
  if (!this.followPromoted) {
222
289
  return { kind: "promoted", targetTier };
223
290
  }
@@ -226,14 +293,18 @@ class RouterWalkEngine implements WalkEngine {
226
293
  }
227
294
  case "unwilling_member": {
228
295
  const candidates = reply.candidateMembers ?? [];
229
- if (candidates.length === 0 || memberAttempts >= this.maxMemberRetries) {
230
- // Exhausted the named siblings (or none offered) fall through to a cohort-level
231
- // temporal back-off, restarting at d_max on the caller's retry.
296
+ // Consume this (possibly fresh) list from its best (index-0) member: pick the FIRST
297
+ // candidate not already dialed on this walk. A positional `memberAttempts % len` offset
298
+ // would skip index 0 of every list after the first, permanently starving the best member.
299
+ const next = candidates.find((c) => !triedMembers.has(c));
300
+ if (next === undefined || triedMembers.size >= this.maxMemberRetries) {
301
+ // No untried candidate offered (or the retry cap is spent) → fall through to a
302
+ // cohort-level temporal back-off, restarting at d_max on the caller's retry.
232
303
  return { kind: "retry_later", afterMs: backoffRetryMs(0) };
233
304
  }
234
305
  // Retry the SAME coord at a named alternative member (spatial move within the cohort).
235
- dialTarget = { id: b64urlToBytes(candidates[memberAttempts % candidates.length]!) };
236
- memberAttempts++;
306
+ triedMembers.add(next);
307
+ dialTarget = { id: b64urlToBytes(next) };
237
308
  break;
238
309
  }
239
310
  case "unwilling_cohort": {
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { CohortWireError } from "./validate.js";
14
14
  import {
15
+ validateChildLinkReplyV1,
16
+ validateChildLinkV1,
15
17
  validateCohortGossipV1,
16
18
  validateDemotionNoticeV1,
17
19
  validateMembershipCertV1,
@@ -24,6 +26,8 @@ import {
24
26
  validateSignReplyV1,
25
27
  } from "./validate.js";
26
28
  import type {
29
+ ChildLinkReplyV1,
30
+ ChildLinkV1,
27
31
  CohortGossipV1,
28
32
  DemotionNoticeV1,
29
33
  MembershipCertV1,
@@ -179,6 +183,14 @@ export function decodeRenewReplyV1(bytes: Uint8Array, maxMessageBytes?: number):
179
183
  return validateRenewReplyV1(decodeCohortMessage(bytes, maxMessageBytes));
180
184
  }
181
185
 
186
+ export function decodeChildLinkV1(bytes: Uint8Array, maxMessageBytes?: number, minSigs?: number): ChildLinkV1 {
187
+ return validateChildLinkV1(decodeCohortMessage(bytes, maxMessageBytes), minSigs);
188
+ }
189
+
190
+ export function decodeChildLinkReplyV1(bytes: Uint8Array, maxMessageBytes?: number): ChildLinkReplyV1 {
191
+ return validateChildLinkReplyV1(decodeCohortMessage(bytes, maxMessageBytes));
192
+ }
193
+
182
194
  export function decodePromotionNoticeV1(bytes: Uint8Array, maxMessageBytes?: number): PromotionNoticeV1 {
183
195
  return validatePromotionNoticeV1(decodeCohortMessage(bytes, maxMessageBytes));
184
196
  }
@@ -7,6 +7,8 @@ export {
7
7
  validateRegisterReplyV1,
8
8
  validateRenewV1,
9
9
  validateRenewReplyV1,
10
+ validateChildLinkV1,
11
+ validateChildLinkReplyV1,
10
12
  validatePromotionNoticeV1,
11
13
  validateDemotionNoticeV1,
12
14
  validateCohortGossipV1,
@@ -10,9 +10,9 @@
10
10
  * for the threshold-signed notices, determinism comes from encoding an explicitly-ordered JSON
11
11
  * array (array order is stable, unlike object key order) as UTF-8 — never the `signature` envelope.
12
12
  * Optional fields are normalized to a fixed placeholder (`bootstrap`→`false`, `probe`→`false`,
13
- * `appPayload`→`null`, `bootstrapEvidence`→`null` with an empty string treated as absent,
14
- * `reattach`→`false`, `withdraw`→`false`) so an absent optional and a present-but-default optional can
15
- * never disagree across the wire round-trip.
13
+ * `followOn`→`false`, `appPayload`→`null`, `bootstrapEvidence`→`null` with an empty string treated as
14
+ * absent, `reattach`→`false`, `withdraw`→`false`) so an absent optional and a present-but-default
15
+ * optional can never disagree across the wire round-trip.
16
16
  */
17
17
 
18
18
  import type { CohortGossipV1, RegisterV1, RenewV1 } from "./types.js";
@@ -48,6 +48,11 @@ export function registerSigningPayload(body: RegisterSignable): Uint8Array {
48
48
  // A read-only probe signs `probe: true`; a normal register signs `false`. Both sides recompute
49
49
  // this image identically, so a probe and a register over the same fields produce distinct signatures.
50
50
  body.probe ?? false,
51
+ // A follow-on cold-start re-issue signs `followOn: true`, everything else `false`. Strictly appended
52
+ // (sibling to `probe`/`withdraw`) so a MITM cannot strip or flip the follow-on assertion and the
53
+ // signer (participant) + verifier (db-p2p cohort) agree byte-for-byte. Safe to append: no register
54
+ // signatures are persisted (each is recomputed per verify), so there is no cross-version image concern.
55
+ body.followOn ?? false,
51
56
  ]));
52
57
  }
53
58
 
@@ -79,7 +84,8 @@ export type CohortGossipSignable = Omit<CohortGossipV1, "signature">;
79
84
  * or replicate forged records). Like the other payload helpers, determinism comes from an
80
85
  * explicitly-ordered array — nested records/summaries/evictions are emitted as fixed ordered tuples so
81
86
  * the signer and the receiver (which re-derives this image from the validated frame) agree byte-for-byte.
82
- * Absent optionals (`records`/`evicted`) normalize to `[]`, and `appState` to `null`.
87
+ * Absent optionals (`records`/`evicted`/`childLinks`/`childUnlinks`) normalize to `[]`, and `appState` to
88
+ * `null`. `childLinks`/`childUnlinks` are covered so a MITM cannot strip or inject a child link/unlink.
83
89
  */
84
90
  export function cohortGossipSigningPayload(g: CohortGossipSignable): Uint8Array {
85
91
  return utf8.encode(JSON.stringify([
@@ -88,6 +94,7 @@ export function cohortGossipSigningPayload(g: CohortGossipSignable): Uint8Array
88
94
  g.fromMember,
89
95
  g.coord,
90
96
  g.cohortEpoch,
97
+ g.treeTier,
91
98
  g.willingnessBits,
92
99
  g.loadBuckets,
93
100
  g.windowSeconds,
@@ -111,7 +118,9 @@ export function cohortGossipSigningPayload(g: CohortGossipSignable): Uint8Array
111
118
  r.ttl,
112
119
  r.appState ?? null,
113
120
  ]),
114
- (g.evicted ?? []).map((e) => [e.topicId, e.participantId]),
121
+ (g.evicted ?? []).map((e) => [e.topicId, e.participantId, e.lastPing]),
122
+ (g.childLinks ?? []).map((c) => [c.topicId, c.childCohortCoord, c.effectiveAt]),
123
+ (g.childUnlinks ?? []).map((c) => [c.topicId, c.childCohortCoord, c.effectiveAt]),
115
124
  g.timestamp,
116
125
  ]));
117
126
  }