@optimystic/db-core 0.22.0 → 0.24.1

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 (141) hide show
  1. package/README.md +336 -336
  2. package/dist/src/cluster/structs.d.ts +39 -1
  3. package/dist/src/cluster/structs.d.ts.map +1 -1
  4. package/dist/src/cluster/structs.js +24 -0
  5. package/dist/src/cluster/structs.js.map +1 -1
  6. package/dist/src/collection/collection.d.ts +17 -0
  7. package/dist/src/collection/collection.d.ts.map +1 -1
  8. package/dist/src/collection/collection.js +24 -2
  9. package/dist/src/collection/collection.js.map +1 -1
  10. package/dist/src/collections/tree/tree.d.ts +5 -0
  11. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  12. package/dist/src/collections/tree/tree.js +7 -0
  13. package/dist/src/collections/tree/tree.js.map +1 -1
  14. package/dist/src/network/i-peer-network.d.ts +16 -0
  15. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  16. package/dist/src/network/struct.d.ts +39 -2
  17. package/dist/src/network/struct.d.ts.map +1 -1
  18. package/dist/src/network/struct.js +18 -0
  19. package/dist/src/network/struct.js.map +1 -1
  20. package/dist/src/testing/test-transactor.d.ts +95 -8
  21. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  22. package/dist/src/testing/test-transactor.js +121 -8
  23. package/dist/src/testing/test-transactor.js.map +1 -1
  24. package/dist/src/transaction/transaction.d.ts +1 -1
  25. package/dist/src/transaction/transaction.js +1 -1
  26. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  27. package/dist/src/transactor/network-transactor.js +48 -13
  28. package/dist/src/transactor/network-transactor.js.map +1 -1
  29. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  30. package/dist/src/transactor/transactor-source.js +25 -2
  31. package/dist/src/transactor/transactor-source.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cluster/membership.ts +85 -85
  34. package/src/cluster/structs.ts +43 -4
  35. package/src/cohort-topic/addressing.ts +120 -120
  36. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  37. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  38. package/src/cohort-topic/antidos/index.ts +5 -5
  39. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  40. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  41. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  42. package/src/cohort-topic/antiflood/index.ts +2 -2
  43. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  44. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  45. package/src/cohort-topic/coldstart.ts +237 -237
  46. package/src/cohort-topic/dmax.ts +88 -88
  47. package/src/cohort-topic/gossip/bus.ts +254 -254
  48. package/src/cohort-topic/gossip/index.ts +3 -3
  49. package/src/cohort-topic/gossip/records.ts +45 -45
  50. package/src/cohort-topic/gossip/view.ts +91 -91
  51. package/src/cohort-topic/index.ts +20 -20
  52. package/src/cohort-topic/load/barometer.ts +134 -134
  53. package/src/cohort-topic/load/index.ts +1 -1
  54. package/src/cohort-topic/member-engine.ts +430 -430
  55. package/src/cohort-topic/membership/index.ts +3 -3
  56. package/src/cohort-topic/membership/publisher.ts +163 -163
  57. package/src/cohort-topic/membership/source.ts +41 -41
  58. package/src/cohort-topic/membership/verifier.ts +461 -461
  59. package/src/cohort-topic/ports.ts +157 -157
  60. package/src/cohort-topic/promotion.ts +405 -405
  61. package/src/cohort-topic/registration/bytes.ts +37 -37
  62. package/src/cohort-topic/registration/handoff.ts +154 -154
  63. package/src/cohort-topic/registration/index.ts +6 -6
  64. package/src/cohort-topic/registration/renewal.ts +495 -495
  65. package/src/cohort-topic/registration/sharding.ts +61 -61
  66. package/src/cohort-topic/registration/store.ts +81 -81
  67. package/src/cohort-topic/registration/types.ts +91 -91
  68. package/src/cohort-topic/ring-hash.ts +50 -50
  69. package/src/cohort-topic/service.ts +416 -416
  70. package/src/cohort-topic/sig/index.ts +2 -2
  71. package/src/cohort-topic/sig/payloads.ts +59 -59
  72. package/src/cohort-topic/sig/threshold.ts +64 -64
  73. package/src/cohort-topic/tiers.ts +74 -74
  74. package/src/cohort-topic/traffic.ts +233 -233
  75. package/src/cohort-topic/walk.ts +326 -326
  76. package/src/cohort-topic/willingness.ts +237 -237
  77. package/src/cohort-topic/wire/codec.ts +216 -216
  78. package/src/cohort-topic/wire/index.ts +18 -18
  79. package/src/cohort-topic/wire/payloads.ts +126 -126
  80. package/src/cohort-topic/wire/primitives.ts +188 -188
  81. package/src/cohort-topic/wire/types.ts +475 -475
  82. package/src/cohort-topic/wire/validate.ts +512 -512
  83. package/src/collection/collection-type-registry.ts +37 -37
  84. package/src/collection/collection.ts +25 -2
  85. package/src/collections/diary/diary.ts +68 -68
  86. package/src/collections/tree/readme.md +4 -0
  87. package/src/collections/tree/tree.ts +320 -312
  88. package/src/matchmaking/capability-filter.ts +45 -45
  89. package/src/matchmaking/config.ts +98 -98
  90. package/src/matchmaking/index.ts +21 -21
  91. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  92. package/src/matchmaking/provider.ts +123 -123
  93. package/src/matchmaking/query-eval.ts +105 -105
  94. package/src/matchmaking/seeker-walk.ts +127 -127
  95. package/src/matchmaking/seeker.ts +86 -86
  96. package/src/matchmaking/topic-anchor.ts +90 -90
  97. package/src/matchmaking/voting-quorum.ts +394 -394
  98. package/src/matchmaking/wire.ts +603 -603
  99. package/src/network/i-peer-network.ts +17 -0
  100. package/src/network/stale-failure.ts +43 -43
  101. package/src/network/struct.ts +41 -2
  102. package/src/network/types.ts +37 -37
  103. package/src/reactivity/backfill.ts +220 -220
  104. package/src/reactivity/backpressure.ts +191 -191
  105. package/src/reactivity/checkpoint.ts +308 -308
  106. package/src/reactivity/config.ts +172 -172
  107. package/src/reactivity/dedupe.ts +132 -132
  108. package/src/reactivity/forwarder.ts +87 -87
  109. package/src/reactivity/index.ts +34 -34
  110. package/src/reactivity/notification.ts +123 -123
  111. package/src/reactivity/policy.ts +79 -79
  112. package/src/reactivity/push-state.ts +310 -310
  113. package/src/reactivity/recover.ts +153 -153
  114. package/src/reactivity/replay-buffer.ts +141 -141
  115. package/src/reactivity/resume.ts +549 -549
  116. package/src/reactivity/rotation.ts +415 -415
  117. package/src/reactivity/subscriber.ts +132 -132
  118. package/src/reactivity/subscription.ts +66 -66
  119. package/src/reactivity/topic-anchor.ts +71 -71
  120. package/src/reactivity/verify.ts +73 -73
  121. package/src/reactivity/wire-validate.ts +13 -13
  122. package/src/reactivity/wire.ts +224 -224
  123. package/src/testing/async-wait.ts +65 -65
  124. package/src/testing/index.ts +2 -2
  125. package/src/testing/test-transactor.ts +638 -502
  126. package/src/transaction/errors.ts +91 -91
  127. package/src/transaction/operations-hash.ts +196 -196
  128. package/src/transaction/read-dependency-collector.ts +78 -78
  129. package/src/transaction/transaction.ts +1 -1
  130. package/src/transactor/change-notifier.ts +80 -80
  131. package/src/transactor/index.ts +5 -5
  132. package/src/transactor/network-transactor.ts +49 -14
  133. package/src/transactor/transactor-source.ts +25 -2
  134. package/src/transform/atomic-proxy.ts +92 -92
  135. package/src/transform/helpers.ts +159 -159
  136. package/src/utility/backoff.ts +95 -95
  137. package/src/utility/batch-coordinator.ts +191 -191
  138. package/dist/src/transaction/context.d.ts +0 -60
  139. package/dist/src/transaction/context.d.ts.map +0 -1
  140. package/dist/src/transaction/context.js +0 -91
  141. package/dist/src/transaction/context.js.map +0 -1
@@ -1,405 +1,405 @@
1
- /**
2
- * Cohort-topic substrate — promotion / demotion lifecycle.
3
- *
4
- * Transcribed from `docs/cohort-topic.md` §Promotion and demotion lifecycle and folded back from the
5
- * simulator-validated `packages/substrate-simulator/src/topic-tree.ts`. A forwarder cohort grows
6
- * (promotes) and shrinks (demotes) for a topic `T` based on its per-topic direct-participant count
7
- * and the per-tier load barometer; both transitions are **threshold-signed** (via the gossip ticket's
8
- * {@link CohortSigner}) so participants can verify them.
9
- *
10
- * **Promote** when, for a quorum (the quorum is enforced by the threshold signature — `minSigs`
11
- * signers must agree to the notice):
12
- * - `directParticipants(T) ≥ cap_promote` (64), OR
13
- * - `loadBucket[tier(T)] ≥ bucket_overload` (6) AND `directParticipants(T) ≥ cap_promote_fast` (32)
14
- * — the hot-load fast path from the capacity-barometer ticket, OR
15
- * - the growth **slope** predicts crossing `cap_promote` within `T_promote_lookahead` (30s) — fires
16
- * early to avoid the gossip-lag overshoot (`§Promotion`: pre-promotion on slope).
17
- *
18
- * Once promoted, the state is **sticky** for ≥ `T_promote_sticky` (60s) before it can be reconsidered
19
- * for demotion, so transient count drops don't flap a cohort back to accepting.
20
- *
21
- * **Demote** when, for a quorum: `directParticipants(T) ≤ cap_demote` (16) AND that has held for
22
- * ≥ `T_demote` (5min) AND the cohort has no live child cohorts AND it has a parent (the root, tree
23
- * tier 0, never demotes — it has nowhere to hand off). The `4×` gap between `cap_promote` and
24
- * `cap_demote` plus `T_demote` is the hysteresis that prevents thrash.
25
- *
26
- * **Deviation from the doc's interface sketch (documented).** `docs/cohort-topic.md` sketches
27
- * `onParticipantCountChange` as returning `void` and "may emit `PromotionNoticeV1`". Because
28
- * threshold signing is asynchronous ({@link CohortSigner.thresholdSign} resolves a Promise), the
29
- * methods here are `async` and **return** the signed notice (or `undefined`) instead of emitting it
30
- * through a side channel — the caller broadcasts whatever it gets back. This keeps the module pure
31
- * and unit-testable and avoids an awkward async callback. The op-tier `tier(T)` the barometer is
32
- * indexed by is supplied through the injected {@link PromotionDeps.loadBucket} resolver, so this
33
- * module never needs to know a topic's tier.
34
- *
35
- * **Remote apply path.** `promote()` / `demote()` set this cohort's per-topic state as a side effect of
36
- * the **local** signing path — only the member that originates a notice adopts the new state that way. A
37
- * member that *learns* of a promotion/demotion via gossip or a broadcast notice adopts it through
38
- * {@link PromotionLifecycle.applyPromotionNotice} / {@link PromotionLifecycle.applyDemotionNotice},
39
- * which set the same {@link PromotionState} **without re-signing** (the notice is already a verified
40
- * quorum decision). **Precondition: the caller has already verified the notice's threshold signature**
41
- * (db-p2p's `MembershipVerifier`); this module is crypto-free and trusts that gate. The apply path is
42
- * idempotent and `effectiveAt`-ordered via the monotonic {@link PromotionState.lastEffectiveAt}
43
- * high-water mark, so re-applying a notice (including this member's own echoed broadcast) or a replayed
44
- * older notice is a no-op — a stale promotion can never un-demote a cohort that has since demoted.
45
- */
46
-
47
- import { b64urlToBytes, bytesToB64url } from "./wire/codec.js";
48
- import type { CohortSigner } from "./sig/threshold.js";
49
- import { demotionNoticeSigningPayload, promotionNoticeSigningPayload } from "./sig/payloads.js";
50
- import type { DemotionNoticeV1, PromotionNoticeV1 } from "./wire/types.js";
51
- import type { RegistrationStore } from "./registration/types.js";
52
- import { bytesKey } from "./registration/bytes.js";
53
-
54
- // --- defaults (docs/cohort-topic.md §Configuration; simulator-confirmed) ---
55
-
56
- /** Direct-participant cap before promotion. */
57
- export const DEFAULT_CAP_PROMOTE = 64;
58
- /** Direct-participant cap when the load barometer is hot (fast path). */
59
- export const DEFAULT_CAP_PROMOTE_FAST = 32;
60
- /** Load-barometer bucket at/above which the fast promote path is armed. */
61
- export const DEFAULT_BUCKET_OVERLOAD = 6;
62
- /** Direct-participant floor for demotion (= `cap_promote / 4`). */
63
- export const DEFAULT_CAP_DEMOTE = 16;
64
- /** Hysteresis window the low-load condition must hold before demotion (ms). */
65
- export const DEFAULT_T_DEMOTE_MS = 300_000;
66
- /** Pre-promotion slope lookahead (ms). */
67
- export const DEFAULT_T_PROMOTE_LOOKAHEAD_MS = 30_000;
68
- /** Minimum time a cohort stays promoted before it can be reconsidered for demotion (ms). */
69
- export const DEFAULT_T_PROMOTE_STICKY_MS = 60_000;
70
- /**
71
- * Slope window over which the growth rate is measured for pre-promotion. **Not pinned by
72
- * `docs/cohort-topic.md`** — the simulator (`topic-tree.ts` `DEFAULT_LIFECYCLE_CONFIG`) uses 10s and
73
- * this matches it.
74
- */
75
- export const DEFAULT_GROWTH_WINDOW_MS = 10_000;
76
-
77
- export interface PromotionConfig {
78
- capPromote?: number;
79
- capPromoteFast?: number;
80
- bucketOverload?: number;
81
- capDemote?: number;
82
- tDemoteMs?: number;
83
- tPromoteLookaheadMs?: number;
84
- tPromoteStickyMs?: number;
85
- growthWindowMs?: number;
86
- }
87
-
88
- /** A sampled `(time, count)` growth point, for slope-based pre-promotion. */
89
- interface GrowthSample {
90
- readonly t: number;
91
- readonly count: number;
92
- }
93
-
94
- /** Per-topic promotion bookkeeping held by this cohort. */
95
- interface PromotionState {
96
- promoted: boolean;
97
- /** When the cohort last entered promoted mode (sticky-window anchor). */
98
- promotedAt?: number;
99
- /** When `directParticipants` last dropped to ≤ `cap_demote` (demotion hysteresis anchor). */
100
- lowLoadSince?: number;
101
- /** Recent growth samples within `growthWindowMs`, for slope extrapolation. */
102
- samples: GrowthSample[];
103
- /**
104
- * `effectiveAt` of the most recent promotion/demotion this cohort has adopted — locally originated
105
- * (`promote()` / `demote()`) or remotely applied (`applyPromotionNotice` / `applyDemotionNotice`).
106
- * Monotonic and **never cleared** (a demotion clears `promoted`/`promotedAt` but keeps this), so it
107
- * is the high-water mark the remote-apply path orders against: a notice whose `effectiveAt` is not
108
- * strictly newer is a no-op — re-applied (incl. this member's own echoed broadcast) or stale-replayed.
109
- */
110
- lastEffectiveAt?: number;
111
- }
112
-
113
- export interface PromotionDeps {
114
- /** Replicated registration store — supplies the per-topic `directParticipants` stock count. */
115
- store: Pick<RegistrationStore, "directParticipants">;
116
- /**
117
- * Current load-barometer bucket (0..7) for the topic's op tier `tier(T)`. The caller resolves the
118
- * topic→tier mapping and the barometer lookup, keeping this module tier-agnostic and FRET-free.
119
- */
120
- loadBucket: (topicId: Uint8Array) => number;
121
- /** Live child-cohort count for the topic (0 if none); `> 0` blocks demotion. */
122
- childCohortCount: (topicId: Uint8Array) => number;
123
- /** Tree tier `d` this cohort serves the topic at — `fromTier` on a notice; `toTier = d + 1`. */
124
- treeTier: (topicId: Uint8Array) => number;
125
- /** The tier-`(d − 1)` parent cohort coord, target of a {@link DemotionNoticeV1}. */
126
- parentCoord: (topicId: Uint8Array) => Uint8Array;
127
- /**
128
- * The served coord `coord_d(participantCoord, topicId)` this cohort sits at (raw bytes) — stamped on
129
- * every notice as `cohortCoord` and covered by its threshold signature, so a receiver routes the notice
130
- * to exactly this cohort's engine and verifies it against exactly this cohort's cert. Constant for the
131
- * engine's lifetime (the engine is instantiated at one served coord).
132
- */
133
- cohortCoord: () => Uint8Array;
134
- /** Cohort epoch (raw bytes) the notices are signed under. */
135
- cohortEpoch: () => Uint8Array;
136
- /** Threshold signer (the gossip ticket's `k − x` cohort signer). */
137
- signer: CohortSigner;
138
- config?: PromotionConfig;
139
- }
140
-
141
- /**
142
- * Promotion / demotion lifecycle for one cohort across all the topics it serves. Keyed by `topicId`;
143
- * each topic carries its own promoted/low-load/sticky state.
144
- */
145
- export interface PromotionLifecycle {
146
- /**
147
- * React to a change in `topicId`'s direct-participant count (call after every arrival/eviction).
148
- * Refreshes the growth + hysteresis clocks and, if a promote trigger fires and the cohort is not
149
- * already promoted, threshold-signs and returns a {@link PromotionNoticeV1}. Returns `undefined`
150
- * when no promotion fires.
151
- */
152
- onParticipantCountChange(topicId: Uint8Array, now: number): Promise<PromotionNoticeV1 | undefined>;
153
- /**
154
- * Time-driven demotion check (call on the gossip tick). Returns a threshold-signed
155
- * {@link DemotionNoticeV1} for the parent when every demotion condition holds, else `undefined`.
156
- */
157
- maybeDemote(topicId: Uint8Array, now: number): Promise<DemotionNoticeV1 | undefined>;
158
- /** Whether `topicId` is currently in promoted mode (new registrations get `Promoted(d+1)`). */
159
- isPromoted(topicId: Uint8Array): boolean;
160
- /**
161
- * Adopt a promotion this member did **not** originate (learned via a verified broadcast notice).
162
- * Sets `promoted = true` for `n.topicId` without re-signing. **Precondition: the caller has verified
163
- * `n`'s threshold signature** — this module performs no crypto. Idempotent and `effectiveAt`-ordered
164
- * (see {@link PromotionState.lastEffectiveAt}): a notice not strictly newer than the last adopted
165
- * transition is a no-op, so this member's own echoed broadcast and stale replays are absorbed.
166
- */
167
- applyPromotionNotice(n: PromotionNoticeV1, now: number): void;
168
- /**
169
- * Adopt a demotion this member did **not** originate (learned via a verified broadcast notice).
170
- * Clears `promoted` state for `n.topicId` without re-signing. Same precondition, idempotency, and
171
- * `effectiveAt` ordering as {@link applyPromotionNotice} — a stale promotion can never un-demote.
172
- */
173
- applyDemotionNotice(n: DemotionNoticeV1, now: number): void;
174
- }
175
-
176
- class CohortPromotionLifecycle implements PromotionLifecycle {
177
- private readonly states = new Map<string, PromotionState>();
178
- private readonly capPromote: number;
179
- private readonly capPromoteFast: number;
180
- private readonly bucketOverload: number;
181
- private readonly capDemote: number;
182
- private readonly tDemoteMs: number;
183
- private readonly tPromoteLookaheadMs: number;
184
- private readonly tPromoteStickyMs: number;
185
- private readonly growthWindowMs: number;
186
-
187
- constructor(private readonly deps: PromotionDeps) {
188
- const cfg = deps.config ?? {};
189
- this.capPromote = cfg.capPromote ?? DEFAULT_CAP_PROMOTE;
190
- this.capPromoteFast = cfg.capPromoteFast ?? DEFAULT_CAP_PROMOTE_FAST;
191
- this.bucketOverload = cfg.bucketOverload ?? DEFAULT_BUCKET_OVERLOAD;
192
- this.capDemote = cfg.capDemote ?? DEFAULT_CAP_DEMOTE;
193
- this.tDemoteMs = cfg.tDemoteMs ?? DEFAULT_T_DEMOTE_MS;
194
- this.tPromoteLookaheadMs = cfg.tPromoteLookaheadMs ?? DEFAULT_T_PROMOTE_LOOKAHEAD_MS;
195
- this.tPromoteStickyMs = cfg.tPromoteStickyMs ?? DEFAULT_T_PROMOTE_STICKY_MS;
196
- this.growthWindowMs = cfg.growthWindowMs ?? DEFAULT_GROWTH_WINDOW_MS;
197
- }
198
-
199
- async onParticipantCountChange(topicId: Uint8Array, now: number): Promise<PromotionNoticeV1 | undefined> {
200
- const state = this.stateFor(topicId);
201
- const count = this.deps.store.directParticipants(topicId);
202
- this.pushGrowthSample(state, count, now);
203
- this.refreshLowLoadClock(state, count, now);
204
- if (state.promoted) {
205
- return undefined; // already promoted — sticky until a demotion clears it
206
- }
207
- if (!this.promotionTriggered(topicId, state, count, now)) {
208
- return undefined;
209
- }
210
- return this.promote(topicId, state, now);
211
- }
212
-
213
- async maybeDemote(topicId: Uint8Array, now: number): Promise<DemotionNoticeV1 | undefined> {
214
- const state = this.states.get(bytesKey(topicId));
215
- if (state === undefined) {
216
- return undefined;
217
- }
218
- if (!this.demotionTriggered(topicId, state, now)) {
219
- return undefined;
220
- }
221
- return this.demote(topicId, state, now);
222
- }
223
-
224
- isPromoted(topicId: Uint8Array): boolean {
225
- return this.states.get(bytesKey(topicId))?.promoted ?? false;
226
- }
227
-
228
- // --- remote apply (verified notices this member did not originate) ---
229
-
230
- applyPromotionNotice(n: PromotionNoticeV1, now: number): void {
231
- const state = this.stateFor(b64urlToBytes(n.topicId));
232
- if (!this.isNewerTransition(state, n.effectiveAt)) {
233
- return; // already adopted, our own echoed broadcast, or a stale replay
234
- }
235
- state.promoted = true;
236
- state.promotedAt = now;
237
- state.lastEffectiveAt = n.effectiveAt;
238
- }
239
-
240
- applyDemotionNotice(n: DemotionNoticeV1, _now: number): void {
241
- const state = this.stateFor(b64urlToBytes(n.topicId));
242
- if (!this.isNewerTransition(state, n.effectiveAt)) {
243
- return;
244
- }
245
- // Mirror the local demote() release so a later re-growth re-evaluates cleanly.
246
- state.promoted = false;
247
- state.promotedAt = undefined;
248
- state.lowLoadSince = undefined;
249
- state.lastEffectiveAt = n.effectiveAt;
250
- }
251
-
252
- /** A notice is adopted only if strictly newer than the last transition we adopted (the high-water mark). */
253
- private isNewerTransition(state: PromotionState, effectiveAt: number): boolean {
254
- return state.lastEffectiveAt === undefined || effectiveAt > state.lastEffectiveAt;
255
- }
256
-
257
- // --- promotion ---
258
-
259
- private promotionTriggered(topicId: Uint8Array, state: PromotionState, count: number, now: number): boolean {
260
- if (count >= this.capPromote) {
261
- return true;
262
- }
263
- if (this.deps.loadBucket(topicId) >= this.bucketOverload && count >= this.capPromoteFast) {
264
- return true;
265
- }
266
- return this.slopePredictsCrossing(state, count, now);
267
- }
268
-
269
- /** Linear extrapolation over the growth window: will `directParticipants` cross `cap_promote` within lookahead? */
270
- private slopePredictsCrossing(state: PromotionState, count: number, now: number): boolean {
271
- const samples = state.samples;
272
- if (samples.length < 2) {
273
- return false;
274
- }
275
- const first = samples[0]!;
276
- const last = samples[samples.length - 1]!;
277
- const span = last.t - first.t;
278
- if (span <= 0) {
279
- return false;
280
- }
281
- const slope = (last.count - first.count) / span; // participants per ms
282
- if (slope <= 0) {
283
- return false;
284
- }
285
- const predicted = count + slope * this.tPromoteLookaheadMs;
286
- return predicted >= this.capPromote;
287
- }
288
-
289
- private async promote(topicId: Uint8Array, state: PromotionState, now: number): Promise<PromotionNoticeV1> {
290
- const fromTier = this.deps.treeTier(topicId);
291
- // NOTE: no guard here against fromTier === DEFAULT_D_MAX_CAP (60). At that depth toTier = 61 exceeds the
292
- // tree-tier ceiling, so validatePromotionNoticeV1 rejects the notice on every receiver. Unreachable today
293
- // (tree tier 60 is pathological); if the tree can ever reach the cap, gate promotion below the cap here.
294
- const topicB64 = bytesToB64url(topicId);
295
- const epochB64 = bytesToB64url(this.deps.cohortEpoch());
296
- const cohortB64 = bytesToB64url(this.deps.cohortCoord());
297
- const signable = { topicId: topicB64, fromTier, toTier: fromTier + 1, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
298
- const { thresholdSig, signers } = await this.deps.signer.thresholdSign(promotionNoticeSigningPayload(signable));
299
- state.promoted = true;
300
- state.promotedAt = now;
301
- state.lastEffectiveAt = now; // local effectiveAt — so our own echoed broadcast applies as a no-op
302
- return {
303
- v: 1,
304
- topicId: topicB64,
305
- fromTier,
306
- toTier: fromTier + 1,
307
- cohortCoord: cohortB64,
308
- effectiveAt: now,
309
- thresholdSig: bytesToB64url(thresholdSig),
310
- signers: signers.map(bytesToB64url),
311
- cohortEpoch: epochB64,
312
- };
313
- }
314
-
315
- // --- demotion ---
316
-
317
- private demotionTriggered(topicId: Uint8Array, state: PromotionState, now: number): boolean {
318
- // The root (tree tier 0) has no parent to hand off to — it never demotes.
319
- if (this.deps.treeTier(topicId) <= 0) {
320
- return false;
321
- }
322
- // Sticky: a freshly-promoted cohort holds promoted mode through transient drops.
323
- if (state.promoted && state.promotedAt !== undefined && now - state.promotedAt < this.tPromoteStickyMs) {
324
- return false;
325
- }
326
- // Never collapse a cohort that still has live children beneath it.
327
- if (this.deps.childCohortCount(topicId) > 0) {
328
- return false;
329
- }
330
- if (this.deps.store.directParticipants(topicId) > this.capDemote) {
331
- return false;
332
- }
333
- if (state.lowLoadSince === undefined) {
334
- return false;
335
- }
336
- return now - state.lowLoadSince >= this.tDemoteMs;
337
- }
338
-
339
- private async demote(topicId: Uint8Array, state: PromotionState, now: number): Promise<DemotionNoticeV1> {
340
- const tier = this.deps.treeTier(topicId);
341
- const topicB64 = bytesToB64url(topicId);
342
- const epochB64 = bytesToB64url(this.deps.cohortEpoch());
343
- const parentB64 = bytesToB64url(this.deps.parentCoord(topicId));
344
- const cohortB64 = bytesToB64url(this.deps.cohortCoord());
345
- const signable = { topicId: topicB64, tier, parentCohortCoord: parentB64, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
346
- const { thresholdSig, signers } = await this.deps.signer.thresholdSign(demotionNoticeSigningPayload(signable));
347
- // Release forwarder state: a demoted cohort leaves promoted mode and resets its clocks so a
348
- // later re-growth re-evaluates cleanly.
349
- // NOTE: demotion currently resets only the promoted-bounce clocks; it does NOT stop the node serving
350
- // the topic — the cold-start forwarder, the direct-participant records, and the budget slot all stay,
351
- // and the cohort keeps serving at tier d. If demotion is ever made to actually collapse local tier
352
- // state, it must reassign or drain the topic's records FIRST, then `coldStart.remove` + release the
353
- // budget slot + `traffic.forget`, in that order — removing the forwarder while records remain re-creates
354
- // the off-budget-serving drift fixed in `cohort-topic-coldstart-forwarder-reconcile` (the budget stops
355
- // bounding the served-topic set). The eviction path already reconciles all three via `TopicBudget.onEvict`.
356
- state.promoted = false;
357
- state.promotedAt = undefined;
358
- state.lowLoadSince = undefined;
359
- state.lastEffectiveAt = now; // local effectiveAt — so our own echoed broadcast applies as a no-op
360
- return {
361
- v: 1,
362
- topicId: topicB64,
363
- tier,
364
- parentCohortCoord: parentB64,
365
- cohortCoord: cohortB64,
366
- effectiveAt: now,
367
- thresholdSig: bytesToB64url(thresholdSig),
368
- signers: signers.map(bytesToB64url),
369
- cohortEpoch: epochB64,
370
- };
371
- }
372
-
373
- // --- bookkeeping ---
374
-
375
- private stateFor(topicId: Uint8Array): PromotionState {
376
- const key = bytesKey(topicId);
377
- let state = this.states.get(key);
378
- if (state === undefined) {
379
- state = { promoted: false, samples: [] };
380
- this.states.set(key, state);
381
- }
382
- return state;
383
- }
384
-
385
- private pushGrowthSample(state: PromotionState, count: number, now: number): void {
386
- state.samples.push({ t: now, count });
387
- const cutoff = now - this.growthWindowMs;
388
- while (state.samples.length > 1 && state.samples[0]!.t < cutoff) {
389
- state.samples.shift();
390
- }
391
- }
392
-
393
- private refreshLowLoadClock(state: PromotionState, count: number, now: number): void {
394
- if (count <= this.capDemote) {
395
- state.lowLoadSince ??= now;
396
- } else {
397
- state.lowLoadSince = undefined;
398
- }
399
- }
400
- }
401
-
402
- /** Build a {@link PromotionLifecycle} over the injected store, barometer resolver, and signer. */
403
- export function createPromotionLifecycle(deps: PromotionDeps): PromotionLifecycle {
404
- return new CohortPromotionLifecycle(deps);
405
- }
1
+ /**
2
+ * Cohort-topic substrate — promotion / demotion lifecycle.
3
+ *
4
+ * Transcribed from `docs/cohort-topic.md` §Promotion and demotion lifecycle and folded back from the
5
+ * simulator-validated `packages/substrate-simulator/src/topic-tree.ts`. A forwarder cohort grows
6
+ * (promotes) and shrinks (demotes) for a topic `T` based on its per-topic direct-participant count
7
+ * and the per-tier load barometer; both transitions are **threshold-signed** (via the gossip ticket's
8
+ * {@link CohortSigner}) so participants can verify them.
9
+ *
10
+ * **Promote** when, for a quorum (the quorum is enforced by the threshold signature — `minSigs`
11
+ * signers must agree to the notice):
12
+ * - `directParticipants(T) ≥ cap_promote` (64), OR
13
+ * - `loadBucket[tier(T)] ≥ bucket_overload` (6) AND `directParticipants(T) ≥ cap_promote_fast` (32)
14
+ * — the hot-load fast path from the capacity-barometer ticket, OR
15
+ * - the growth **slope** predicts crossing `cap_promote` within `T_promote_lookahead` (30s) — fires
16
+ * early to avoid the gossip-lag overshoot (`§Promotion`: pre-promotion on slope).
17
+ *
18
+ * Once promoted, the state is **sticky** for ≥ `T_promote_sticky` (60s) before it can be reconsidered
19
+ * for demotion, so transient count drops don't flap a cohort back to accepting.
20
+ *
21
+ * **Demote** when, for a quorum: `directParticipants(T) ≤ cap_demote` (16) AND that has held for
22
+ * ≥ `T_demote` (5min) AND the cohort has no live child cohorts AND it has a parent (the root, tree
23
+ * tier 0, never demotes — it has nowhere to hand off). The `4×` gap between `cap_promote` and
24
+ * `cap_demote` plus `T_demote` is the hysteresis that prevents thrash.
25
+ *
26
+ * **Deviation from the doc's interface sketch (documented).** `docs/cohort-topic.md` sketches
27
+ * `onParticipantCountChange` as returning `void` and "may emit `PromotionNoticeV1`". Because
28
+ * threshold signing is asynchronous ({@link CohortSigner.thresholdSign} resolves a Promise), the
29
+ * methods here are `async` and **return** the signed notice (or `undefined`) instead of emitting it
30
+ * through a side channel — the caller broadcasts whatever it gets back. This keeps the module pure
31
+ * and unit-testable and avoids an awkward async callback. The op-tier `tier(T)` the barometer is
32
+ * indexed by is supplied through the injected {@link PromotionDeps.loadBucket} resolver, so this
33
+ * module never needs to know a topic's tier.
34
+ *
35
+ * **Remote apply path.** `promote()` / `demote()` set this cohort's per-topic state as a side effect of
36
+ * the **local** signing path — only the member that originates a notice adopts the new state that way. A
37
+ * member that *learns* of a promotion/demotion via gossip or a broadcast notice adopts it through
38
+ * {@link PromotionLifecycle.applyPromotionNotice} / {@link PromotionLifecycle.applyDemotionNotice},
39
+ * which set the same {@link PromotionState} **without re-signing** (the notice is already a verified
40
+ * quorum decision). **Precondition: the caller has already verified the notice's threshold signature**
41
+ * (db-p2p's `MembershipVerifier`); this module is crypto-free and trusts that gate. The apply path is
42
+ * idempotent and `effectiveAt`-ordered via the monotonic {@link PromotionState.lastEffectiveAt}
43
+ * high-water mark, so re-applying a notice (including this member's own echoed broadcast) or a replayed
44
+ * older notice is a no-op — a stale promotion can never un-demote a cohort that has since demoted.
45
+ */
46
+
47
+ import { b64urlToBytes, bytesToB64url } from "./wire/codec.js";
48
+ import type { CohortSigner } from "./sig/threshold.js";
49
+ import { demotionNoticeSigningPayload, promotionNoticeSigningPayload } from "./sig/payloads.js";
50
+ import type { DemotionNoticeV1, PromotionNoticeV1 } from "./wire/types.js";
51
+ import type { RegistrationStore } from "./registration/types.js";
52
+ import { bytesKey } from "./registration/bytes.js";
53
+
54
+ // --- defaults (docs/cohort-topic.md §Configuration; simulator-confirmed) ---
55
+
56
+ /** Direct-participant cap before promotion. */
57
+ export const DEFAULT_CAP_PROMOTE = 64;
58
+ /** Direct-participant cap when the load barometer is hot (fast path). */
59
+ export const DEFAULT_CAP_PROMOTE_FAST = 32;
60
+ /** Load-barometer bucket at/above which the fast promote path is armed. */
61
+ export const DEFAULT_BUCKET_OVERLOAD = 6;
62
+ /** Direct-participant floor for demotion (= `cap_promote / 4`). */
63
+ export const DEFAULT_CAP_DEMOTE = 16;
64
+ /** Hysteresis window the low-load condition must hold before demotion (ms). */
65
+ export const DEFAULT_T_DEMOTE_MS = 300_000;
66
+ /** Pre-promotion slope lookahead (ms). */
67
+ export const DEFAULT_T_PROMOTE_LOOKAHEAD_MS = 30_000;
68
+ /** Minimum time a cohort stays promoted before it can be reconsidered for demotion (ms). */
69
+ export const DEFAULT_T_PROMOTE_STICKY_MS = 60_000;
70
+ /**
71
+ * Slope window over which the growth rate is measured for pre-promotion. **Not pinned by
72
+ * `docs/cohort-topic.md`** — the simulator (`topic-tree.ts` `DEFAULT_LIFECYCLE_CONFIG`) uses 10s and
73
+ * this matches it.
74
+ */
75
+ export const DEFAULT_GROWTH_WINDOW_MS = 10_000;
76
+
77
+ export interface PromotionConfig {
78
+ capPromote?: number;
79
+ capPromoteFast?: number;
80
+ bucketOverload?: number;
81
+ capDemote?: number;
82
+ tDemoteMs?: number;
83
+ tPromoteLookaheadMs?: number;
84
+ tPromoteStickyMs?: number;
85
+ growthWindowMs?: number;
86
+ }
87
+
88
+ /** A sampled `(time, count)` growth point, for slope-based pre-promotion. */
89
+ interface GrowthSample {
90
+ readonly t: number;
91
+ readonly count: number;
92
+ }
93
+
94
+ /** Per-topic promotion bookkeeping held by this cohort. */
95
+ interface PromotionState {
96
+ promoted: boolean;
97
+ /** When the cohort last entered promoted mode (sticky-window anchor). */
98
+ promotedAt?: number;
99
+ /** When `directParticipants` last dropped to ≤ `cap_demote` (demotion hysteresis anchor). */
100
+ lowLoadSince?: number;
101
+ /** Recent growth samples within `growthWindowMs`, for slope extrapolation. */
102
+ samples: GrowthSample[];
103
+ /**
104
+ * `effectiveAt` of the most recent promotion/demotion this cohort has adopted — locally originated
105
+ * (`promote()` / `demote()`) or remotely applied (`applyPromotionNotice` / `applyDemotionNotice`).
106
+ * Monotonic and **never cleared** (a demotion clears `promoted`/`promotedAt` but keeps this), so it
107
+ * is the high-water mark the remote-apply path orders against: a notice whose `effectiveAt` is not
108
+ * strictly newer is a no-op — re-applied (incl. this member's own echoed broadcast) or stale-replayed.
109
+ */
110
+ lastEffectiveAt?: number;
111
+ }
112
+
113
+ export interface PromotionDeps {
114
+ /** Replicated registration store — supplies the per-topic `directParticipants` stock count. */
115
+ store: Pick<RegistrationStore, "directParticipants">;
116
+ /**
117
+ * Current load-barometer bucket (0..7) for the topic's op tier `tier(T)`. The caller resolves the
118
+ * topic→tier mapping and the barometer lookup, keeping this module tier-agnostic and FRET-free.
119
+ */
120
+ loadBucket: (topicId: Uint8Array) => number;
121
+ /** Live child-cohort count for the topic (0 if none); `> 0` blocks demotion. */
122
+ childCohortCount: (topicId: Uint8Array) => number;
123
+ /** Tree tier `d` this cohort serves the topic at — `fromTier` on a notice; `toTier = d + 1`. */
124
+ treeTier: (topicId: Uint8Array) => number;
125
+ /** The tier-`(d − 1)` parent cohort coord, target of a {@link DemotionNoticeV1}. */
126
+ parentCoord: (topicId: Uint8Array) => Uint8Array;
127
+ /**
128
+ * The served coord `coord_d(participantCoord, topicId)` this cohort sits at (raw bytes) — stamped on
129
+ * every notice as `cohortCoord` and covered by its threshold signature, so a receiver routes the notice
130
+ * to exactly this cohort's engine and verifies it against exactly this cohort's cert. Constant for the
131
+ * engine's lifetime (the engine is instantiated at one served coord).
132
+ */
133
+ cohortCoord: () => Uint8Array;
134
+ /** Cohort epoch (raw bytes) the notices are signed under. */
135
+ cohortEpoch: () => Uint8Array;
136
+ /** Threshold signer (the gossip ticket's `k − x` cohort signer). */
137
+ signer: CohortSigner;
138
+ config?: PromotionConfig;
139
+ }
140
+
141
+ /**
142
+ * Promotion / demotion lifecycle for one cohort across all the topics it serves. Keyed by `topicId`;
143
+ * each topic carries its own promoted/low-load/sticky state.
144
+ */
145
+ export interface PromotionLifecycle {
146
+ /**
147
+ * React to a change in `topicId`'s direct-participant count (call after every arrival/eviction).
148
+ * Refreshes the growth + hysteresis clocks and, if a promote trigger fires and the cohort is not
149
+ * already promoted, threshold-signs and returns a {@link PromotionNoticeV1}. Returns `undefined`
150
+ * when no promotion fires.
151
+ */
152
+ onParticipantCountChange(topicId: Uint8Array, now: number): Promise<PromotionNoticeV1 | undefined>;
153
+ /**
154
+ * Time-driven demotion check (call on the gossip tick). Returns a threshold-signed
155
+ * {@link DemotionNoticeV1} for the parent when every demotion condition holds, else `undefined`.
156
+ */
157
+ maybeDemote(topicId: Uint8Array, now: number): Promise<DemotionNoticeV1 | undefined>;
158
+ /** Whether `topicId` is currently in promoted mode (new registrations get `Promoted(d+1)`). */
159
+ isPromoted(topicId: Uint8Array): boolean;
160
+ /**
161
+ * Adopt a promotion this member did **not** originate (learned via a verified broadcast notice).
162
+ * Sets `promoted = true` for `n.topicId` without re-signing. **Precondition: the caller has verified
163
+ * `n`'s threshold signature** — this module performs no crypto. Idempotent and `effectiveAt`-ordered
164
+ * (see {@link PromotionState.lastEffectiveAt}): a notice not strictly newer than the last adopted
165
+ * transition is a no-op, so this member's own echoed broadcast and stale replays are absorbed.
166
+ */
167
+ applyPromotionNotice(n: PromotionNoticeV1, now: number): void;
168
+ /**
169
+ * Adopt a demotion this member did **not** originate (learned via a verified broadcast notice).
170
+ * Clears `promoted` state for `n.topicId` without re-signing. Same precondition, idempotency, and
171
+ * `effectiveAt` ordering as {@link applyPromotionNotice} — a stale promotion can never un-demote.
172
+ */
173
+ applyDemotionNotice(n: DemotionNoticeV1, now: number): void;
174
+ }
175
+
176
+ class CohortPromotionLifecycle implements PromotionLifecycle {
177
+ private readonly states = new Map<string, PromotionState>();
178
+ private readonly capPromote: number;
179
+ private readonly capPromoteFast: number;
180
+ private readonly bucketOverload: number;
181
+ private readonly capDemote: number;
182
+ private readonly tDemoteMs: number;
183
+ private readonly tPromoteLookaheadMs: number;
184
+ private readonly tPromoteStickyMs: number;
185
+ private readonly growthWindowMs: number;
186
+
187
+ constructor(private readonly deps: PromotionDeps) {
188
+ const cfg = deps.config ?? {};
189
+ this.capPromote = cfg.capPromote ?? DEFAULT_CAP_PROMOTE;
190
+ this.capPromoteFast = cfg.capPromoteFast ?? DEFAULT_CAP_PROMOTE_FAST;
191
+ this.bucketOverload = cfg.bucketOverload ?? DEFAULT_BUCKET_OVERLOAD;
192
+ this.capDemote = cfg.capDemote ?? DEFAULT_CAP_DEMOTE;
193
+ this.tDemoteMs = cfg.tDemoteMs ?? DEFAULT_T_DEMOTE_MS;
194
+ this.tPromoteLookaheadMs = cfg.tPromoteLookaheadMs ?? DEFAULT_T_PROMOTE_LOOKAHEAD_MS;
195
+ this.tPromoteStickyMs = cfg.tPromoteStickyMs ?? DEFAULT_T_PROMOTE_STICKY_MS;
196
+ this.growthWindowMs = cfg.growthWindowMs ?? DEFAULT_GROWTH_WINDOW_MS;
197
+ }
198
+
199
+ async onParticipantCountChange(topicId: Uint8Array, now: number): Promise<PromotionNoticeV1 | undefined> {
200
+ const state = this.stateFor(topicId);
201
+ const count = this.deps.store.directParticipants(topicId);
202
+ this.pushGrowthSample(state, count, now);
203
+ this.refreshLowLoadClock(state, count, now);
204
+ if (state.promoted) {
205
+ return undefined; // already promoted — sticky until a demotion clears it
206
+ }
207
+ if (!this.promotionTriggered(topicId, state, count, now)) {
208
+ return undefined;
209
+ }
210
+ return this.promote(topicId, state, now);
211
+ }
212
+
213
+ async maybeDemote(topicId: Uint8Array, now: number): Promise<DemotionNoticeV1 | undefined> {
214
+ const state = this.states.get(bytesKey(topicId));
215
+ if (state === undefined) {
216
+ return undefined;
217
+ }
218
+ if (!this.demotionTriggered(topicId, state, now)) {
219
+ return undefined;
220
+ }
221
+ return this.demote(topicId, state, now);
222
+ }
223
+
224
+ isPromoted(topicId: Uint8Array): boolean {
225
+ return this.states.get(bytesKey(topicId))?.promoted ?? false;
226
+ }
227
+
228
+ // --- remote apply (verified notices this member did not originate) ---
229
+
230
+ applyPromotionNotice(n: PromotionNoticeV1, now: number): void {
231
+ const state = this.stateFor(b64urlToBytes(n.topicId));
232
+ if (!this.isNewerTransition(state, n.effectiveAt)) {
233
+ return; // already adopted, our own echoed broadcast, or a stale replay
234
+ }
235
+ state.promoted = true;
236
+ state.promotedAt = now;
237
+ state.lastEffectiveAt = n.effectiveAt;
238
+ }
239
+
240
+ applyDemotionNotice(n: DemotionNoticeV1, _now: number): void {
241
+ const state = this.stateFor(b64urlToBytes(n.topicId));
242
+ if (!this.isNewerTransition(state, n.effectiveAt)) {
243
+ return;
244
+ }
245
+ // Mirror the local demote() release so a later re-growth re-evaluates cleanly.
246
+ state.promoted = false;
247
+ state.promotedAt = undefined;
248
+ state.lowLoadSince = undefined;
249
+ state.lastEffectiveAt = n.effectiveAt;
250
+ }
251
+
252
+ /** A notice is adopted only if strictly newer than the last transition we adopted (the high-water mark). */
253
+ private isNewerTransition(state: PromotionState, effectiveAt: number): boolean {
254
+ return state.lastEffectiveAt === undefined || effectiveAt > state.lastEffectiveAt;
255
+ }
256
+
257
+ // --- promotion ---
258
+
259
+ private promotionTriggered(topicId: Uint8Array, state: PromotionState, count: number, now: number): boolean {
260
+ if (count >= this.capPromote) {
261
+ return true;
262
+ }
263
+ if (this.deps.loadBucket(topicId) >= this.bucketOverload && count >= this.capPromoteFast) {
264
+ return true;
265
+ }
266
+ return this.slopePredictsCrossing(state, count, now);
267
+ }
268
+
269
+ /** Linear extrapolation over the growth window: will `directParticipants` cross `cap_promote` within lookahead? */
270
+ private slopePredictsCrossing(state: PromotionState, count: number, now: number): boolean {
271
+ const samples = state.samples;
272
+ if (samples.length < 2) {
273
+ return false;
274
+ }
275
+ const first = samples[0]!;
276
+ const last = samples[samples.length - 1]!;
277
+ const span = last.t - first.t;
278
+ if (span <= 0) {
279
+ return false;
280
+ }
281
+ const slope = (last.count - first.count) / span; // participants per ms
282
+ if (slope <= 0) {
283
+ return false;
284
+ }
285
+ const predicted = count + slope * this.tPromoteLookaheadMs;
286
+ return predicted >= this.capPromote;
287
+ }
288
+
289
+ private async promote(topicId: Uint8Array, state: PromotionState, now: number): Promise<PromotionNoticeV1> {
290
+ const fromTier = this.deps.treeTier(topicId);
291
+ // NOTE: no guard here against fromTier === DEFAULT_D_MAX_CAP (60). At that depth toTier = 61 exceeds the
292
+ // tree-tier ceiling, so validatePromotionNoticeV1 rejects the notice on every receiver. Unreachable today
293
+ // (tree tier 60 is pathological); if the tree can ever reach the cap, gate promotion below the cap here.
294
+ const topicB64 = bytesToB64url(topicId);
295
+ const epochB64 = bytesToB64url(this.deps.cohortEpoch());
296
+ const cohortB64 = bytesToB64url(this.deps.cohortCoord());
297
+ const signable = { topicId: topicB64, fromTier, toTier: fromTier + 1, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
298
+ const { thresholdSig, signers } = await this.deps.signer.thresholdSign(promotionNoticeSigningPayload(signable));
299
+ state.promoted = true;
300
+ state.promotedAt = now;
301
+ state.lastEffectiveAt = now; // local effectiveAt — so our own echoed broadcast applies as a no-op
302
+ return {
303
+ v: 1,
304
+ topicId: topicB64,
305
+ fromTier,
306
+ toTier: fromTier + 1,
307
+ cohortCoord: cohortB64,
308
+ effectiveAt: now,
309
+ thresholdSig: bytesToB64url(thresholdSig),
310
+ signers: signers.map(bytesToB64url),
311
+ cohortEpoch: epochB64,
312
+ };
313
+ }
314
+
315
+ // --- demotion ---
316
+
317
+ private demotionTriggered(topicId: Uint8Array, state: PromotionState, now: number): boolean {
318
+ // The root (tree tier 0) has no parent to hand off to — it never demotes.
319
+ if (this.deps.treeTier(topicId) <= 0) {
320
+ return false;
321
+ }
322
+ // Sticky: a freshly-promoted cohort holds promoted mode through transient drops.
323
+ if (state.promoted && state.promotedAt !== undefined && now - state.promotedAt < this.tPromoteStickyMs) {
324
+ return false;
325
+ }
326
+ // Never collapse a cohort that still has live children beneath it.
327
+ if (this.deps.childCohortCount(topicId) > 0) {
328
+ return false;
329
+ }
330
+ if (this.deps.store.directParticipants(topicId) > this.capDemote) {
331
+ return false;
332
+ }
333
+ if (state.lowLoadSince === undefined) {
334
+ return false;
335
+ }
336
+ return now - state.lowLoadSince >= this.tDemoteMs;
337
+ }
338
+
339
+ private async demote(topicId: Uint8Array, state: PromotionState, now: number): Promise<DemotionNoticeV1> {
340
+ const tier = this.deps.treeTier(topicId);
341
+ const topicB64 = bytesToB64url(topicId);
342
+ const epochB64 = bytesToB64url(this.deps.cohortEpoch());
343
+ const parentB64 = bytesToB64url(this.deps.parentCoord(topicId));
344
+ const cohortB64 = bytesToB64url(this.deps.cohortCoord());
345
+ const signable = { topicId: topicB64, tier, parentCohortCoord: parentB64, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
346
+ const { thresholdSig, signers } = await this.deps.signer.thresholdSign(demotionNoticeSigningPayload(signable));
347
+ // Release forwarder state: a demoted cohort leaves promoted mode and resets its clocks so a
348
+ // later re-growth re-evaluates cleanly.
349
+ // NOTE: demotion currently resets only the promoted-bounce clocks; it does NOT stop the node serving
350
+ // the topic — the cold-start forwarder, the direct-participant records, and the budget slot all stay,
351
+ // and the cohort keeps serving at tier d. If demotion is ever made to actually collapse local tier
352
+ // state, it must reassign or drain the topic's records FIRST, then `coldStart.remove` + release the
353
+ // budget slot + `traffic.forget`, in that order — removing the forwarder while records remain re-creates
354
+ // the off-budget-serving drift fixed in `cohort-topic-coldstart-forwarder-reconcile` (the budget stops
355
+ // bounding the served-topic set). The eviction path already reconciles all three via `TopicBudget.onEvict`.
356
+ state.promoted = false;
357
+ state.promotedAt = undefined;
358
+ state.lowLoadSince = undefined;
359
+ state.lastEffectiveAt = now; // local effectiveAt — so our own echoed broadcast applies as a no-op
360
+ return {
361
+ v: 1,
362
+ topicId: topicB64,
363
+ tier,
364
+ parentCohortCoord: parentB64,
365
+ cohortCoord: cohortB64,
366
+ effectiveAt: now,
367
+ thresholdSig: bytesToB64url(thresholdSig),
368
+ signers: signers.map(bytesToB64url),
369
+ cohortEpoch: epochB64,
370
+ };
371
+ }
372
+
373
+ // --- bookkeeping ---
374
+
375
+ private stateFor(topicId: Uint8Array): PromotionState {
376
+ const key = bytesKey(topicId);
377
+ let state = this.states.get(key);
378
+ if (state === undefined) {
379
+ state = { promoted: false, samples: [] };
380
+ this.states.set(key, state);
381
+ }
382
+ return state;
383
+ }
384
+
385
+ private pushGrowthSample(state: PromotionState, count: number, now: number): void {
386
+ state.samples.push({ t: now, count });
387
+ const cutoff = now - this.growthWindowMs;
388
+ while (state.samples.length > 1 && state.samples[0]!.t < cutoff) {
389
+ state.samples.shift();
390
+ }
391
+ }
392
+
393
+ private refreshLowLoadClock(state: PromotionState, count: number, now: number): void {
394
+ if (count <= this.capDemote) {
395
+ state.lowLoadSince ??= now;
396
+ } else {
397
+ state.lowLoadSince = undefined;
398
+ }
399
+ }
400
+ }
401
+
402
+ /** Build a {@link PromotionLifecycle} over the injected store, barometer resolver, and signer. */
403
+ export function createPromotionLifecycle(deps: PromotionDeps): PromotionLifecycle {
404
+ return new CohortPromotionLifecycle(deps);
405
+ }