@optimystic/db-p2p 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 (275) hide show
  1. package/README.md +616 -609
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +4 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +52 -3
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +129 -6
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts.map +1 -1
  10. package/dist/src/cluster/client.js +3 -1
  11. package/dist/src/cluster/client.js.map +1 -1
  12. package/dist/src/cluster/cluster-repo.d.ts +182 -11
  13. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  14. package/dist/src/cluster/cluster-repo.js +429 -69
  15. package/dist/src/cluster/cluster-repo.js.map +1 -1
  16. package/dist/src/cluster/peer-key-binding.d.ts +25 -0
  17. package/dist/src/cluster/peer-key-binding.d.ts.map +1 -0
  18. package/dist/src/cluster/peer-key-binding.js +39 -0
  19. package/dist/src/cluster/peer-key-binding.js.map +1 -0
  20. package/dist/src/cluster/quorum-restore.d.ts +72 -0
  21. package/dist/src/cluster/quorum-restore.d.ts.map +1 -0
  22. package/dist/src/cluster/quorum-restore.js +0 -0
  23. package/dist/src/cluster/quorum-restore.js.map +1 -0
  24. package/dist/src/cluster/rebalance-monitor.d.ts +23 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  26. package/dist/src/cluster/rebalance-monitor.js +17 -2
  27. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  28. package/dist/src/cluster/service.d.ts.map +1 -1
  29. package/dist/src/cluster/service.js +7 -1
  30. package/dist/src/cluster/service.js.map +1 -1
  31. package/dist/src/cluster/spread-on-churn.d.ts +7 -0
  32. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  33. package/dist/src/cluster/spread-on-churn.js +4 -1
  34. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  35. package/dist/src/cluster/supermajority-coupling.d.ts +22 -0
  36. package/dist/src/cluster/supermajority-coupling.d.ts.map +1 -0
  37. package/dist/src/cluster/supermajority-coupling.js +27 -0
  38. package/dist/src/cluster/supermajority-coupling.js.map +1 -0
  39. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +46 -4
  40. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -1
  41. package/dist/src/cohort-topic/cohort-gossip-driver.js +62 -7
  42. package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -1
  43. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +7 -0
  44. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -1
  45. package/dist/src/cohort-topic/cohort-gossip-transport.js +9 -0
  46. package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -1
  47. package/dist/src/cohort-topic/host.d.ts +197 -27
  48. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  49. package/dist/src/cohort-topic/host.js +643 -87
  50. package/dist/src/cohort-topic/host.js.map +1 -1
  51. package/dist/src/dispute/arbitrator-selection.d.ts +68 -7
  52. package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -1
  53. package/dist/src/dispute/arbitrator-selection.js +105 -16
  54. package/dist/src/dispute/arbitrator-selection.js.map +1 -1
  55. package/dist/src/dispute/cascade.d.ts +7 -0
  56. package/dist/src/dispute/cascade.d.ts.map +1 -1
  57. package/dist/src/dispute/cascade.js +1 -1
  58. package/dist/src/dispute/cascade.js.map +1 -1
  59. package/dist/src/dispute/client.d.ts.map +1 -1
  60. package/dist/src/dispute/client.js +3 -2
  61. package/dist/src/dispute/client.js.map +1 -1
  62. package/dist/src/dispute/dispute-service.d.ts +32 -3
  63. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  64. package/dist/src/dispute/dispute-service.js +82 -9
  65. package/dist/src/dispute/dispute-service.js.map +1 -1
  66. package/dist/src/dispute/index.d.ts +1 -1
  67. package/dist/src/dispute/index.d.ts.map +1 -1
  68. package/dist/src/dispute/index.js +1 -1
  69. package/dist/src/dispute/index.js.map +1 -1
  70. package/dist/src/dispute/invalidation.d.ts +25 -0
  71. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  72. package/dist/src/dispute/invalidation.js +14 -2
  73. package/dist/src/dispute/invalidation.js.map +1 -1
  74. package/dist/src/dispute/service.d.ts.map +1 -1
  75. package/dist/src/dispute/service.js +7 -1
  76. package/dist/src/dispute/service.js.map +1 -1
  77. package/dist/src/index.d.ts +4 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/index.js +4 -1
  80. package/dist/src/index.js.map +1 -1
  81. package/dist/src/it-utility.d.ts +1 -1
  82. package/dist/src/it-utility.d.ts.map +1 -1
  83. package/dist/src/it-utility.js +4 -13
  84. package/dist/src/it-utility.js.map +1 -1
  85. package/dist/src/libp2p-key-network.d.ts +98 -1
  86. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  87. package/dist/src/libp2p-key-network.js +272 -17
  88. package/dist/src/libp2p-key-network.js.map +1 -1
  89. package/dist/src/libp2p-node-base.d.ts +12 -0
  90. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  91. package/dist/src/libp2p-node-base.js +415 -87
  92. package/dist/src/libp2p-node-base.js.map +1 -1
  93. package/dist/src/matchmaking/query-transport.d.ts +2 -0
  94. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  95. package/dist/src/matchmaking/query-transport.js +6 -0
  96. package/dist/src/matchmaking/query-transport.js.map +1 -1
  97. package/dist/src/matchmaking/traffic-validation.js +1 -0
  98. package/dist/src/matchmaking/traffic-validation.js.map +1 -1
  99. package/dist/src/network/network-manager-service.d.ts +2 -2
  100. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  101. package/dist/src/network/network-manager-service.js +8 -4
  102. package/dist/src/network/network-manager-service.js.map +1 -1
  103. package/dist/src/owned-block-seed.d.ts +27 -0
  104. package/dist/src/owned-block-seed.d.ts.map +1 -0
  105. package/dist/src/owned-block-seed.js +37 -0
  106. package/dist/src/owned-block-seed.js.map +1 -0
  107. package/dist/src/protocol-client.d.ts +1 -0
  108. package/dist/src/protocol-client.d.ts.map +1 -1
  109. package/dist/src/protocol-client.js +9 -2
  110. package/dist/src/protocol-client.js.map +1 -1
  111. package/dist/src/protocol-limits.d.ts +32 -0
  112. package/dist/src/protocol-limits.d.ts.map +1 -0
  113. package/dist/src/protocol-limits.js +32 -0
  114. package/dist/src/protocol-limits.js.map +1 -0
  115. package/dist/src/repo/client.d.ts.map +1 -1
  116. package/dist/src/repo/client.js +3 -0
  117. package/dist/src/repo/client.js.map +1 -1
  118. package/dist/src/repo/cluster-coordinator.d.ts +41 -4
  119. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  120. package/dist/src/repo/cluster-coordinator.js +128 -80
  121. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  122. package/dist/src/repo/coordinator-repo.d.ts +38 -1
  123. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  124. package/dist/src/repo/coordinator-repo.js +92 -13
  125. package/dist/src/repo/coordinator-repo.js.map +1 -1
  126. package/dist/src/repo/service.d.ts.map +1 -1
  127. package/dist/src/repo/service.js +7 -1
  128. package/dist/src/repo/service.js.map +1 -1
  129. package/dist/src/reputation/peer-reputation.js +1 -1
  130. package/dist/src/reputation/peer-reputation.js.map +1 -1
  131. package/dist/src/reputation/types.d.ts +3 -1
  132. package/dist/src/reputation/types.d.ts.map +1 -1
  133. package/dist/src/reputation/types.js +3 -0
  134. package/dist/src/reputation/types.js.map +1 -1
  135. package/dist/src/rn.d.ts +5 -1
  136. package/dist/src/rn.d.ts.map +1 -1
  137. package/dist/src/rn.js +7 -1
  138. package/dist/src/rn.js.map +1 -1
  139. package/dist/src/storage/arachnode-fret-adapter.d.ts +30 -1
  140. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
  141. package/dist/src/storage/arachnode-fret-adapter.js +15 -2
  142. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
  143. package/dist/src/storage/arachnode-partition.d.ts +49 -0
  144. package/dist/src/storage/arachnode-partition.d.ts.map +1 -0
  145. package/dist/src/storage/arachnode-partition.js +61 -0
  146. package/dist/src/storage/arachnode-partition.js.map +1 -0
  147. package/dist/src/storage/block-storage.d.ts +40 -1
  148. package/dist/src/storage/block-storage.d.ts.map +1 -1
  149. package/dist/src/storage/block-storage.js +150 -57
  150. package/dist/src/storage/block-storage.js.map +1 -1
  151. package/dist/src/storage/i-block-storage.d.ts +8 -0
  152. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/i-raw-storage.d.ts +13 -0
  154. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  155. package/dist/src/storage/kv-raw-storage.d.ts +45 -0
  156. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -0
  157. package/dist/src/storage/kv-raw-storage.js +113 -0
  158. package/dist/src/storage/kv-raw-storage.js.map +1 -0
  159. package/dist/src/storage/memory-storage.d.ts +20 -58
  160. package/dist/src/storage/memory-storage.d.ts.map +1 -1
  161. package/dist/src/storage/memory-storage.js +22 -148
  162. package/dist/src/storage/memory-storage.js.map +1 -1
  163. package/dist/src/storage/memory-store-driver.d.ts +39 -0
  164. package/dist/src/storage/memory-store-driver.d.ts.map +1 -0
  165. package/dist/src/storage/memory-store-driver.js +129 -0
  166. package/dist/src/storage/memory-store-driver.js.map +1 -0
  167. package/dist/src/storage/raw-store-codec.d.ts +10 -0
  168. package/dist/src/storage/raw-store-codec.d.ts.map +1 -0
  169. package/dist/src/storage/raw-store-codec.js +36 -0
  170. package/dist/src/storage/raw-store-codec.js.map +1 -0
  171. package/dist/src/storage/raw-store-driver.d.ts +69 -0
  172. package/dist/src/storage/raw-store-driver.d.ts.map +1 -0
  173. package/dist/src/storage/raw-store-driver.js +2 -0
  174. package/dist/src/storage/raw-store-driver.js.map +1 -0
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +1 -1
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +1 -1
  178. package/dist/src/storage/restoration-coordinator.d.ts +73 -0
  179. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -0
  180. package/dist/src/storage/restoration-coordinator.js +181 -0
  181. package/dist/src/storage/restoration-coordinator.js.map +1 -0
  182. package/dist/src/storage/ring-selector.d.ts +92 -5
  183. package/dist/src/storage/ring-selector.d.ts.map +1 -1
  184. package/dist/src/storage/ring-selector.js +153 -51
  185. package/dist/src/storage/ring-selector.js.map +1 -1
  186. package/dist/src/storage/ring-shift-coordinator.d.ts +128 -0
  187. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -0
  188. package/dist/src/storage/ring-shift-coordinator.js +196 -0
  189. package/dist/src/storage/ring-shift-coordinator.js.map +1 -0
  190. package/dist/src/storage/storage-monitor.d.ts +28 -0
  191. package/dist/src/storage/storage-monitor.d.ts.map +1 -1
  192. package/dist/src/storage/storage-monitor.js +42 -2
  193. package/dist/src/storage/storage-monitor.js.map +1 -1
  194. package/dist/src/storage/storage-repo.d.ts +19 -0
  195. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  196. package/dist/src/storage/storage-repo.js +172 -22
  197. package/dist/src/storage/storage-repo.js.map +1 -1
  198. package/dist/src/sync/client.d.ts.map +1 -1
  199. package/dist/src/sync/client.js +5 -1
  200. package/dist/src/sync/client.js.map +1 -1
  201. package/dist/src/sync/service.d.ts.map +1 -1
  202. package/dist/src/sync/service.js +2 -1
  203. package/dist/src/sync/service.js.map +1 -1
  204. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +28 -4
  205. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  206. package/dist/src/testing/cohort-topic-mesh-harness.js +51 -10
  207. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  208. package/dist/src/testing/index.d.ts +1 -0
  209. package/dist/src/testing/index.d.ts.map +1 -1
  210. package/dist/src/testing/index.js +1 -0
  211. package/dist/src/testing/index.js.map +1 -1
  212. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  213. package/dist/src/testing/mesh-harness.js +15 -7
  214. package/dist/src/testing/mesh-harness.js.map +1 -1
  215. package/dist/src/testing/raw-storage-conformance.d.ts +24 -0
  216. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -0
  217. package/dist/src/testing/raw-storage-conformance.js +313 -0
  218. package/dist/src/testing/raw-storage-conformance.js.map +1 -0
  219. package/package.json +4 -4
  220. package/src/cluster/block-transfer-service.ts +4 -3
  221. package/src/cluster/block-transfer.ts +424 -265
  222. package/src/cluster/client.ts +3 -1
  223. package/src/cluster/cluster-repo.ts +481 -71
  224. package/src/cluster/peer-key-binding.ts +37 -0
  225. package/src/cluster/quorum-restore.ts +0 -0
  226. package/src/cluster/rebalance-monitor.ts +34 -3
  227. package/src/cluster/service.ts +7 -1
  228. package/src/cluster/spread-on-churn.ts +11 -1
  229. package/src/cluster/supermajority-coupling.ts +37 -0
  230. package/src/cohort-topic/cohort-gossip-driver.ts +96 -9
  231. package/src/cohort-topic/cohort-gossip-transport.ts +10 -0
  232. package/src/cohort-topic/host.ts +831 -105
  233. package/src/dispute/arbitrator-selection.ts +138 -28
  234. package/src/dispute/cascade.ts +8 -1
  235. package/src/dispute/client.ts +3 -2
  236. package/src/dispute/dispute-service.ts +94 -9
  237. package/src/dispute/index.ts +7 -1
  238. package/src/dispute/invalidation.ts +40 -2
  239. package/src/dispute/service.ts +7 -1
  240. package/src/index.ts +46 -43
  241. package/src/it-utility.ts +5 -13
  242. package/src/libp2p-key-network.ts +281 -18
  243. package/src/libp2p-node-base.ts +485 -79
  244. package/src/matchmaking/query-transport.ts +9 -0
  245. package/src/network/network-manager-service.ts +8 -2
  246. package/src/owned-block-seed.ts +40 -0
  247. package/src/protocol-client.ts +10 -3
  248. package/src/protocol-limits.ts +33 -0
  249. package/src/repo/client.ts +3 -0
  250. package/src/repo/cluster-coordinator.ts +145 -82
  251. package/src/repo/coordinator-repo.ts +96 -16
  252. package/src/repo/service.ts +7 -1
  253. package/src/reputation/types.ts +3 -0
  254. package/src/rn.ts +7 -1
  255. package/src/storage/arachnode-fret-adapter.ts +33 -2
  256. package/src/storage/arachnode-partition.ts +74 -0
  257. package/src/storage/block-storage.ts +169 -61
  258. package/src/storage/i-block-storage.ts +9 -0
  259. package/src/storage/i-raw-storage.ts +14 -0
  260. package/src/storage/kv-raw-storage.ts +135 -0
  261. package/src/storage/memory-storage.ts +25 -173
  262. package/src/storage/memory-store-driver.ts +157 -0
  263. package/src/storage/raw-store-codec.ts +42 -0
  264. package/src/storage/raw-store-driver.ts +80 -0
  265. package/src/storage/{restoration-coordinator-v2.ts → restoration-coordinator.ts} +43 -37
  266. package/src/storage/ring-selector.ts +317 -176
  267. package/src/storage/ring-shift-coordinator.ts +271 -0
  268. package/src/storage/storage-monitor.ts +70 -3
  269. package/src/storage/storage-repo.ts +710 -558
  270. package/src/sync/client.ts +5 -1
  271. package/src/sync/service.ts +2 -1
  272. package/src/testing/cohort-topic-mesh-harness.ts +61 -11
  273. package/src/testing/index.ts +1 -0
  274. package/src/testing/mesh-harness.ts +14 -6
  275. package/src/testing/raw-storage-conformance.ts +397 -0
@@ -31,8 +31,9 @@
31
31
  * driver to call.
32
32
  *
33
33
  * **Anti-DoS + cold-start (gaps 6–7).** Each {@link CoordEngine} is injected its own per-coord anti-DoS
34
- * guards — a `RegisterRateLimiter` (4/min per peer-topic), a `CorrelationReplayGuard` (60 s freshness),
35
- * and a `TopicBudget` (2048 topics, LRU) — so a budget/limit at one coord is independent of another. The
34
+ * guards — a `RegisterRateLimiter` (4/min per peer-topic), a `CorrelationReplayGuard` (60 s freshness,
35
+ * hard `maxKeys` LRU cap), and a `TopicBudget` (2048 topics, LRU) — so a budget/limit at one coord is
36
+ * independent of another. The
36
37
  * node-level {@link BootstrapEvidence} policy (one tier→verifier policy, no per-coord state) is built once
37
38
  * and shared. db-core embeds no PoW / reputation scheme, so the host supplies the real verifiers
38
39
  * ({@link createPoWVerifier} / {@link createReputationVerifier}) and the participant-side PoW minter
@@ -42,8 +43,8 @@
42
43
  * backing keeps T0/T1 permissive-but-logged so cold-root origination is not blocked
43
44
  * (`cohort-topic-bootstrap-coldstart-origination-regression`), and an entirely unconfigured host stays
44
45
  * permissive-but-logged at every tier (never an undefined gate). A cold-started tier-`d > 0` forwarder registers with
45
- * its tier-`(d − 1)` parent by routing a forwarder-link frame over the router (gap 7), staying
46
- * `awaiting_parent` until the ack.
46
+ * its tier-`(d − 1)` parent by routing a (child-cohort-signed) {@link ChildLinkV1} over the router; the parent
47
+ * authenticates + records the child and acks, and the forwarder stays `awaiting_parent` until that `linked` ack.
47
48
  *
48
49
  * **Scope.** `followOn` derivation for a promoted-redirect arrival is parked in backlog
49
50
  * (`cohort-topic-followon-derivation`); this milestone serves a **single tier-0 cohort**, so `followOn`
@@ -53,12 +54,11 @@
53
54
  * `routeAct`/`assembleCohort`) by `test/cohort-topic/live-tier.spec.ts`.
54
55
  */
55
56
  import { hashPeerId, readAllBounded } from "p2p-fret";
56
- import { RingHash, createRegistrationStore, createSlotAssigner, createCohortGossipBus, createWillingnessCheck, createPromotionLifecycle, createColdStartManager, createTrafficCounters, createRenewalCohortSide, createMembershipVerifier, createMembershipSourceRouter, createMembershipCertPublisher, createCohortSigner, createCohortMemberEngine, createCohortTopicService, createLoadBarometer, createTierAddressing, createRegisterRateLimiter, createCorrelationReplayGuard, createTopicBudget, createBootstrapEvidence, LruMap, coreProfile, DEFAULT_MIN_SIGS, DEFAULT_MAX_NO_POW_TIER, DEFAULT_TRAFFIC_WINDOW_SECONDS, DEFAULT_TTL_MS, bytesToB64url, b64urlToBytes, bytesEqual, compareBytes, encodeCohortMessage, decodeCohortMessage, membershipCertSignable, membershipCertSigningPayload, toCohortTopicSummary, validateRegisterV1, validateRenewV1, validateSignRequestV1, validateSignReplyV1, validatePromotionNoticeV1, validateDemotionNoticeV1, registerSigningPayload, renewSigningPayload, cohortGossipSigningPayload, promotionNoticeSigningPayload, demotionNoticeSigningPayload, } from "@optimystic/db-core";
57
- import { randomBytes } from "@libp2p/crypto";
57
+ import { RingHash, createRegistrationStore, createSlotAssigner, createCohortGossipBus, createWillingnessCheck, createPromotionLifecycle, createColdStartManager, createTrafficCounters, createRenewalCohortSide, createMembershipVerifier, createMembershipSourceRouter, createMembershipCertPublisher, createCohortSigner, createCohortMemberEngine, createCohortTopicService, createLoadBarometer, createTierAddressing, createRegisterRateLimiter, DEFAULT_RATE_WINDOW_MS, createCorrelationReplayGuard, createTopicBudget, createBootstrapEvidence, LruMap, coreProfile, DEFAULT_MIN_SIGS, DEFAULT_MAX_NO_POW_TIER, DEFAULT_TRAFFIC_WINDOW_SECONDS, bytesToB64url, b64urlToBytes, bytesEqual, encodeCohortMessage, decodeCohortMessage, decodeCohortGossipV1, membershipCertSignable, membershipCertSigningPayload, toCohortTopicSummary, validateRegisterV1, validateRenewV1, validateChildLinkV1, validateChildLinkReplyV1, validateSignRequestV1, validateSignReplyV1, validatePromotionNoticeV1, validateDemotionNoticeV1, registerSigningPayload, renewSigningPayload, cohortGossipSigningPayload, promotionNoticeSigningPayload, demotionNoticeSigningPayload, childLinkSigningPayload, } from "@optimystic/db-core";
58
58
  import { peerIdFromString } from "@libp2p/peer-id";
59
59
  import { FretTopicRouter } from "./topic-router.js";
60
60
  import { FretCohortGossipTransport } from "./cohort-gossip-transport.js";
61
- import { buildCohortGossip, createPendingDeltas, DEFAULT_GOSSIP_INTERVAL_MS } from "./cohort-gossip-driver.js";
61
+ import { buildCohortGossip, createPendingDeltas, DEFAULT_GOSSIP_INTERVAL_MS, DEFAULT_WILLINGNESS_HEARTBEAT_MS } from "./cohort-gossip-driver.js";
62
62
  import { FretMembershipSource } from "./membership-source.js";
63
63
  import { FretMembershipPublishSink } from "./membership-publish-sink.js";
64
64
  import { FretCohortThresholdCrypto, createVerifyOnlyThresholdCrypto } from "./threshold-crypto.js";
@@ -86,6 +86,7 @@ export async function createCohortTopicHost(node, fret, options = {}) {
86
86
  const fanout = options.fanout ?? 16;
87
87
  const maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
88
88
  const gossipIntervalMs = options.gossipIntervalMs ?? DEFAULT_GOSSIP_INTERVAL_MS;
89
+ const willingnessHeartbeatMs = options.willingnessHeartbeatMs ?? DEFAULT_WILLINGNESS_HEARTBEAT_MS;
89
90
  const hash = new RingHash();
90
91
  const selfPeerStr = node.peerId.toString();
91
92
  const selfMemberBytes = peerIdToBytes(node.peerId); // dialable member id
@@ -167,6 +168,12 @@ export async function createCohortTopicHost(node, fret, options = {}) {
167
168
  // transport's cohort peer resolution.
168
169
  const broadcastNotice = (notice, servedCoord) => {
169
170
  const frame = encodeCohortMessage(notice, maxBytes);
171
+ // A demotion fans to BOTH the demoting child's served coord (siblings adopt `promoted = false` via the
172
+ // `cohortCoord`-routed apply) and the parent coord (the parent unrecords the child). `handleInboundNotice`
173
+ // runs both apply semantics independently: the `cohortCoord` target does the sibling-adopt, and a demotion
174
+ // additionally resolves `parentCohortCoord` and — on the same threshold verify against the child cohort
175
+ // cert — calls `parent.unrecordChild(...)`. A parent-only node applies only the unlink; a node serving
176
+ // both coords applies both. See `noticeBroadcastCoords` / `handleInboundNotice`.
170
177
  for (const coord of noticeBroadcastCoords(notice, servedCoord)) {
171
178
  gossipTransport.broadcastOver(protocols.promote, coord, frame);
172
179
  }
@@ -209,6 +216,7 @@ export async function createCohortTopicHost(node, fret, options = {}) {
209
216
  wantK,
210
217
  minSigs,
211
218
  maxBytes,
219
+ willingnessHeartbeatMs,
212
220
  publishSink,
213
221
  privateKey: options.privateKey,
214
222
  router,
@@ -229,9 +237,83 @@ export async function createCohortTopicHost(node, fret, options = {}) {
229
237
  // Cache this node's own freshly-published cohort cert into the verifier, so an inbound notice
230
238
  // signed by this node's cohort verifies locally without a network refetch. `verifier` is declared
231
239
  // just below; the closure only runs on a (later) publish, after it is initialized.
240
+ //
241
+ // NOTE (stale trust-lock, drop-the-lock-on-demotion): `verifier.cache()` marks this coord *trusted*,
242
+ // which trust-LOCKS it (no un-anchored refetch can downgrade it). If this node later leaves the coord's
243
+ // cohort it stops republishing and its anchor goes `"unknown"`, so it can be stranded distrusting the
244
+ // coord's later-epoch messages. The verifier self-heals via bounded re-TOFU on a demonstrated chain gap
245
+ // (`staleGapRecoveryStrikes`, see `db-core/.../membership/verifier.ts` + `docs/cohort-topic.md`
246
+ // §Bootstrapping trust). The *root-cause* fix is for the host to drop the lock here on demotion, but
247
+ // that needs an engine-reclaim / demotion signal the host does not emit today. `createCoordRegistry`
248
+ // now evicts, but only IDLE engines (no records → never published a cert), so it never strands a
249
+ // trust-lock and does not resolve this on its own (see the NOTE at `evictOneIdle`). When a demotion /
250
+ // cert-publishing-engine reclaim signal lands, add a `verifier.forget(coord)` / downgrade call on
251
+ // demotion and prefer it over (or alongside) the strike-counter heuristic.
232
252
  onCertPublished: (cert) => verifier.cache(cert),
233
253
  };
234
- const registry = createCoordRegistry(ctx);
254
+ const registry = createCoordRegistry(ctx, options.antiDos?.coordEnginesMax);
255
+ // --- cold-sibling engine instantiation on a verified co-member gossip frame (§Cold-start instantiation) ---
256
+ // A brand-new multi-node cohort deadlocks otherwise: FRET lands every register for a coord on the ONE
257
+ // nearest member, so its siblings are never independently woken by a routed register — they hold no engine,
258
+ // are not subscribed to the coord's gossip, and silently drop the willingness/record frames the served
259
+ // member sends. So replication/failover never materialise. This gate lets a co-member's frame (e.g. the
260
+ // idle-but-willing willingness heartbeat) instantiate the sibling engine, which then joins the gossip and
261
+ // reciprocates its own willingness. Called from the `/cohort-gossip` handler BEFORE `deliver`, so the fresh
262
+ // bus is subscribed in time to merge the very frame that woke it.
263
+ //
264
+ // Bounded to genuine co-members by the existing `verifyGossip` auth check (peer-key signature verifies for
265
+ // `fromMember` AND `fromMember` ∈ `cohortAround(coord).members`), so a peer can only make us instantiate an
266
+ // engine for a coord where FRET assembly agrees we are both members. Live-signer mode only: without a key
267
+ // there is no co-member gate, and unauthenticated engine creation would be a DoS vector, so key-less/interim
268
+ // mode keeps today's behaviour (drop gossip for an unknown coord).
269
+ //
270
+ // Scope: tier-0 only (`treeTier === 0`). A tier-`d > 0` frame carries no topic/participantCoord context a
271
+ // bare willingness heartbeat could seed the parent-coord derivation from, and overlaps the parent-child
272
+ // link work — so a tier-`d > 0` frame for an unknown coord falls through to today's drop (the bus has no
273
+ // engine subscribed to it). See `docs/cohort-topic.md` §Cold-start instantiation.
274
+ //
275
+ // The registry is hard-capped with LRU eviction of idle engines (`createCoordRegistry`), so a
276
+ // gossip-instantiated cold sibling is no longer a permanent per-co-member-coord cost: an idle one is
277
+ // reclaimed under memory pressure like any other cold engine, and a creation over a full-of-live registry
278
+ // is refused (`CoordEngineRegistryFullError`) and dropped here rather than crashing the gossip handler.
279
+ const maybeInstantiateColdSibling = (frame) => {
280
+ if (verifyGossip === undefined) {
281
+ return; // key-less / interim mode: no co-member gate, so never auto-instantiate
282
+ }
283
+ let g;
284
+ try {
285
+ g = decodeCohortGossipV1(frame, maxBytes);
286
+ }
287
+ catch {
288
+ return; // malformed → the normal deliver path drops it
289
+ }
290
+ if (g.treeTier !== 0) {
291
+ return; // tier-0 milestone only (a tier-d>0 unknown-coord frame falls through to drop)
292
+ }
293
+ const coord = b64urlToBytes(g.coord);
294
+ // NOTE: findByCoord bumps LRU recency as a side effect, and this runs BEFORE the verifyGossip
295
+ // co-member gate — so an unverified gossip frame naming a coord we already serve can pin that engine
296
+ // against idle-eviction. Marginal today (an outsider can only pin engines that already exist, bounded
297
+ // by real co-membership; it cannot create engines — that path is gated below). If gossip-flood
298
+ // recency-pinning ever matters, add a touch-free `has(coord)` lookup for this existence probe.
299
+ if (registry.findByCoord(coord) !== undefined) {
300
+ return; // already serving this coord — nothing to instantiate
301
+ }
302
+ if (!verifyGossip(g, coord)) {
303
+ return; // co-member gate: bad signature or non-member → do not instantiate
304
+ }
305
+ // The dummy `participantCoord` seeds only the tier-`d > 0` parent-coord derivation, which a tier-0
306
+ // engine never exercises (demotion is gated on `treeTier > 0`); self's member bytes are a safe filler.
307
+ try {
308
+ registry.forCoord(coord, g.treeTier, selfMemberBytes);
309
+ }
310
+ catch (err) {
311
+ if (err instanceof CoordEngineRegistryFullError) {
312
+ return; // registry full of live cohorts — drop the cold-sibling instantiation (same as any drop)
313
+ }
314
+ throw err;
315
+ }
316
+ };
235
317
  // --- intra-cohort sign endorsement (the `/sign` handler body) ---
236
318
  // A member dials us to endorse a threshold-signed artifact; we sign the exact request payload iff we
237
319
  // and the requester share the cohort+epoch around `coord`. Exported `handleSignRequest` is the testable
@@ -325,21 +407,78 @@ export async function createCohortTopicHost(node, fret, options = {}) {
325
407
  // `d` this equals the participant's `coord_d(self, topicId)` routing key by construction, i.e. the
326
408
  // coordinate FRET routed to (§Tier addressing).
327
409
  const servedCoord = addressing.coord(reg.treeTier, participantCoord, topicId);
328
- const coordEngine = registry.forCoord(servedCoord, reg.treeTier, participantCoord);
410
+ let coordEngine;
411
+ try {
412
+ coordEngine = registry.forCoord(servedCoord, reg.treeTier, participantCoord);
413
+ }
414
+ catch (err) {
415
+ if (err instanceof CoordEngineRegistryFullError) {
416
+ // Every engine slot holds a live cohort — refuse this new coord cleanly. `unwilling_cohort`
417
+ // with a back-off is the walk's "retry in time / restart at d_max" signal (§Capacity barometer),
418
+ // the same shape the per-coord topic budget answers when full-of-populated.
419
+ log("cohort-topic: register refused — coord-engine registry full");
420
+ return { v: 1, result: "unwilling_cohort", retryAfterMs: DEFAULT_RATE_WINDOW_MS, reason: "coord-engine registry full" };
421
+ }
422
+ throw err;
423
+ }
329
424
  if (fretCohort !== undefined) {
330
425
  crossCheckCohort(fret, wantK, servedCoord, fretCohort);
331
426
  }
332
427
  // `parentCoord` for a cold-start forwarder's parent registration; undefined at the root.
333
428
  const parentCoord = reg.treeTier > 0 ? addressing.coord(reg.treeTier - 1, participantCoord, topicId) : undefined;
334
- return coordEngine.engine.handleRegister(reg, { followOn: false, treeTier: reg.treeTier, parentCoord }, now);
429
+ // Derive `followOn` straight from the participant-asserted wire flag. This is the information the
430
+ // child cohort genuinely cannot infer locally (the tier-addressing hash decorrelates parent/child
431
+ // coords, and FRET carries no breadcrumb of the redirect), so it must be carried on the frame. It is
432
+ // forgeable, so the engine's step-1 bootstrap-evidence gate — which demands the same proof for
433
+ // `followOn: true` as for a `bootstrap: true` cold-root — is what keeps it honest (§Anti-DoS).
434
+ const followOn = reg.followOn === true;
435
+ return coordEngine.engine.handleRegister(reg, { followOn, treeTier: reg.treeTier, parentCoord }, now);
436
+ };
437
+ // --- child-link dispatch: verify + record a child cohort (parent side) ---
438
+ // A routed `ChildLinkV1` reaches this node's parent engine through the SAME two entry points a register
439
+ // uses (the FRET activity handler + the direct-dial `register` handler). Live-key mode verifies the child
440
+ // cohort's threshold signature against the child cohort's cert; key-less mode is permissive (mirrors the
441
+ // `verifyRegisterSig` fallback) so unit/mock flows still link + record.
442
+ const verifyChildLinkSig = options.privateKey === undefined
443
+ ? undefined
444
+ : async (link, at) => {
445
+ // Live-key: an UNSIGNED link (misconfigured mixed mode) is never permissive-accepted — reject it,
446
+ // never a silent record.
447
+ if (link.thresholdSig.length === 0) {
448
+ return false;
449
+ }
450
+ let signers;
451
+ let sig;
452
+ try {
453
+ signers = link.signers.map(b64urlToBytes);
454
+ sig = b64urlToBytes(link.thresholdSig);
455
+ }
456
+ catch {
457
+ return false; // a signer / sig that is not valid base64url cannot verify
458
+ }
459
+ // Verify against the CHILD cohort's cert at the child coord/tier — identical to the notice verify at
460
+ // `verifyAndApplyNotice`, with the same bounded refetch so a link flood cannot amplify into dials.
461
+ const result = await verifier.verifyMessage(signers, b64urlToBytes(link.childCohortCoord), link.childTier, childLinkSigningPayload(link), sig, { minRefetchIntervalMs: PROMOTE_REFETCH_MIN_INTERVAL_MS, now: at });
462
+ return result === "verified";
463
+ };
464
+ const childLinkDeps = {
465
+ coord: (tier, pc, topicId) => addressing.coord(tier, pc, topicId),
466
+ resolveParent: (parentServedCoord, parentTier, childParticipantCoord) => registry.forCoord(parentServedCoord, parentTier, childParticipantCoord),
467
+ verifyChildLinkSig,
335
468
  };
336
469
  // --- protocol handlers + activity callback ---
337
470
  // Await registration so the host is not returned (and dialed) before the five handlers are live —
338
471
  // and, crucially, before the gossip driver below starts ticking (no tick may run on a half-wired node).
339
- await registerProtocolHandlers(node, protocols, registry, dispatchRegister, signEndorse, verifier, promoteGate, gossipTransport, publishSink, membershipSource, selfCoord, maxBytes);
472
+ await registerProtocolHandlers(node, protocols, registry, dispatchRegister, childLinkDeps, minSigs, signEndorse, verifier, promoteGate, gossipTransport, maybeInstantiateColdSibling, publishSink, membershipSource, selfCoord, maxBytes);
340
473
  fret.setActivityHandler(async (activity, cohort) => {
341
- const reg = validateRegisterV1(decodeCohortMessage(b64urlToBytes(activity), maxBytes));
342
- const reply = await dispatchRegister(reg, cohort, Date.now());
474
+ const decoded = decodeCohortMessage(b64urlToBytes(activity), maxBytes);
475
+ // Decode-and-branch: a `ChildLinkV1` (a child cohort registering with this parent) runs the child-link
476
+ // dispatch; everything else is an ordinary participant `RegisterV1`. The two shapes are disjoint (a
477
+ // child-link has no `treeTier`/`signature`; a register has no `childCohortCoord`/`childTier`).
478
+ const link = tryValidate(() => validateChildLinkV1(decoded, minSigs));
479
+ const reply = link !== undefined
480
+ ? await dispatchChildLink(link, childLinkDeps, Date.now())
481
+ : await dispatchRegister(validateRegisterV1(decoded), cohort, Date.now());
343
482
  return { commitCertificate: bytesToB64url(encodeCohortMessage(reply, maxBytes)) };
344
483
  });
345
484
  // --- periodic gossip-cadence driver (gap 5) ---
@@ -392,6 +531,7 @@ export async function createCohortTopicHost(node, fret, options = {}) {
392
531
  profile,
393
532
  gossipTransport,
394
533
  promoteGate,
534
+ membershipSource,
395
535
  stop: async () => {
396
536
  stopped = true;
397
537
  clearInterval(timer);
@@ -536,34 +676,43 @@ function createBootstrapEvidencePolicy(antiDos, hash, log, parentTopicView, hasC
536
676
  });
537
677
  }
538
678
  /**
539
- * Route a forwarder→parent link to `parentCoord` and resolve on the round-trip (the parent ack).
679
+ * Route a child-cohort→parent link to `parentCoord` and resolve **only on a `linked` ack**.
540
680
  *
541
- * The link is a `RegisterV1`-style frame routed over {@link ITopicRouter.routeAndAct} keyed at the
542
- * parent coord: it rides the parent's serving tier (`treeTier 1`) with this engine's seed
543
- * `participantCoord`, so the parent recomputes `servedCoord = coord_{d−1}(participantCoord, topicId) =
544
- * parentCoord`. A fresh CSPRNG `correlationId` keeps it clear of the parent's replay guard on retry.
545
- * Resolution of the route is treated as the ack (richer child-link confirmation the parent recording
546
- * `childCohortCount` over a dedicated child-link frame is the follow-on
547
- * `cohort-topic-parent-child-link`); a rejection propagates so the cold-start manager keeps the
548
- * forwarder `awaiting_parent`.
681
+ * The frame is a dedicated {@link ChildLinkV1} (not a participant `RegisterV1`) routed over
682
+ * {@link ITopicRouter.routeAndAct} keyed at the parent coord: it carries the child's served coord
683
+ * (`childCohortCoord`) and its seed `childParticipantCoord`, so the parent recomputes
684
+ * `coord_{d−1}(childParticipantCoord, topicId) == parentCoord` and `coord_d(...) == childCohortCoord`,
685
+ * binding the relationship. In live-key mode the child cohort threshold-signs the link over its own coord
686
+ * at its current epoch; key-less it ships unsigned (empty `thresholdSig`/`signers`). The parent authenticates
687
+ * + records the child and replies {@link ChildLinkReplyV1}; a `rejected` (or unreachable) reply propagates as
688
+ * a throw so the cold-start manager keeps the forwarder `awaiting_parent` for a later retry.
549
689
  */
550
690
  async function registerForwarderWithParent(ctx, link) {
691
+ const childTier = link.treeTier;
551
692
  const frame = {
552
693
  v: 1,
553
694
  topicId: bytesToB64url(link.topicId),
695
+ childCohortCoord: bytesToB64url(link.childCohortCoord),
696
+ childParticipantCoord: bytesToB64url(link.participantCoord),
697
+ childTier,
554
698
  tier: clampTier(link.opTier ?? 0),
555
- treeTier: Math.max(0, link.treeTier - 1),
556
- participantCoord: bytesToB64url(link.participantCoord),
557
- ttl: DEFAULT_TTL_MS,
558
- // Not a root cold-start: a follow-on link to an already-promoted parent, so no bootstrap evidence.
559
- bootstrap: false,
560
- timestamp: Date.now(),
561
- correlationId: bytesToB64url(randomBytes(16)),
562
- // Interim: the forwarder cohort cannot sign as the participant; the dedicated child-link frame
563
- // (follow-on) carries the cohort threshold signature instead.
564
- signature: "",
699
+ effectiveAt: Date.now(),
700
+ thresholdSig: "",
701
+ signers: [],
702
+ cohortEpoch: bytesToB64url(link.cohortEpoch()),
565
703
  };
566
- await ctx.router.routeAndAct(link.parentCoord, encodeCohortMessage(frame, ctx.maxBytes), { wantK: ctx.wantK, minSigs: ctx.minSigs });
704
+ // Live-key: threshold-sign over the canonical child-link image (signature covers only the signable fields,
705
+ // so filling `thresholdSig`/`signers` after does not alter what was signed). Key-less: leave it unsigned.
706
+ if (link.signChildLink !== undefined) {
707
+ const { thresholdSig, signers } = await link.signChildLink.thresholdSign(childLinkSigningPayload(frame));
708
+ frame.thresholdSig = bytesToB64url(thresholdSig);
709
+ frame.signers = signers.map(bytesToB64url);
710
+ }
711
+ const replyBytes = await ctx.router.routeAndAct(link.parentCoord, encodeCohortMessage(frame, ctx.maxBytes), { wantK: ctx.wantK, minSigs: ctx.minSigs });
712
+ const reply = validateChildLinkReplyV1(decodeCohortMessage(replyBytes, ctx.maxBytes));
713
+ if (reply.result !== "linked") {
714
+ throw new Error(`cohort-topic child-link rejected by parent${reply.reason !== undefined ? `: ${reply.reason}` : ""}`);
715
+ }
567
716
  }
568
717
  /** Clamp an op tier to the valid T0–T3 range so the link frame validates at a (future) real parent. */
569
718
  function clampTier(tier) {
@@ -573,9 +722,82 @@ function clampTier(tier) {
573
722
  return tier > 3 ? 3 : tier;
574
723
  }
575
724
  // --- registry + coord engine ---
576
- /** Build the lazy `servedCoord → CoordEngine` registry over the shared collaborators. */
577
- function createCoordRegistry(ctx) {
725
+ /**
726
+ * Default hard cap on the number of live per-coord cohort engines (see
727
+ * {@link CohortTopicAntiDosOptions.coordEnginesMax}). Aligned in order of magnitude with the topic-budget
728
+ * `topics_max` (2048): the same "this many distinct served units before an anti-abuse ceiling bites" scale.
729
+ */
730
+ export const DEFAULT_COORD_ENGINES_MAX = 2048;
731
+ /**
732
+ * Thrown by {@link CoordRegistry.forCoord} when it must create a new engine but the registry is full of
733
+ * **live** cohorts (every slot holds records or a cold-start forwarder, so nothing is idle-evictable).
734
+ * Signals a capacity refusal, not a bug — the register / child-link / cold-sibling dispatch paths catch it
735
+ * and answer a clean refusal (`unwilling_cohort` / `rejected` / drop) rather than letting it escape.
736
+ */
737
+ export class CoordEngineRegistryFullError extends Error {
738
+ constructor(maxEngines) {
739
+ super(`cohort-topic coord-engine registry full (max=${maxEngines}) — every slot holds a live cohort`);
740
+ this.name = "CoordEngineRegistryFullError";
741
+ }
742
+ }
743
+ /**
744
+ * Build the lazy `servedCoord → CoordEngine` registry over the shared collaborators, hard-capped at
745
+ * `maxEngines` with least-recently-used eviction of **idle** engines.
746
+ *
747
+ * The served coord is a hash over attacker-chosen `(treeTier, participantCoord, topicId)`, and `forCoord`
748
+ * runs on the register hot path **before** the per-coord anti-DoS gates — so, uncapped, one peer spraying
749
+ * distinct coords drives unbounded engine allocation (each engine owns a store, gossip bus, rate limiter,
750
+ * replay guard, topic budget, …). The cap bounds that: on a creation over a full registry we evict the
751
+ * least-recently-used **idle** engine (no records, no cold-start forwarder — a throwaway cold coord) and
752
+ * tear it down; when every slot holds a live cohort we refuse the new coord ({@link CoordEngineRegistryFullError})
753
+ * so a legitimate multi-cohort node keeps working while attacker-driven cold engines cannot pile up.
754
+ *
755
+ * Recency is bumped on every lookup that hands back an engine (`forCoord` / `findByCoord` / `findHolder` /
756
+ * `findServing`), so a hot cohort under load is never the eviction victim.
757
+ */
758
+ function createCoordRegistry(ctx, maxEngines = DEFAULT_COORD_ENGINES_MAX) {
759
+ if (!Number.isInteger(maxEngines) || maxEngines <= 0) {
760
+ throw new RangeError(`coordEnginesMax must be a positive integer, got ${maxEngines}`);
761
+ }
578
762
  const engines = new Map();
763
+ // LRU recency by engine key: a monotonic touch sequence (higher = more recently used). Every engine in
764
+ // `engines` has an entry; entries are dropped alongside their engine on eviction / close.
765
+ const recency = new Map();
766
+ let seq = 0;
767
+ const touch = (key) => { recency.set(key, ++seq); };
768
+ // An engine is idle-evictable iff it holds no registration record AND no cold-start forwarder — i.e. no
769
+ // genuine cohort state to lose. A live engine (records or a forwarder) is never a throwaway.
770
+ const isIdle = (engine) => !engine.hasState() && !engine.hasForwarders();
771
+ // Evict the least-recently-used idle engine to free a slot; returns true iff one was freed. Tears the
772
+ // victim down (`close()` drops its gossip-bus subscription) so eviction does not leak the subscription.
773
+ //
774
+ // NOTE (verifier trust-lock, cohort-topic-treetier-bound-engine-cap): only IDLE engines are evicted here,
775
+ // and an idle engine (`hasState() === false`) has never published a membership cert — so there is no
776
+ // verifier trust-lock (`onCertPublished` → `verifier.cache`, above) to drop for its coord. If this policy
777
+ // is ever widened to evict a cert-publishing engine, add a `verifier.forget(coord)` / downgrade here:
778
+ // otherwise the stale trust-lock strands the coord's later-epoch messages (the drop-the-lock tripwire the
779
+ // `onCertPublished` NOTE describes). Do NOT widen without that.
780
+ const evictOneIdle = () => {
781
+ let victimKey;
782
+ let victimSeq = Infinity;
783
+ for (const [key, engine] of engines) {
784
+ if (!isIdle(engine)) {
785
+ continue; // live cohort (records) or mid-link cold-start forwarder — never evicted
786
+ }
787
+ const s = recency.get(key) ?? 0;
788
+ if (s < victimSeq) {
789
+ victimSeq = s;
790
+ victimKey = key;
791
+ }
792
+ }
793
+ if (victimKey === undefined) {
794
+ return false;
795
+ }
796
+ engines.get(victimKey).close();
797
+ engines.delete(victimKey);
798
+ recency.delete(victimKey);
799
+ return true;
800
+ };
579
801
  return {
580
802
  forCoord(coord, treeTier, participantCoord) {
581
803
  const key = bytesToB64url(coord);
@@ -583,25 +805,39 @@ function createCoordRegistry(ctx) {
583
805
  // share one engine rather than racing to construct a second.
584
806
  let engine = engines.get(key);
585
807
  if (engine === undefined) {
808
+ if (engines.size >= maxEngines && !evictOneIdle()) {
809
+ // Full of live cohorts — refuse rather than grow unbounded. Callers turn this into a clean
810
+ // capacity reply/drop (see the paths listed on `CoordEngineRegistryFullError`).
811
+ log("cohort-topic: coord-engine registry full (max=%d) — refusing new coord %s", maxEngines, key);
812
+ throw new CoordEngineRegistryFullError(maxEngines);
813
+ }
586
814
  engine = createCoordEngine(ctx, coord, treeTier, participantCoord);
587
815
  engines.set(key, engine);
588
816
  }
817
+ touch(key);
589
818
  return engine;
590
819
  },
591
820
  findByCoord(coord) {
592
- return engines.get(bytesToB64url(coord));
821
+ const key = bytesToB64url(coord);
822
+ const engine = engines.get(key);
823
+ if (engine !== undefined) {
824
+ touch(key);
825
+ }
826
+ return engine;
593
827
  },
594
828
  findHolder(topicId, participantId) {
595
- for (const engine of engines.values()) {
829
+ for (const [key, engine] of engines) {
596
830
  if (engine.holds(topicId, participantId)) {
831
+ touch(key);
597
832
  return engine;
598
833
  }
599
834
  }
600
835
  return undefined;
601
836
  },
602
837
  findServing(topicId, treeTier) {
603
- for (const engine of engines.values()) {
838
+ for (const [key, engine] of engines) {
604
839
  if (engine.treeTier === treeTier && engine.servesTopic(topicId)) {
840
+ touch(key);
605
841
  return engine;
606
842
  }
607
843
  }
@@ -615,6 +851,7 @@ function createCoordRegistry(ctx) {
615
851
  engine.close();
616
852
  }
617
853
  engines.clear();
854
+ recency.clear();
618
855
  },
619
856
  };
620
857
  }
@@ -623,8 +860,9 @@ function createCoordRegistry(ctx) {
623
860
  * Two roles, one small object:
624
861
  *
625
862
  * - **Producer** — {@link predecessor} is the identity of the *last published* cert; a publish whose
626
- * first `k − x` differ from it is a rotation, and the predecessor identity scopes the rotation `/sign`
627
- * round (its epoch is `prevEpoch`, its members are the outgoing cohort to collect from).
863
+ * epoch (`H(sorted members)`) differs from it is a rotation — any member change, head or tail and the
864
+ * predecessor identity scopes the rotation `/sign` round (its epoch is `prevEpoch`, its members are the
865
+ * outgoing cohort to collect from).
628
866
  * - **Endorser** — {@link membersAt} answers "was I a member of the cohort at `epoch`?" over a two-deep
629
867
  * observed-epoch history ({@link current} + {@link prior}), kept fresh by {@link observe} on every
630
868
  * cohort assembly. A request for an epoch past that window is refused (the rapid-double-rotation gap).
@@ -668,15 +906,69 @@ class RotationState {
668
906
  return undefined;
669
907
  }
670
908
  }
909
+ function createChildRegistry() {
910
+ const byTopic = new Map();
911
+ // Freshness-ordered write shared by link/unlink: apply `linked` only if `effectiveAt` is strictly newer than
912
+ // the entry's high-water (or the entry is new), so a stale/out-of-order replay is dropped and an idempotent
913
+ // re-apply is a no-op. Returns whether the state actually advanced (so the caller re-gossips only real changes).
914
+ const apply = (topicId, childCohortCoord, effectiveAt, linked) => {
915
+ const topicKey = bytesToB64url(topicId);
916
+ let children = byTopic.get(topicKey);
917
+ if (children === undefined) {
918
+ children = new Map();
919
+ byTopic.set(topicKey, children);
920
+ }
921
+ const childKey = bytesToB64url(childCohortCoord);
922
+ const existing = children.get(childKey);
923
+ if (existing === undefined) {
924
+ // A never-seen unlink writes a `linked = false` tombstone (never a negative count), so a later stale
925
+ // link with an earlier effectiveAt cannot resurrect a demoted child.
926
+ children.set(childKey, { linked, lastEffectiveAt: effectiveAt });
927
+ return true;
928
+ }
929
+ if (effectiveAt <= existing.lastEffectiveAt) {
930
+ return false;
931
+ }
932
+ const changed = existing.linked !== linked;
933
+ existing.linked = linked;
934
+ existing.lastEffectiveAt = effectiveAt;
935
+ return changed;
936
+ };
937
+ return {
938
+ recordChild(topicId, childCohortCoord, effectiveAt) {
939
+ return apply(topicId, childCohortCoord, effectiveAt, true);
940
+ },
941
+ unrecordChild(topicId, childCohortCoord, effectiveAt) {
942
+ return apply(topicId, childCohortCoord, effectiveAt, false);
943
+ },
944
+ count(topicId) {
945
+ const children = byTopic.get(bytesToB64url(topicId));
946
+ if (children === undefined) {
947
+ return 0;
948
+ }
949
+ let n = 0;
950
+ for (const entry of children.values()) {
951
+ if (entry.linked) {
952
+ n++;
953
+ }
954
+ }
955
+ return n;
956
+ },
957
+ };
958
+ }
671
959
  /**
672
960
  * Compose one {@link CoordEngine} bound to `servedCoord`. The cohort it threshold-signs / shards with
673
961
  * is the FRET assembly around `servedCoord` (not the node's own ring position). The promotion tier
674
962
  * inputs are coord-derived: `treeTier` is fixed at instantiation; `parentCoord` is
675
963
  * `coord_{d-1}(participantCoord, topicId)` (the shard's parent shares the prefix, so any participant
676
- * routed here yields the same parent); `childCohortCount` is `0` for the single-cohort milestone.
964
+ * routed here yields the same parent); `childCohortCount` is the converged union of recorded child cohorts
965
+ * (per-cohort child registry, gossip-replicated), `0` until this cohort parents a child.
677
966
  */
678
967
  function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
679
968
  const store = createRegistrationStore();
969
+ // The child cohorts this cohort parents (recorded by the parent-side child-link dispatch). Its `count`
970
+ // feeds the demotion gate, the gossip summary, and the traffic snapshot below (was hardcoded 0).
971
+ const childRegistry = createChildRegistry();
680
972
  // Epoch-rotation bookkeeping. `cohort()` observes every assembly so the endorser history stays fresh
681
973
  // (the gossip-cadence driver assembles each round); the producer reads `predecessor()` on publish.
682
974
  const rotationState = new RotationState();
@@ -713,6 +1005,19 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
713
1005
  topicBudget.touch(topicId, store.directParticipants(topicId));
714
1006
  }
715
1007
  },
1008
+ // Cross-member child-set convergence: merge inbound child link/unlink deltas straight into this engine's
1009
+ // child registry (last-writer-wins by effectiveAt), so every parent member reads the same converged union
1010
+ // — not only the FRET-routed member that recorded the child. A direct write (no re-enqueue) so a received
1011
+ // delta is not re-gossiped; one broadcast already reaches the whole cohort. (`childRegistry` is declared
1012
+ // just above, so it is fully initialized by the time this callback fires on an inbound merge.)
1013
+ onChildDeltas: (childLinks, childUnlinks) => {
1014
+ for (const ref of childLinks) {
1015
+ childRegistry.recordChild(b64urlToBytes(ref.topicId), b64urlToBytes(ref.childCohortCoord), ref.effectiveAt);
1016
+ }
1017
+ for (const ref of childUnlinks) {
1018
+ childRegistry.unrecordChild(b64urlToBytes(ref.topicId), b64urlToBytes(ref.childCohortCoord), ref.effectiveAt);
1019
+ }
1020
+ },
716
1021
  });
717
1022
  const view = bus.view();
718
1023
  const selfMember = bytesToB64url(ctx.selfMemberBytes);
@@ -741,6 +1046,10 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
741
1046
  };
742
1047
  const noticeSigner = makeCoordSigner("promotion");
743
1048
  const membershipSigner = makeCoordSigner("membership");
1049
+ // The child cohort threshold-signs its own child-link exactly as it signs a promotion notice — over its
1050
+ // own served coord at its current epoch (a sibling of `noticeSigner`). Verify-only key-less (the child
1051
+ // then emits an UNSIGNED link and the parent permissive-accepts it, matching the register-sig fallback).
1052
+ const childLinkSigner = makeCoordSigner("childlink");
744
1053
  // Cohort-side membership-cert publisher: threshold-signs a MembershipCertV1 over this coord's cohort
745
1054
  // and serves it through the node's publish sink. Driven by the onStabilized / pumpMembership hooks.
746
1055
  const membershipPublisher = createMembershipCertPublisher({
@@ -759,10 +1068,16 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
759
1068
  // gossip-cadence driver iterating `registry.all()` would reject on every key-less engine.
760
1069
  const canPublish = ctx.privateKey !== undefined;
761
1070
  // --- epoch-rotation attestation production (cohort-topic-trust-anchor-rotation-production) ---
762
- // The first `k x` members of the cert's ascending order; a change across a publish is a rotation
763
- // (mirrors the publisher's own republish gate — same inputs, so the two agree on what is a rotation).
764
- const firstKx = (members) => [...members].sort(compareBytes).slice(0, ctx.minSigs).map(bytesToB64url);
765
- const firstKxChanged = (a, b) => !sameStringOrder(firstKx(a.memberBytes), firstKx(b.memberBytes));
1071
+ // Any change to the cohort identity (`epochKey = H(sorted members)`) is a rotation head OR tail
1072
+ // (mirrors the publisher's own republish gate — both now key on the epoch, so the two agree on what is
1073
+ // a rotation).
1074
+ // NOTE: the `/sign` "rotation" endorsement gate remembers only the current + immediately-prior observed
1075
+ // epoch (RotationState.membersAt). That two-deep bound is orthogonal to attesting on any epoch change —
1076
+ // RotationState.observe already shifts on every observed epoch change (any member change rotates the
1077
+ // epoch), so rapid churn could age a predecessor epoch out of the window regardless. If rapid multi-step
1078
+ // churn ever makes rotation attestations frequently unproducible, that is a history-depth concern in
1079
+ // RotationState, not this trigger.
1080
+ const epochChanged = (a, b) => a.epochKey !== b.epochKey;
766
1081
  /**
767
1082
  * Threshold-sign the new cert's canonical payload under the **predecessor** cohort identity, producing the
768
1083
  * `{ prevEpoch, rotationSig, rotationSigners }` attestation — or `undefined` if the predecessor quorum is
@@ -796,10 +1111,10 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
796
1111
  }
797
1112
  };
798
1113
  /**
799
- * Publish (or refresh) this cohort's membership cert, attaching a rotation attestation when the first
800
- * `k x` changed since the last publish. `refresh` selects the publisher path: `false` for a
1114
+ * Publish (or refresh) this cohort's membership cert, attaching a rotation attestation when the cohort
1115
+ * identity (epoch) changed since the last publish. `refresh` selects the publisher path: `false` for a
801
1116
  * stabilization event ({@link CoordEngine.onStabilized}), `true` for the periodic refresh
802
- * ({@link CoordEngine.pumpMembership}). A first-`k − x` change is a stabilization regardless of which hook
1117
+ * ({@link CoordEngine.pumpMembership}). An epoch change is a stabilization regardless of which hook
803
1118
  * fired, so it routes through `onStabilized` (which republishes promptly on the change) carrying the
804
1119
  * attestation; the `/sign` round runs only on that change, so it costs one round per rotation, never per
805
1120
  * tick. Key-less interim mode no-ops (the verify-only signer cannot assemble).
@@ -811,7 +1126,7 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
811
1126
  const snapshot = snapshotAt(now); // also observes the current identity (snapshotAt → cohort())
812
1127
  const current = identityOf(snapshot);
813
1128
  const predecessor = rotationState.predecessor();
814
- const rotating = predecessor !== undefined && firstKxChanged(predecessor, current);
1129
+ const rotating = predecessor !== undefined && epochChanged(predecessor, current);
815
1130
  let published;
816
1131
  if (rotating) {
817
1132
  const rotation = await produceRotation(snapshot, predecessor);
@@ -833,16 +1148,23 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
833
1148
  primaryTopicCount: (tier) => countPrimaryTopics(store, ctx.selfMemberBytes, tier),
834
1149
  config: { cohortSize: ctx.wantK },
835
1150
  });
836
- const traffic = createTrafficCounters({ view, store, selfMember });
1151
+ const traffic = createTrafficCounters({ view, store, selfMember, childCohortCount: (topicId) => childRegistry.count(topicId) });
837
1152
  const promotion = createPromotionLifecycle({
838
1153
  store,
839
1154
  loadBucket: (topicId) => ctx.barometer.bucket(tierOfTopic(store, topicId)),
840
- // Single-cohort milestone: a tier-0 cohort with no children. Child-cohort tracking is a follow-on.
841
- childCohortCount: () => 0,
1155
+ // Real child count off this engine's local registry a converged union across the parent cohort (the
1156
+ // FRET-routed member that records a child gossips a child-link delta; every member merges inbound ones),
1157
+ // so this count is consistent cohort-wide. Blocks demotion while any child is linked; once the last
1158
+ // child demotes (its notice unlinks it here and cohort-wide) the count falls to 0 and the parent can
1159
+ // demote in turn (`promotion.ts` demotionTriggered).
1160
+ childCohortCount: (topicId) => childRegistry.count(topicId),
842
1161
  treeTier: () => treeTier,
843
1162
  // `coord_{d-1}(P, topicId)`; never invoked at the root (demotion is gated on `treeTier > 0`), so
844
1163
  // the `d = 0` branch (clamped to `coord_0`) is a well-formed placeholder that the lifecycle skips.
845
1164
  parentCoord: (topicId) => ctx.addressing.coord(Math.max(0, treeTier - 1), participantCoord, topicId),
1165
+ // The served coord this engine was instantiated at — stamped on every notice as `cohortCoord` and
1166
+ // covered by its threshold signature, so a receiver routes + verifies the notice by exactly this coord.
1167
+ cohortCoord: () => servedCoord,
846
1168
  cohortEpoch: localEpoch,
847
1169
  signer: noticeSigner,
848
1170
  // Production defaults (cap_promote = 64, …) unless the host was given a promotion override — the
@@ -862,7 +1184,20 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
862
1184
  // single-tier-0 milestone has no parent (the root serves immediately), so a unit test exercises this.
863
1185
  const coldStart = createColdStartManager({
864
1186
  parentRegistrar: {
865
- registerWithParent: (topicId, parentCoord, tier, opTier) => registerForwarderWithParent(ctx, { topicId, parentCoord, treeTier: tier, opTier, participantCoord }),
1187
+ registerWithParent: (topicId, parentCoord, tier, opTier) => registerForwarderWithParent(ctx, {
1188
+ topicId,
1189
+ parentCoord,
1190
+ treeTier: tier,
1191
+ opTier,
1192
+ participantCoord,
1193
+ // This engine's served coord IS `coord_d(participantCoord, topicId)` — the child cohort coord the
1194
+ // parent verifies + records against.
1195
+ childCohortCoord: servedCoord,
1196
+ cohortEpoch: localEpoch,
1197
+ // Live-key: the child threshold-signs the link over its own coord/epoch. Key-less interim: no
1198
+ // signer, so the link ships unsigned and the parent permissive-accepts it.
1199
+ signChildLink: canPublish ? childLinkSigner : undefined,
1200
+ }),
866
1201
  },
867
1202
  });
868
1203
  // Per-coord anti-DoS guards (gap 6): each CoordEngine owns its own set — a rate-limit budget / replay
@@ -873,7 +1208,19 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
873
1208
  // so a probe flood cannot exhaust a participant's register budget at this coord, or vice-versa.
874
1209
  const probeRateLimiter = createRegisterRateLimiter(ctx.antiDos.rateLimiter);
875
1210
  const replayGuard = createCorrelationReplayGuard(ctx.antiDos.replayGuard);
876
- const topicBudget = createTopicBudget(ctx.antiDos.topicBudget);
1211
+ // Reconcile the cold-start forwarder set + traffic window with the topic budget on eviction. When the
1212
+ // budget is full and evicts the coldest (zero-participant) resident to make room for a new topic, tear
1213
+ // down the evicted topic's forwarder and traffic counters too — otherwise `servesTopic` stays true off
1214
+ // the leftover forwarder, the topic is served forever with no budget slot, and the forwarder map grows
1215
+ // unbounded. `coldStart` (declared above at the createColdStartManager call) and `traffic` are both
1216
+ // initialized before this point, so the callback closes over live instances.
1217
+ const topicBudget = createTopicBudget({
1218
+ ...ctx.antiDos.topicBudget,
1219
+ onEvict: (topicId) => {
1220
+ coldStart.remove(topicId);
1221
+ traffic.forget(topicId);
1222
+ },
1223
+ });
877
1224
  const renewal = createRenewalCohortSide({
878
1225
  store,
879
1226
  self: ctx.selfMemberBytes,
@@ -887,6 +1234,10 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
887
1234
  evicted: (rec) => pending.evicted(rec),
888
1235
  },
889
1236
  verifyParticipantSig: ctx.verifyParticipantSig,
1237
+ // Freshness gate for the privileged withdraw/reattach paths. Share the register path's skew config
1238
+ // (`ctx.antiDos.replayGuard`, the same `{ maxAgeMs, maxFutureSkewMs }` `createCorrelationReplayGuard`
1239
+ // consumes above) so an operator tuning the window moves the register and renew paths together.
1240
+ freshness: ctx.antiDos.replayGuard,
890
1241
  });
891
1242
  const engine = createCohortMemberEngine({
892
1243
  self: ctx.selfMemberBytes,
@@ -926,34 +1277,53 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
926
1277
  }
927
1278
  return [...byKey.values()];
928
1279
  };
1280
+ // Timestamp of the last frame this engine actually emitted (any frame carries willingness). Drives the
1281
+ // idle-but-willing heartbeat throttle: an idle round heartbeats only if this engine has never emitted
1282
+ // (first idle round → immediate, so bootstrap converges fast) or `T_willingness_heartbeat` has elapsed.
1283
+ // A record-carrying round emits every round and updates this clock, so the throttle governs only
1284
+ // genuinely-idle engines. `undefined` until the first emit.
1285
+ // NOTE: re-broadcasts willingness for every idle-but-willing cohort every T_willingness_heartbeat; if a
1286
+ // node ever serves very many idle cohorts, batch the heartbeats or lengthen the interval.
1287
+ let lastGossipAt;
929
1288
  // One gossip round: sweep stale records (firing the `evicted` deltas), freeze each resident topic's
930
- // traffic summary, drain the touch/evicted deltas, then assemble + sign + broadcast the frame. Idle
931
- // empty engines (no topics, no deltas) build no frame and skip the broadcast.
1289
+ // traffic summary, drain the touch/evicted deltas, then assemble + sign + broadcast the frame. An idle
1290
+ // engine (no topics, no deltas) normally builds no frame except a willingness heartbeat, where an idle
1291
+ // but willing engine still emits a willingness/load-only frame so a cold cohort can bootstrap.
932
1292
  const gossipRound = async (now) => {
933
1293
  engine.sweepStale(now);
934
1294
  const topicSummaries = residentTopics().map((topicId) => toCohortTopicSummary(topicId, traffic.publish(topicId, now), {
935
1295
  tier: tierOfTopic(store, topicId),
936
1296
  directParticipants: store.directParticipants(topicId),
937
1297
  promoted: promotion.isPromoted(topicId),
938
- // Single-cohort milestone: no child cohorts tracked. Child-cohort tracking is a follow-on.
939
- childCohortCount: 0,
1298
+ // Real child count off this engine's local registry a converged union across the parent cohort
1299
+ // (every member gossips its child link/unlink deltas; inbound ones merge here), so this is
1300
+ // consistent cohort-wide, not a single-member shard.
1301
+ childCohortCount: childRegistry.count(topicId),
940
1302
  }));
941
- const { records, evicted } = pending.drain();
1303
+ const { records, evicted, childLinks, childUnlinks } = pending.drain();
1304
+ const idle = topicSummaries.length === 0 && records.length === 0 && evicted.length === 0
1305
+ && childLinks.length === 0 && childUnlinks.length === 0;
1306
+ const heartbeat = idle && (lastGossipAt === undefined || now - lastGossipAt >= ctx.willingnessHeartbeatMs);
942
1307
  const g = buildCohortGossip({
943
1308
  fromMember: selfMember,
944
1309
  coord: bytesToB64url(servedCoord),
945
1310
  cohortEpoch: bytesToB64url(localEpoch()),
1311
+ treeTier,
1312
+ heartbeat,
946
1313
  profile: ctx.profile,
947
1314
  barometer: ctx.barometer,
948
1315
  windowSeconds: DEFAULT_TRAFFIC_WINDOW_SECONDS,
949
1316
  topicSummaries,
950
1317
  records,
951
1318
  evicted,
1319
+ childLinks,
1320
+ childUnlinks,
952
1321
  timestamp: now,
953
1322
  });
954
1323
  if (g === undefined) {
955
1324
  return undefined;
956
1325
  }
1326
+ lastGossipAt = now;
957
1327
  if (ctx.signGossip !== undefined) {
958
1328
  g.signature = await ctx.signGossip(g);
959
1329
  }
@@ -988,14 +1358,40 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
988
1358
  cohort,
989
1359
  cohortIdentityAt: (epoch) => rotationState.membersAt(bytesToB64url(epoch)),
990
1360
  hasState: () => store.listAll().length > 0,
1361
+ hasForwarders: () => coldStart.hasForwarders(),
991
1362
  holds: (topicId, participantId) => store.getByParticipant(topicId, participantId) !== undefined,
992
1363
  records: (topicId) => store.listByTopic(topicId),
993
1364
  topicTraffic: (topicId) => traffic.snapshot(topicId),
1365
+ // NOTE: the only production caller today is the matchmaking QueryV1 serve handler
1366
+ // (matchmaking-query-accounting-seam). When the AggregateCountV1 sweep RPC lands it is another
1367
+ // genuine cohort query and should bump the same seam — reuse `engine.recordQuery` at that call site.
1368
+ // NOTE: unlike `recordArrival` (whose topic always becomes resident via the store), a query can be
1369
+ // recorded against a NON-resident topic (the recordless `findByCoord` serve fallback). `gossipRound`
1370
+ // only `publish`es `residentTopics()`, and `traffic.forget` fires only on topic-budget eviction, so
1371
+ // such a topic's query window prunes only at epoch `reset`. Harmless now (bounded by epoch lifetime +
1372
+ // the serve gate; a few timestamps per query). If a node ever serves high-volume queries for topics it
1373
+ // holds no records for, prune non-resident windows on a timer or forget them when their engine idles.
1374
+ recordQuery: (topicId, now) => traffic.recordQuery(topicId, now),
994
1375
  cohortView: () => view,
995
1376
  servesTopic: (topicId) => store.directParticipants(topicId) > 0 || coldStart.get(topicId) !== undefined,
996
1377
  budgetHasTopic: (topicId) => topicBudget.has(topicId),
997
1378
  budgetParticipantCount: (topicId) => topicBudget.participantCount(topicId),
998
1379
  forwarder: (topicId) => coldStart.get(topicId),
1380
+ // A local record/unrecord (parent-side child-link dispatch / demotion-notice unlink) also enqueues the
1381
+ // corresponding gossip delta on a real state change, so the child set converges across the parent cohort.
1382
+ // A gossip-merged delta writes the registry directly (via the bus `onChildDeltas` callback) and is NOT
1383
+ // re-enqueued here — one broadcast reaches the whole cohort.
1384
+ recordChild: (topicId, childCohortCoord, effectiveAt) => {
1385
+ if (childRegistry.recordChild(topicId, childCohortCoord, effectiveAt)) {
1386
+ pending.childLink(topicId, childCohortCoord, effectiveAt);
1387
+ }
1388
+ },
1389
+ unrecordChild: (topicId, childCohortCoord, effectiveAt) => {
1390
+ if (childRegistry.unrecordChild(topicId, childCohortCoord, effectiveAt)) {
1391
+ pending.childUnlink(topicId, childCohortCoord, effectiveAt);
1392
+ }
1393
+ },
1394
+ childCohortCount: (topicId) => childRegistry.count(topicId),
999
1395
  isPromoted: (topicId) => promotion.isPromoted(topicId),
1000
1396
  applyPromotionNotice: (notice, now) => promotion.applyPromotionNotice(notice, now),
1001
1397
  applyDemotionNotice: (notice, now) => promotion.applyDemotionNotice(notice, now),
@@ -1022,6 +1418,55 @@ export function resolveRenew(registry, renew, now) {
1022
1418
  }
1023
1419
  return holder.engine.handleRenew(renew, now);
1024
1420
  }
1421
+ /**
1422
+ * Parent-side handling of an inbound {@link ChildLinkV1}: bind the parent-child relationship, verify the
1423
+ * child cohort's threshold signature, record the child, and ack. Steps:
1424
+ *
1425
+ * 1. **Bind.** Recompute `coord_childTier(childParticipantCoord, topicId)` and reject unless it equals the
1426
+ * signed `childCohortCoord`; `coord_(childTier−1)(...)` is this parent's served coord. An attacker cannot
1427
+ * point the link at an unrelated parent without a `childParticipantCoord` that also hashes to the signed
1428
+ * child coord (which it cannot, absent the prefix-class membership).
1429
+ * 2. **Verify** (live-key) the child cohort threshold sig against the child cohort's cert; key-less-permissive
1430
+ * short-circuits it. A non-verified result → `rejected`.
1431
+ * 3. **Record** the child on the parent engine (freshness-ordered, idempotent).
1432
+ * 4. Reply `linked`.
1433
+ */
1434
+ export async function dispatchChildLink(link, deps, now) {
1435
+ const topicId = b64urlToBytes(link.topicId);
1436
+ const childParticipantCoord = b64urlToBytes(link.childParticipantCoord);
1437
+ const childCohortCoord = b64urlToBytes(link.childCohortCoord);
1438
+ // Step 1 — bind the relationship. `validateChildLinkV1` already enforced `childTier >= 1`, so `childTier − 1`
1439
+ // is a well-formed parent tier.
1440
+ const recomputedChild = deps.coord(link.childTier, childParticipantCoord, topicId);
1441
+ if (!bytesEqual(recomputedChild, childCohortCoord)) {
1442
+ return { v: 1, result: "rejected", reason: "coord mismatch" };
1443
+ }
1444
+ const parentServedCoord = deps.coord(link.childTier - 1, childParticipantCoord, topicId);
1445
+ // Step 2 — verify the child cohort threshold signature (live-key). Key-less-permissive short-circuits.
1446
+ if (deps.verifyChildLinkSig !== undefined) {
1447
+ const verified = await deps.verifyChildLinkSig(link, now);
1448
+ if (!verified) {
1449
+ return { v: 1, result: "rejected", reason: "child cohort signature not verified" };
1450
+ }
1451
+ }
1452
+ // Step 3 — record on the parent engine (resolving it by its served coord). Resolving may create the parent
1453
+ // engine, so it can hit the registry cap: a full-of-live registry refuses, which we turn into a clean
1454
+ // `rejected` (the child re-links on a later round / after the cohort drains an idle engine) rather than an
1455
+ // unhandled throw on the stream.
1456
+ let parent;
1457
+ try {
1458
+ parent = deps.resolveParent(parentServedCoord, link.childTier - 1, childParticipantCoord);
1459
+ }
1460
+ catch (err) {
1461
+ if (err instanceof CoordEngineRegistryFullError) {
1462
+ return { v: 1, result: "rejected", reason: "parent cohort capacity" };
1463
+ }
1464
+ throw err;
1465
+ }
1466
+ parent.recordChild(topicId, childCohortCoord, link.effectiveAt);
1467
+ // Step 4 — ack.
1468
+ return { v: 1, result: "linked" };
1469
+ }
1025
1470
  /** Tolerated future skew (ms) for a `membership` payload's `stabilizedAt` — a value beyond this is refused. */
1026
1471
  const SIGN_STABILIZED_AT_SKEW_MS = 5_000;
1027
1472
  /** The canonical signable-image tag a non-`rotation` {@link SignKind} must carry (binds tag ↔ kind). */
@@ -1029,6 +1474,7 @@ const SIGNABLE_IMAGE_TAG = {
1029
1474
  membership: "MembershipCertV1",
1030
1475
  promotion: "PromotionNoticeV1",
1031
1476
  demotion: "DemotionNoticeV1",
1477
+ childlink: "ChildLinkV1",
1032
1478
  };
1033
1479
  /**
1034
1480
  * Decode a `/sign` payload's canonical signable array image — `utf8(JSON.stringify([...]))` produced by
@@ -1143,6 +1589,9 @@ export async function handleSignRequest(request, fromPeerStr, deps) {
1143
1589
  // All kinds — bind the payload-internal `cohortEpoch` to our own current epoch (closes the falsified-internal
1144
1590
  // -epoch hole, for promotion / demotion too). It is `image[2]` for a MembershipCertV1 image and the last
1145
1591
  // element for promotion / demotion (see `sig/payloads.ts`).
1592
+ // NOTE: this reads the notice epoch positionally as the LAST element — `sig/payloads.ts` deliberately keeps
1593
+ // `cohortEpoch` last (with the newer `cohortCoord` inserted just before it) to preserve this. Do not append
1594
+ // a field after `cohortEpoch` in those images without updating this read.
1146
1595
  const currentEpochB64 = bytesToB64url(deps.currentEpoch(coord));
1147
1596
  const embeddedEpoch = request.kind === "membership" ? image[2] : image[image.length - 1];
1148
1597
  if (embeddedEpoch !== currentEpochB64) {
@@ -1174,7 +1623,7 @@ export async function handleSignRequest(request, fromPeerStr, deps) {
1174
1623
  return { v: 1, signer: bytesToB64url(deps.selfMember), signature: bytesToB64url(signature) };
1175
1624
  }
1176
1625
  /**
1177
- * Hard cap on tracked `(topicId, tier)` high-water entries; the least-recently-touched are evicted beyond
1626
+ * Hard cap on tracked per-served-coord high-water entries; the least-recently-touched are evicted beyond
1178
1627
  * this. A modest bound is plenty — only verified applies grow the map, so it never evicts under legitimate
1179
1628
  * load — but it caps the otherwise retain-forever shape on a long-lived node.
1180
1629
  */
@@ -1212,8 +1661,8 @@ export const PROMOTE_REFETCH_MIN_INTERVAL_MS = 60_000;
1212
1661
  * Verify an inbound notice's threshold signature against the cohort `MembershipCertV1` for
1213
1662
  * `target.servedCoord` and, on success, apply it to the target's promotion lifecycle. Returns:
1214
1663
  *
1215
- * - `"dropped"` — no local engine serves the notice's `(topic, tier)` (e.g. a demotion arriving at a
1216
- * parent that does not track the child); nothing to apply to.
1664
+ * - `"dropped"` — no local engine serves the notice's carried `cohortCoord` (e.g. a demotion arriving at a
1665
+ * parent-only node that does not serve the demoting child's coord); nothing to apply to.
1217
1666
  * - `"untrusted"` — the `signers` are not a `≥ minSigs` subset of the cohort cert, or the multisig does
1218
1667
  * not verify (a forged single-signer / short-quorum notice); local state is left unchanged.
1219
1668
  * - `"applied"` — verified and applied.
@@ -1261,6 +1710,52 @@ export async function verifyAndApplyNotice(inbound, target, verifier, now) {
1261
1710
  target.applyDemotionNotice(inbound.notice, now);
1262
1711
  return "applied";
1263
1712
  }
1713
+ /**
1714
+ * Parent-side apply of a demotion notice: release (unrecord) the demoting child at its parent cohort. A
1715
+ * demoting child threshold-signs its {@link DemotionNoticeV1} and fans it to **both** its own served coord
1716
+ * (siblings adopt `promoted = false` via {@link verifyAndApplyNotice}) and its `parentCohortCoord`. This is the
1717
+ * second, independent apply semantics at the parent: resolve the parent engine at `parentCohortCoord`, verify
1718
+ * the notice's threshold signature against the **child** cohort cert (identical verify to the sibling-adopt —
1719
+ * signers ⊆ the child cohort cert at the notice's `tier`, keyed by the child's `cohortCoord`), and on
1720
+ * `verified` call `parent.unrecordChild(topicId, cohortCoord, effectiveAt)`.
1721
+ *
1722
+ * Returns:
1723
+ * - `"no-parent"` — this node does not serve `parentCohortCoord`; nothing to unrecord (a pure sibling-adopt node).
1724
+ * - `"untrusted"` — the signature does not verify against the child cohort cert (a forged / under-quorum notice).
1725
+ * - `"unlinked"` — verified; the child was unrecorded (or was already released — the child registry's own
1726
+ * per-`(topic, childCoord)` freshness makes a replay an idempotent no-op).
1727
+ *
1728
+ * **Freshness is the child registry's, not the promote-gate high-water.** The sibling-adopt high-water is keyed
1729
+ * by the child coord and advanced only on a sibling-adopt `"applied"`; the unlink is ordered independently by
1730
+ * the child registry's per-child `lastEffectiveAt`, so a demotion that is a stale no-op for the sibling-adopt
1731
+ * target still applies the unlink at the parent, and vice-versa. The verify carries the same
1732
+ * {@link PROMOTE_REFETCH_MIN_INTERVAL_MS} bound as the sibling-adopt, so it cannot amplify into dials.
1733
+ */
1734
+ export async function applyDemotionUnlinkAtParent(notice, registry, verifier, now) {
1735
+ const parent = registry.findByCoord(b64urlToBytes(notice.parentCohortCoord));
1736
+ if (parent === undefined) {
1737
+ return "no-parent";
1738
+ }
1739
+ let signers;
1740
+ let sig;
1741
+ try {
1742
+ signers = notice.signers.map(b64urlToBytes);
1743
+ sig = b64urlToBytes(notice.thresholdSig);
1744
+ }
1745
+ catch {
1746
+ return "untrusted"; // a signer / sig that is not valid base64url cannot verify
1747
+ }
1748
+ // Verify against the CHILD cohort cert (the demoting cohort's served coord `notice.cohortCoord`), NOT the
1749
+ // parent's — the demotion is threshold-signed by the child cohort. Same bounded refetch as the sibling-adopt.
1750
+ const payload = demotionNoticeSigningPayload(notice);
1751
+ const result = await verifier.verifyMessage(signers, b64urlToBytes(notice.cohortCoord), notice.tier, payload, sig, { minRefetchIntervalMs: PROMOTE_REFETCH_MIN_INTERVAL_MS, now });
1752
+ if (result !== "verified") {
1753
+ return "untrusted";
1754
+ }
1755
+ // `cohortCoord` is the child's served coord — the key the parent recorded the child under.
1756
+ parent.unrecordChild(b64urlToBytes(notice.topicId), b64urlToBytes(notice.cohortCoord), notice.effectiveAt);
1757
+ return "unlinked";
1758
+ }
1264
1759
  /**
1265
1760
  * Full inbound `promote`-frame pipeline with the anti-abuse gate, exported so it is unit-testable without a
1266
1761
  * live node (`cohort-topic-promote-handler-verify-amplification`). Runs the cheapest checks first — each
@@ -1268,14 +1763,27 @@ export async function verifyAndApplyNotice(inbound, target, verifier, now) {
1268
1763
  * work:
1269
1764
  *
1270
1765
  * ```
1271
- * decode → per-(peer,topic) rate limit → findServing → effectiveAt high-water → verify+apply
1766
+ * decode → per-(peer,topic) rate limit → resolve engine by carried cohortCoord → effectiveAt high-water → verify+apply
1767
+ * ↘ (demotion) parent-unlink at parentCohortCoord
1272
1768
  * ```
1273
1769
  *
1770
+ * A **demotion** carries a second, independent apply semantics: beyond the sibling-adopt above, it also
1771
+ * releases the demoting child at its parent cohort ({@link applyDemotionUnlinkAtParent}). Both paths may fire
1772
+ * on one node (one that serves both the child coord and the parent coord). The parent-unlink runs OUTSIDE the
1773
+ * sibling-adopt high-water (which is keyed by the child coord and would otherwise stale-drop the parent-coord
1774
+ * frame after the child-coord frame advanced it); its freshness is the child registry's own per-child key.
1775
+ * `"unlinked"` is returned when the unlink fired but the sibling-adopt did not (a parent-only node).
1776
+ *
1274
1777
  * - **Rate limit** (`gate.rateLimiter`) keys on `(from, topicId)`; an over-rate peer is dropped before the
1275
- * `findServing` map scan and the verify, so a peer cannot amplify junk into verify/network work.
1276
- * - **High-water** (`gate.highWater`, per `(topicId, tier)`) drops a notice whose `effectiveAt` is at or
1277
- * below the last *applied* one a replay / out-of-order frame before `verifyMessage`. It is advanced
1278
- * **only** on an `"applied"` outcome, so a forged frame (which never verifies) cannot poison it.
1778
+ * coord lookup and the verify, so a peer cannot amplify junk into verify/network work.
1779
+ * - **Resolve engine by `cohortCoord`** ({@link CoordRegistry.findByCoord}) the exact served coord the
1780
+ * notice was decided for, covered by its signature. A node serving several sibling cohorts for one
1781
+ * `(topic, tier)` applies the notice to the cohort that produced it, never a first-match `(topic, tier)`
1782
+ * scan; a coord this node does not serve is dropped.
1783
+ * - **High-water** (`gate.highWater`, keyed per served `cohortCoord`) drops a notice whose `effectiveAt` is
1784
+ * at or below the last *applied* one — a replay / out-of-order frame — before `verifyMessage`. It is
1785
+ * advanced **only** on an `"applied"` outcome, so a forged frame (which never verifies) cannot poison it.
1786
+ * Keying by coord (not `(topic, tier)`) keeps two sibling cohorts on one node from sharing a water.
1279
1787
  * - The receiver-side `cohortEpoch` is intentionally **not** gated on: the epoch rotates on every
1280
1788
  * membership change, so a legitimately in-flight notice can briefly carry the prior epoch right after a
1281
1789
  * rotation — making an epoch check a brittle, false-positive-prone filter. The rate limiter + high-water
@@ -1293,32 +1801,68 @@ export async function handleInboundNotice(frame, from, registry, verifier, gate,
1293
1801
  }
1294
1802
  const tier = inbound.kind === "promotion" ? inbound.notice.fromTier : inbound.notice.tier;
1295
1803
  const topicId = b64urlToBytes(inbound.notice.topicId);
1296
- // Per-(peer, topic) rate limit — before the findServing scan and the verify.
1804
+ // Per-(peer, topic) rate limit — before the coord lookup and the verify.
1297
1805
  if (gate.rateLimiter.check(from, topicId, now).ok === false) {
1298
1806
  log("promote: rate-limited %s notice for topic %s tier %d", inbound.kind, inbound.notice.topicId, tier);
1299
1807
  return "rate-limited";
1300
1808
  }
1301
- const target = registry.findServing(topicId, tier);
1809
+ // --- Sibling-adopt path: apply to the cohort that produced the notice (its signed `cohortCoord`). ---
1810
+ // Route by the notice's signed `cohortCoord` — the exact served coord the deciding cohort sits at. A node
1811
+ // serving several sibling cohorts for one `(topic, tier)` (possible at `d ≥ 1`) thus applies the notice to
1812
+ // the cohort that produced it, and `verifyAndApplyNotice` verifies against that same coord's cert. The coord
1813
+ // is covered by the threshold signature, so it cannot be rewritten to hijack a sibling. A coord this node
1814
+ // does not serve → `dropped` here (e.g. a demotion fanned to a parent-only node — the parent-unlink path
1815
+ // below still runs).
1816
+ const target = registry.findByCoord(b64urlToBytes(inbound.notice.cohortCoord));
1817
+ let siblingOutcome;
1302
1818
  if (target === undefined) {
1303
- log("promote: dropped %s notice for topic %s tier %d (no serving engine)", inbound.kind, inbound.notice.topicId, tier);
1304
- return "dropped";
1819
+ siblingOutcome = "dropped";
1820
+ }
1821
+ else {
1822
+ // Freshness / replay gate: drop an at-or-below-high-water notice before the expensive verify. Keyed by the
1823
+ // served coord (which uniquely identifies the cohort) so two sibling cohorts on one node do not share a
1824
+ // high-water — an applied notice for cohort A must not stale-drop a legitimate cohort-B notice. `tier` is
1825
+ // kept in the key only for readability.
1826
+ const waterKey = `${inbound.notice.cohortCoord}|${tier}`;
1827
+ const water = gate.highWater.get(waterKey);
1828
+ if (water !== undefined && inbound.notice.effectiveAt <= water) {
1829
+ log("promote: stale %s notice for topic %s tier %d (effectiveAt %d <= high-water %d)", inbound.kind, inbound.notice.topicId, tier, inbound.notice.effectiveAt, water);
1830
+ siblingOutcome = "stale";
1831
+ }
1832
+ else {
1833
+ siblingOutcome = await verifyAndApplyNotice(inbound, target, verifier, now);
1834
+ if (siblingOutcome === "applied") {
1835
+ // Advance the high-water only on a *verified-and-applied* notice, so a forged frame cannot poison it.
1836
+ gate.highWater.set(waterKey, inbound.notice.effectiveAt);
1837
+ }
1838
+ else {
1839
+ log("promote: %s %s notice for topic %s tier %d", siblingOutcome, inbound.kind, inbound.notice.topicId, tier);
1840
+ }
1841
+ }
1305
1842
  }
1306
- // Freshness / replay gate: drop an at-or-below-high-water notice before the expensive verify.
1307
- const waterKey = `${inbound.notice.topicId}|${tier}`;
1308
- const water = gate.highWater.get(waterKey);
1309
- if (water !== undefined && inbound.notice.effectiveAt <= water) {
1310
- log("promote: stale %s notice for topic %s tier %d (effectiveAt %d <= high-water %d)", inbound.kind, inbound.notice.topicId, tier, inbound.notice.effectiveAt, water);
1311
- return "stale";
1843
+ if (inbound.kind !== "demotion") {
1844
+ return siblingOutcome;
1312
1845
  }
1313
- const outcome = await verifyAndApplyNotice(inbound, target, verifier, now);
1314
- if (outcome === "applied") {
1315
- // Advance the high-water only on a *verified-and-applied* notice, so a forged frame cannot poison it.
1316
- gate.highWater.set(waterKey, inbound.notice.effectiveAt);
1846
+ // --- Parent-unlink path (demotion only): additionally release the demoting child at its parent cohort. ---
1847
+ // A demotion is fanned to BOTH the child coord (sibling-adopt, above) and the parent coord (this unlink),
1848
+ // arriving as two independent frames. This path is deliberately OUTSIDE the sibling-adopt high-water: that
1849
+ // water is keyed by the child coord and advanced only on a sibling-adopt apply, so — on a node serving both
1850
+ // coords — the child-coord frame would advance the water and stale-drop the parent-coord frame before it
1851
+ // could unrecord. The child registry's own per-`(topic, childCoord)` freshness orders the unlink instead, so
1852
+ // a replay is an idempotent no-op. The verify is against the child cohort cert (same as the sibling-adopt),
1853
+ // so a forged demotion cannot unrecord.
1854
+ const unlink = await applyDemotionUnlinkAtParent(inbound.notice, registry, verifier, now);
1855
+ if (siblingOutcome === "applied") {
1856
+ return "applied"; // a node serving both coords: the sibling-adopt is the primary reported outcome
1317
1857
  }
1318
- else {
1319
- log("promote: %s %s notice for topic %s tier %d", outcome, inbound.kind, inbound.notice.topicId, tier);
1858
+ if (unlink === "unlinked") {
1859
+ return "unlinked";
1320
1860
  }
1321
- return outcome;
1861
+ if (unlink === "untrusted") {
1862
+ log("promote: untrusted demotion notice at parent for topic %s tier %d", inbound.notice.topicId, tier);
1863
+ return "untrusted";
1864
+ }
1865
+ return siblingOutcome;
1322
1866
  }
1323
1867
  /**
1324
1868
  * The cohort coords a notice is broadcast to over the `promote` protocol: always the cohort around
@@ -1343,7 +1887,7 @@ function crossCheckCohort(fret, wantK, servedCoord, fretCohort) {
1343
1887
  log("cohort cross-check mismatch at coord %s: FRET-routed=%o assembled=%o; trusting the recomputed assembly", bytesToB64url(servedCoord), fretCohort, assembled);
1344
1888
  }
1345
1889
  }
1346
- /** Positional equality over two string lists (the first-`k x` rotation-change check). */
1890
+ /** Positional equality over two string lists (the endorser's sorted-member-list image check). */
1347
1891
  function sameStringOrder(a, b) {
1348
1892
  if (a.length !== b.length) {
1349
1893
  return false;
@@ -1378,15 +1922,24 @@ function tierOfTopic(store, topicId) {
1378
1922
  const recs = store.listByTopic(topicId);
1379
1923
  return (recs.length > 0 ? recs[0].tier : 0);
1380
1924
  }
1381
- async function registerProtocolHandlers(node, protocols, registry, dispatchRegister, signEndorse, verifier, promoteGate, gossipTransport, publishSink, membershipSource, selfCoord, maxBytes) {
1925
+ async function registerProtocolHandlers(node, protocols, registry, dispatchRegister,
1926
+ /** Parent-side child-link dispatch deps + the quorum bound used to structurally validate an inbound link. */
1927
+ childLinkDeps, minSigs, signEndorse, verifier, promoteGate, gossipTransport,
1928
+ /** Instantiate a cold sibling's coord engine off a verified co-member frame (§Cold-start instantiation). */
1929
+ maybeInstantiateColdSibling, publishSink, membershipSource, selfCoord, maxBytes) {
1382
1930
  await Promise.all([
1383
- // register: a direct dial carries either a RegisterV1 (re-attach walk fallback) or a RenewV1 (ping).
1931
+ // register: a direct dial carries a RenewV1 (ping), a ChildLinkV1 (a child cohort registering with this
1932
+ // parent), or a RegisterV1 (re-attach walk fallback). The three shapes are disjoint, so try each in turn.
1384
1933
  node.handle(protocols.register, makeFrameHandler(async (frame) => {
1385
1934
  const decoded = decodeCohortMessage(frame, maxBytes);
1386
1935
  const renew = tryValidate(() => validateRenewV1(decoded));
1387
1936
  if (renew !== undefined) {
1388
1937
  return encodeCohortMessage(resolveRenew(registry, renew, Date.now()), maxBytes);
1389
1938
  }
1939
+ const link = tryValidate(() => validateChildLinkV1(decoded, minSigs));
1940
+ if (link !== undefined) {
1941
+ return encodeCohortMessage(await dispatchChildLink(link, childLinkDeps, Date.now()), maxBytes);
1942
+ }
1390
1943
  const reg = validateRegisterV1(decoded);
1391
1944
  // Direct dial (not FRET-routed): no cohort member list to cross-check against.
1392
1945
  const reply = await dispatchRegister(reg, undefined, Date.now());
@@ -1394,7 +1947,10 @@ async function registerProtocolHandlers(node, protocols, registry, dispatchRegis
1394
1947
  }, maxBytes)),
1395
1948
  // cohort-gossip: feed inbound gossip into the shared transport (one-way). It fans the frame to
1396
1949
  // every coord engine's bus; per-bus epoch matching governs which engine merges the record deltas.
1950
+ // First, if this is a verified co-member frame for a coord we hold no engine for, instantiate that
1951
+ // engine (§Cold-start instantiation) so its freshly-subscribed bus merges this very frame on `deliver`.
1397
1952
  node.handle(protocols.gossip, makeFrameHandler(async (frame, from) => {
1953
+ maybeInstantiateColdSibling(frame);
1398
1954
  gossipTransport.deliver(from.toString(), frame);
1399
1955
  return undefined;
1400
1956
  }, maxBytes)),