@optimystic/db-p2p 0.25.1 → 0.27.0

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 (107) hide show
  1. package/dist/src/cluster/certified-claims.d.ts +17 -3
  2. package/dist/src/cluster/certified-claims.d.ts.map +1 -1
  3. package/dist/src/cluster/certified-claims.js +5 -3
  4. package/dist/src/cluster/certified-claims.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +57 -92
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +149 -201
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/commit-proof.d.ts +16 -0
  10. package/dist/src/cluster/commit-proof.d.ts.map +1 -1
  11. package/dist/src/cluster/commit-proof.js +32 -1
  12. package/dist/src/cluster/commit-proof.js.map +1 -1
  13. package/dist/src/cluster/quorum-restore.d.ts +81 -28
  14. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  15. package/dist/src/cluster/quorum-restore.js +148 -51
  16. package/dist/src/cluster/quorum-restore.js.map +1 -1
  17. package/dist/src/cluster/race-resolution.d.ts +89 -0
  18. package/dist/src/cluster/race-resolution.d.ts.map +1 -0
  19. package/dist/src/cluster/race-resolution.js +140 -0
  20. package/dist/src/cluster/race-resolution.js.map +1 -0
  21. package/dist/src/cluster/reconcile-block.d.ts +9 -4
  22. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  23. package/dist/src/cluster/reconcile-block.js +28 -11
  24. package/dist/src/cluster/reconcile-block.js.map +1 -1
  25. package/dist/src/cluster/record-operations.d.ts +25 -0
  26. package/dist/src/cluster/record-operations.d.ts.map +1 -0
  27. package/dist/src/cluster/record-operations.js +56 -0
  28. package/dist/src/cluster/record-operations.js.map +1 -0
  29. package/dist/src/cohort-topic/host.d.ts +133 -51
  30. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  31. package/dist/src/cohort-topic/host.js +290 -83
  32. package/dist/src/cohort-topic/host.js.map +1 -1
  33. package/dist/src/libp2p-node-base.d.ts +13 -4
  34. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  35. package/dist/src/libp2p-node-base.js +5 -5
  36. package/dist/src/libp2p-node-base.js.map +1 -1
  37. package/dist/src/repo/cluster-coordinator.d.ts +9 -0
  38. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  39. package/dist/src/repo/cluster-coordinator.js +13 -2
  40. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  41. package/dist/src/repo/coordinator-repo.d.ts +34 -2
  42. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  43. package/dist/src/repo/coordinator-repo.js +57 -3
  44. package/dist/src/repo/coordinator-repo.js.map +1 -1
  45. package/dist/src/storage/block-latch.d.ts +5 -4
  46. package/dist/src/storage/block-latch.d.ts.map +1 -1
  47. package/dist/src/storage/block-latch.js +5 -4
  48. package/dist/src/storage/block-latch.js.map +1 -1
  49. package/dist/src/storage/block-storage.d.ts +1 -1
  50. package/dist/src/storage/block-storage.d.ts.map +1 -1
  51. package/dist/src/storage/block-storage.js +11 -3
  52. package/dist/src/storage/block-storage.js.map +1 -1
  53. package/dist/src/storage/cached-raw-storage.d.ts +17 -1
  54. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  55. package/dist/src/storage/cached-raw-storage.js +8 -1
  56. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  57. package/dist/src/storage/cached-store-driver.d.ts +7 -0
  58. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  59. package/dist/src/storage/cached-store-driver.js +20 -0
  60. package/dist/src/storage/cached-store-driver.js.map +1 -1
  61. package/dist/src/storage/i-block-storage.d.ts +45 -2
  62. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  63. package/dist/src/storage/i-block-storage.js +29 -0
  64. package/dist/src/storage/i-block-storage.js.map +1 -1
  65. package/dist/src/storage/i-raw-storage.d.ts +16 -0
  66. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  67. package/dist/src/storage/kv-raw-storage.d.ts +5 -1
  68. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  69. package/dist/src/storage/kv-raw-storage.js +8 -1
  70. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  71. package/dist/src/storage/raw-store-driver.d.ts +15 -0
  72. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  73. package/dist/src/storage/shared-cache-pool.d.ts +5 -0
  74. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  75. package/dist/src/storage/shared-cache-pool.js +6 -1
  76. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  77. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  78. package/dist/src/storage/storage-repo.js +141 -99
  79. package/dist/src/storage/storage-repo.js.map +1 -1
  80. package/dist/src/storage/with-read-cache.d.ts +9 -5
  81. package/dist/src/storage/with-read-cache.d.ts.map +1 -1
  82. package/dist/src/storage/with-read-cache.js +16 -6
  83. package/dist/src/storage/with-read-cache.js.map +1 -1
  84. package/package.json +2 -2
  85. package/{README.md → readme.md} +2 -2
  86. package/src/cluster/certified-claims.ts +22 -9
  87. package/src/cluster/cluster-repo.ts +199 -212
  88. package/src/cluster/commit-proof.ts +38 -2
  89. package/src/cluster/quorum-restore.ts +183 -56
  90. package/src/cluster/race-resolution.ts +158 -0
  91. package/src/cluster/reconcile-block.ts +34 -11
  92. package/src/cluster/record-operations.ts +59 -0
  93. package/src/cohort-topic/host.ts +423 -118
  94. package/src/libp2p-node-base.ts +18 -9
  95. package/src/repo/cluster-coordinator.ts +1039 -1027
  96. package/src/repo/coordinator-repo.ts +1937 -1855
  97. package/src/storage/block-latch.ts +5 -4
  98. package/src/storage/block-storage.ts +11 -3
  99. package/src/storage/cached-raw-storage.ts +21 -1
  100. package/src/storage/cached-store-driver.ts +23 -0
  101. package/src/storage/i-block-storage.ts +47 -2
  102. package/src/storage/i-raw-storage.ts +17 -0
  103. package/src/storage/kv-raw-storage.ts +8 -1
  104. package/src/storage/raw-store-driver.ts +26 -0
  105. package/src/storage/shared-cache-pool.ts +6 -1
  106. package/src/storage/storage-repo.ts +143 -103
  107. package/src/storage/with-read-cache.ts +16 -6
@@ -162,12 +162,25 @@ export async function createCohortTopicHost(node, fret, options = {}) {
162
162
  const members = cohortAround(coord).members.map(bytesToPeerIdString);
163
163
  return members.includes(bytesToPeerIdString(fromBytes));
164
164
  };
165
+ // Node-level `promote`-handler anti-abuse gate (`cohort-topic-promote-handler-verify-amplification`):
166
+ // a per-(peer, topic) rate limiter (own instance — the register-path limiter is per-coord inside each
167
+ // engine; this handler is node-level) plus the per-(coord, tier, topic) adopted-transition record.
168
+ // Defaults to `register_rate_per_peer` (4 / min / peer / topic) with exponential back-off. Declared here
169
+ // rather than with the other anti-DoS wiring below because `broadcastNotice` writes the record on the
170
+ // origination path.
171
+ const promoteGate = createPromoteGate(options.antiDos?.rateLimiter);
165
172
  // --- outbound notice broadcast (gap 4) ---
166
173
  // A coord engine that threshold-signs a promotion/demotion notice hands it here; we fan it over the
167
174
  // `promote` protocol to the cohort around the served coord (siblings adopt the state) and, for a
168
175
  // demotion, additionally to the parent coord (childCohortCount bookkeeping). Reuses the gossip
169
176
  // transport's cohort peer resolution.
170
177
  const broadcastNotice = (notice, servedCoord) => {
178
+ // Record the origination in the node-level adopted-transition map BEFORE fanning out: `broadcastOver`
179
+ // excludes self, so an originated notice never arrives back on the inbound path — this is the ONLY
180
+ // write for locally-originated transitions. Keyed off the notice's own `cohortCoord` only; the parent
181
+ // coord a demotion also fans to is deliberately outside this ordering (the parent-unlink is ordered by
182
+ // the child registry's per-child `lastEffectiveAt` — see `applyDemotionUnlinkAtParent`).
183
+ recordAdoptedTransition(promoteGate, notice);
171
184
  const frame = encodeCohortMessage(notice, maxBytes);
172
185
  // A demotion fans to BOTH the demoting child's served coord (siblings adopt `promoted = false` via the
173
186
  // `cohortCoord`-routed apply) and the parent coord (the parent unrecords the child). `handleInboundNotice`
@@ -201,11 +214,6 @@ export async function createCohortTopicHost(node, fret, options = {}) {
201
214
  // {@link createBootstrapEvidencePolicy}.
202
215
  const hasCommittedParentBacking = options.antiDos?.parentTopicView !== undefined || options.committedParentTopicReader !== undefined;
203
216
  const bootstrapEvidence = createBootstrapEvidencePolicy(options.antiDos, hash, log, parentTopicView, hasCommittedParentBacking);
204
- // Node-level `promote`-handler anti-abuse gate (`cohort-topic-promote-handler-verify-amplification`):
205
- // a per-(peer, topic) rate limiter (own instance — the register-path limiter is per-coord inside each
206
- // engine; this handler is node-level) plus the per-(topic, tier) effectiveAt high-water. Defaults to
207
- // `register_rate_per_peer` (4 / min / peer / topic) with exponential back-off.
208
- const promoteGate = createPromoteGate(options.antiDos?.rateLimiter);
209
217
  const ctx = {
210
218
  hash,
211
219
  addressing,
@@ -244,13 +252,19 @@ export async function createCohortTopicHost(node, fret, options = {}) {
244
252
  // cohort it stops republishing and its anchor goes `"unknown"`, so it can be stranded distrusting the
245
253
  // coord's later-epoch messages. The verifier self-heals via bounded re-TOFU on a demonstrated chain gap
246
254
  // (`staleGapRecoveryStrikes`, see `db-core/.../membership/verifier.ts` + `docs/cohort-topic.md`
247
- // §Bootstrapping trust). The *root-cause* fix is for the host to drop the lock here on demotion, but
248
- // that needs an engine-reclaim / demotion signal the host does not emit today. `createCoordRegistry`
249
- // now evicts, but only IDLE engines (no records → never published a cert), so it never strands a
250
- // trust-lock and does not resolve this on its own (see the NOTE at `evictOneIdle`). When a demotion /
251
- // cert-publishing-engine reclaim signal lands, add a `verifier.forget(coord)` / downgrade call on
252
- // demotion and prefer it over (or alongside) the strike-counter heuristic.
255
+ // §Bootstrapping trust). Engine EVICTION drops the lock for the evicted coord via `onEngineEvicted`
256
+ // below on a keyed node `pumpMembership` publishes for every engine (record-less ones included), so
257
+ // eviction cannot assume "no records → never published". Demotion, however, does NOT release the engine
258
+ // (a demoted engine keeps its records/forwarder and stays resident), so the demotion-side lock drop
259
+ // still has no signal to hang off; the strike-counter heuristic remains the recovery there.
253
260
  onCertPublished: (cert) => verifier.cache(cert),
261
+ // Drop the verifier trust-lock for an evicted engine's coord — unconditional, because on a keyed node
262
+ // ANY engine (even one holding no records) may have published a cert via the gossip-cadence
263
+ // `pumpMembership` sweep. A no-op for a coord the verifier holds nothing for.
264
+ onEngineEvicted: (coord) => verifier.forget(bytesToB64url(coord)),
265
+ // Node-level adopted-transition reader (engine seeding). Wired unconditionally — key-less hosts also
266
+ // adopt verified inbound notices, so their recreated engines need the seed just the same.
267
+ adoptedTransition: (coord, tier, topicId) => promoteGate.transitions.get(transitionKey(bytesToB64url(coord), tier, bytesToB64url(topicId))),
254
268
  };
255
269
  const registry = createCoordRegistry(ctx, options.antiDos?.coordEnginesMax);
256
270
  // --- cold-sibling engine instantiation on a verified co-member gossip frame (§Cold-start instantiation) ---
@@ -273,10 +287,11 @@ export async function createCohortTopicHost(node, fret, options = {}) {
273
287
  // link work — so a tier-`d > 0` frame for an unknown coord falls through to today's drop (the bus has no
274
288
  // engine subscribed to it). See `docs/cohort-topic.md` §Cold-start instantiation.
275
289
  //
276
- // The registry is hard-capped with LRU eviction of idle engines (`createCoordRegistry`), so a
277
- // gossip-instantiated cold sibling is no longer a permanent per-co-member-coord cost: an idle one is
278
- // reclaimed under memory pressure like any other cold engine, and a creation over a full-of-live registry
279
- // is refused (`CoordEngineRegistryFullError`) and dropped here rather than crashing the gossip handler.
290
+ // The registry is hard-capped with ranked LRU eviction (`createCoordRegistry`), so a
291
+ // gossip-instantiated cold sibling is no longer a permanent per-co-member-coord cost: one holding no
292
+ // state is reclaimed under memory pressure like any other cold engine, and a creation over a
293
+ // full-of-pinned registry is refused (`CoordEngineRegistryFullError`) and dropped here rather than
294
+ // crashing the gossip handler.
280
295
  const maybeInstantiateColdSibling = (frame) => {
281
296
  if (verifyGossip === undefined) {
282
297
  return; // key-less / interim mode: no co-member gate, so never auto-instantiate
@@ -731,7 +746,7 @@ function clampTier(tier) {
731
746
  export const DEFAULT_COORD_ENGINES_MAX = 2048;
732
747
  /**
733
748
  * Thrown by {@link CoordRegistry.forCoord} when it must create a new engine but the registry is full of
734
- * **live** cohorts (every slot holds records or a cold-start forwarder, so nothing is idle-evictable).
749
+ * **pinned** engines (every slot holds records or a cold-start forwarder, so nothing is evictable).
735
750
  * Signals a capacity refusal, not a bug — the register / child-link / cold-sibling dispatch paths catch it
736
751
  * and answer a clean refusal (`unwilling_cohort` / `rejected` / drop) rather than letting it escape.
737
752
  */
@@ -741,20 +756,46 @@ export class CoordEngineRegistryFullError extends Error {
741
756
  this.name = "CoordEngineRegistryFullError";
742
757
  }
743
758
  }
759
+ /**
760
+ * Eviction rank per {@link EngineStateKind}. `"pinned"` — an engine holding it is not an eviction
761
+ * candidate at all. A number — the engine IS a candidate, but only after every candidate whose rank is
762
+ * strictly lower; higher = evicted later. An engine holding nothing is rank 0: the genuinely-cold
763
+ * attacker-sprayed coord the cap exists for.
764
+ *
765
+ * `children` / `promotion` are deliberately NOT pinned. A child link is peer-supplied input —
766
+ * key-less-permissive mode records one without any signature check — so pinning on it would let any peer
767
+ * make all `coordEnginesMax` slots un-evictable and drive {@link CoordEngineRegistryFullError} for
768
+ * legitimate coords, reopening the exact spray vector the cap closes. Ranking keeps the hard guarantee
769
+ * (some engine is always evictable while any unpinned one exists) while making the realistic loss — a
770
+ * handful of real parent/promoted engines among ~2000 attacker-cold ones — the *last* to go instead of a
771
+ * pure LRU-age pick.
772
+ *
773
+ * EXHAUSTIVE: a new {@link EngineStateKind} with no rank here does not typecheck, and the spec's
774
+ * exhaustiveness guard cross-checks this table's keys against a `liveness()` result at runtime.
775
+ */
776
+ export const EVICTION_RANK = {
777
+ records: "pinned",
778
+ forwarders: "pinned",
779
+ children: 1,
780
+ promotion: 1,
781
+ };
744
782
  /**
745
783
  * Build the lazy `servedCoord → CoordEngine` registry over the shared collaborators, hard-capped at
746
- * `maxEngines` with least-recently-used eviction of **idle** engines.
784
+ * `maxEngines` with ranked least-recently-used eviction.
747
785
  *
748
786
  * The served coord is a hash over attacker-chosen `(treeTier, participantCoord, topicId)`, and `forCoord`
749
787
  * runs on the register hot path **before** the per-coord anti-DoS gates — so, uncapped, one peer spraying
750
788
  * distinct coords drives unbounded engine allocation (each engine owns a store, gossip bus, rate limiter,
751
789
  * replay guard, topic budget, …). The cap bounds that: on a creation over a full registry we evict the
752
- * least-recently-used **idle** engine (no records, no cold-start forwarder — a throwaway cold coord) and
753
- * tear it down; when every slot holds a live cohort we refuse the new coord ({@link CoordEngineRegistryFullError})
754
- * so a legitimate multi-cohort node keeps working while attacker-driven cold engines cannot pile up.
790
+ * `(rank, recency)`-least candidate engine ({@link EVICTION_RANK} over {@link CoordEngine.liveness}) and
791
+ * tear it down; when every slot holds a pinned engine (records or a forwarder) we refuse the new coord
792
+ * ({@link CoordEngineRegistryFullError}) so a legitimate multi-cohort node keeps working while
793
+ * attacker-driven cold engines cannot pile up.
755
794
  *
756
795
  * Recency is bumped on every lookup that hands back an engine (`forCoord` / `findByCoord` / `findHolder` /
757
- * `findServing`), so a hot cohort under load is never the eviction victim.
796
+ * `findServing`) and breaks ties *within* a rank, so a hot cohort under load loses only to a colder engine
797
+ * of the same rank — but rank is applied first, so even a hot rank-0 engine is evicted before any rank-1
798
+ * one (intended: a child-/promotion-holding engine survives a spray of freshly-touched cold coords).
758
799
  */
759
800
  function createCoordRegistry(ctx, maxEngines = DEFAULT_COORD_ENGINES_MAX) {
760
801
  if (!Number.isInteger(maxEngines) || maxEngines <= 0) {
@@ -766,27 +807,48 @@ function createCoordRegistry(ctx, maxEngines = DEFAULT_COORD_ENGINES_MAX) {
766
807
  const recency = new Map();
767
808
  let seq = 0;
768
809
  const touch = (key) => { recency.set(key, ++seq); };
769
- // An engine is idle-evictable iff it holds no registration record AND no cold-start forwarder i.e. no
770
- // genuine cohort state to lose. A live engine (records or a forwarder) is never a throwaway.
771
- const isIdle = (engine) => !engine.hasState() && !engine.hasForwarders();
772
- // Evict the least-recently-used idle engine to free a slot; returns true iff one was freed. Tears the
773
- // victim down (`close()` drops its gossip-bus subscription) so eviction does not leak the subscription.
810
+ // An engine's eviction rank: `undefined` when it holds any pinned state class (not a candidate at all),
811
+ // else the maximum numeric rank over the classes it holds (0 when it holds nothing). Iterates the rank
812
+ // table, whose keys the type system pins to exactly the EngineStateKind set the liveness record carries.
813
+ const evictionRank = (engine) => {
814
+ const liveness = engine.liveness();
815
+ let rank = 0;
816
+ for (const kind of Object.keys(EVICTION_RANK)) {
817
+ if (!liveness[kind]) {
818
+ continue;
819
+ }
820
+ const r = EVICTION_RANK[kind];
821
+ if (r === "pinned") {
822
+ return undefined;
823
+ }
824
+ if (r > rank) {
825
+ rank = r;
826
+ }
827
+ }
828
+ return rank;
829
+ };
830
+ // Evict the `(rank, recency)`-lexicographically-least candidate engine to free a slot; returns true iff
831
+ // one was freed. Tears the victim down (`close()` drops its gossip-bus subscription) so eviction does not
832
+ // leak the subscription, and fires `ctx.onEngineEvicted` so the host drops the coord's verifier
833
+ // trust-lock.
774
834
  //
775
- // NOTE (verifier trust-lock, cohort-topic-treetier-bound-engine-cap): only IDLE engines are evicted here,
776
- // and an idle engine (`hasState() === false`) has never published a membership cert — so there is no
777
- // verifier trust-lock (`onCertPublished` `verifier.cache`, above) to drop for its coord. If this policy
778
- // is ever widened to evict a cert-publishing engine, add a `verifier.forget(coord)` / downgrade here:
779
- // otherwise the stale trust-lock strands the coord's later-epoch messages (the drop-the-lock tripwire the
780
- // `onCertPublished` NOTE describes). Do NOT widen without that.
781
- const evictOneIdle = () => {
835
+ // NOTE (verifier trust-lock): eviction can reclaim an engine that HAS published a membership cert — on a
836
+ // keyed node the gossip-cadence driver calls `pumpMembership` for every engine in `registry.all()`,
837
+ // record-less ones included, so "no records" never implied "never published". `onEngineEvicted` is
838
+ // therefore called for EVERY victim, unconditionally (the host wires it to `verifier.forget(coord)`, a
839
+ // cheap no-op for a coord that never published); do not try to track which engines published.
840
+ const evictOne = () => {
782
841
  let victimKey;
842
+ let victimRank = Infinity;
783
843
  let victimSeq = Infinity;
784
844
  for (const [key, engine] of engines) {
785
- if (!isIdle(engine)) {
786
- continue; // live cohort (records) or mid-link cold-start forwarder — never evicted
845
+ const rank = evictionRank(engine);
846
+ if (rank === undefined) {
847
+ continue; // pinned: holds records or a mid-link cold-start forwarder — never evicted
787
848
  }
788
849
  const s = recency.get(key) ?? 0;
789
- if (s < victimSeq) {
850
+ if (rank < victimRank || (rank === victimRank && s < victimSeq)) {
851
+ victimRank = rank;
790
852
  victimSeq = s;
791
853
  victimKey = key;
792
854
  }
@@ -794,9 +856,11 @@ function createCoordRegistry(ctx, maxEngines = DEFAULT_COORD_ENGINES_MAX) {
794
856
  if (victimKey === undefined) {
795
857
  return false;
796
858
  }
797
- engines.get(victimKey).close();
859
+ const victim = engines.get(victimKey);
860
+ victim.close();
798
861
  engines.delete(victimKey);
799
862
  recency.delete(victimKey);
863
+ ctx.onEngineEvicted?.(victim.servedCoord);
800
864
  return true;
801
865
  };
802
866
  return {
@@ -806,8 +870,8 @@ function createCoordRegistry(ctx, maxEngines = DEFAULT_COORD_ENGINES_MAX) {
806
870
  // share one engine rather than racing to construct a second.
807
871
  let engine = engines.get(key);
808
872
  if (engine === undefined) {
809
- if (engines.size >= maxEngines && !evictOneIdle()) {
810
- // Full of live cohorts — refuse rather than grow unbounded. Callers turn this into a clean
873
+ if (engines.size >= maxEngines && !evictOne()) {
874
+ // Full of pinned engines — refuse rather than grow unbounded. Callers turn this into a clean
811
875
  // capacity reply/drop (see the paths listed on `CoordEngineRegistryFullError`).
812
876
  log("cohort-topic: coord-engine registry full (max=%d) — refusing new coord %s", maxEngines, key);
813
877
  throw new CoordEngineRegistryFullError(maxEngines);
@@ -907,6 +971,10 @@ class RotationState {
907
971
  return undefined;
908
972
  }
909
973
  }
974
+ // NOTE: released children are kept forever as `linked = false` tombstones (the high-water that stops a stale
975
+ // link resurrecting them), so a long-lived engine's map grows with child churn. They cost memory only, never
976
+ // traffic — `linkedChildren` excludes them, so re-advertisement volume is bounded by the *live* child count.
977
+ // If child churn on a long-lived parent ever makes this map large, age tombstones out past a demotion horizon.
910
978
  function createChildRegistry() {
911
979
  const byTopic = new Map();
912
980
  // Freshness-ordered write shared by link/unlink: apply `linked` only if `effectiveAt` is strictly newer than
@@ -924,7 +992,7 @@ function createChildRegistry() {
924
992
  if (existing === undefined) {
925
993
  // A never-seen unlink writes a `linked = false` tombstone (never a negative count), so a later stale
926
994
  // link with an earlier effectiveAt cannot resurrect a demoted child.
927
- children.set(childKey, { linked, lastEffectiveAt: effectiveAt });
995
+ children.set(childKey, { linked, lastEffectiveAt: effectiveAt, topicId, childCohortCoord });
928
996
  return true;
929
997
  }
930
998
  if (effectiveAt <= existing.lastEffectiveAt) {
@@ -955,6 +1023,27 @@ function createChildRegistry() {
955
1023
  }
956
1024
  return n;
957
1025
  },
1026
+ hasLinkedChildren() {
1027
+ for (const children of byTopic.values()) {
1028
+ for (const entry of children.values()) {
1029
+ if (entry.linked) {
1030
+ return true;
1031
+ }
1032
+ }
1033
+ }
1034
+ return false;
1035
+ },
1036
+ linkedChildren() {
1037
+ const out = [];
1038
+ for (const children of byTopic.values()) {
1039
+ for (const entry of children.values()) {
1040
+ if (entry.linked) {
1041
+ out.push({ topicId: entry.topicId, childCohortCoord: entry.childCohortCoord, effectiveAt: entry.lastEffectiveAt });
1042
+ }
1043
+ }
1044
+ }
1045
+ return out;
1046
+ },
958
1047
  };
959
1048
  }
960
1049
  /**
@@ -1111,6 +1200,15 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
1111
1200
  return undefined;
1112
1201
  }
1113
1202
  };
1203
+ // Set by `close()` (registry eviction, or host `stop()`); makes every time-driven entry point below inert.
1204
+ // The gossip-cadence driver iterates a `registry.all()` SNAPSHOT and awaits between engines, so an inbound
1205
+ // register / child-link / cold-sibling frame landing in one of those awaits can evict — and close — an
1206
+ // engine the tick is still walking. Driving a closed engine afterwards is not merely wasted work: a
1207
+ // `pumpMembership` would publish a cert and re-run `onCertPublished` → `verifier.cache`, re-locking the very
1208
+ // coord `onEngineEvicted` → `verifier.forget` had just released, for a coord this node no longer serves.
1209
+ // Guarding here rather than in the driver keeps the invariant total: a closed engine does nothing, whoever
1210
+ // still holds a reference to it.
1211
+ let closed = false;
1114
1212
  /**
1115
1213
  * Publish (or refresh) this cohort's membership cert, attaching a rotation attestation when the cohort
1116
1214
  * identity (epoch) changed since the last publish. `refresh` selects the publisher path: `false` for a
@@ -1121,7 +1219,7 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
1121
1219
  * tick. Key-less interim mode no-ops (the verify-only signer cannot assemble).
1122
1220
  */
1123
1221
  const publishMembership = async (now, refresh) => {
1124
- if (!canPublish) {
1222
+ if (closed || !canPublish) {
1125
1223
  return undefined;
1126
1224
  }
1127
1225
  const snapshot = snapshotAt(now); // also observes the current identity (snapshotAt → cohort())
@@ -1168,6 +1266,11 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
1168
1266
  cohortCoord: () => servedCoord,
1169
1267
  cohortEpoch: localEpoch,
1170
1268
  signer: noticeSigner,
1269
+ // Seed a topic's replay ordering + direction from the node-level adopted-transition record (the
1270
+ // promote gate's map), which outlives this engine — so recreating an evicted engine neither reopens
1271
+ // the replay window nor forgets a correct promoted mode. For a notice at this served coord both a
1272
+ // promotion's `fromTier` and a demotion's `tier` equal `treeTier`, so this key matches both writes.
1273
+ seedTransition: (topicId) => ctx.adoptedTransition?.(servedCoord, treeTier, topicId),
1171
1274
  // Production defaults (cap_promote = 64, …) unless the host was given a promotion override — the
1172
1275
  // live-tier e2e lowers `capPromote` to drive promotion with a small participant count. The
1173
1276
  // coord-derived inputs above (treeTier / childCohortCount / parentCoord) are never overridden.
@@ -1286,11 +1389,18 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
1286
1389
  // NOTE: re-broadcasts willingness for every idle-but-willing cohort every T_willingness_heartbeat; if a
1287
1390
  // node ever serves very many idle cohorts, batch the heartbeats or lengthen the interval.
1288
1391
  let lastGossipAt;
1392
+ // Timestamp of the last round in which this engine re-advertised its linked child set, or `undefined` if it
1393
+ // never has. Bounds child-set resync to one frame per `T_willingness_heartbeat`, not one per gossip round.
1394
+ let lastChildReadvertAt;
1289
1395
  // One gossip round: sweep stale records (firing the `evicted` deltas), freeze each resident topic's
1290
- // traffic summary, drain the touch/evicted deltas, then assemble + sign + broadcast the frame. An idle
1291
- // engine (no topics, no deltas) normally builds no frame — except a willingness heartbeat, where an idle
1292
- // but willing engine still emits a willingness/load-only frame so a cold cohort can bootstrap.
1396
+ // traffic summary, re-advertise the linked child set on its throttle, drain the touch/evicted/child deltas,
1397
+ // then assemble + sign + broadcast the frame. An idle engine (no topics, no deltas) normally builds no
1398
+ // frame — except a willingness heartbeat, where an idle but willing engine still emits a willingness/load-
1399
+ // only frame so a cold cohort can bootstrap.
1293
1400
  const gossipRound = async (now) => {
1401
+ if (closed) {
1402
+ return undefined;
1403
+ }
1294
1404
  engine.sweepStale(now);
1295
1405
  const topicSummaries = residentTopics().map((topicId) => toCohortTopicSummary(topicId, traffic.publish(topicId, now), {
1296
1406
  tier: tierOfTopic(store, topicId),
@@ -1301,6 +1411,34 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
1301
1411
  // consistent cohort-wide, not a single-member shard.
1302
1412
  childCohortCount: childRegistry.count(topicId),
1303
1413
  }));
1414
+ // Child-set resync. A link/unlink delta is broadcast exactly once, so a member whose engine was
1415
+ // instantiated after a link drained (a parent rotation, or any FRET reshuffle adding a node to the
1416
+ // parent cohort) would read `childCohortCount == 0` for children that plainly exist — wrongly clearing
1417
+ // the demotion gate and telling seekers the topic is not promoted. So every `willingnessHeartbeatMs`
1418
+ // re-emit the *currently-linked* set at each child's original `effectiveAt`. Safe by construction: the
1419
+ // receiving merge is last-writer-wins on `effectiveAt`, so a re-advertisement is a no-op on a member
1420
+ // that already holds it and cannot resurrect a child released by a newer unlink. Queued through
1421
+ // `pending` (not straight into the frame) so a same-round *local* link for the same child collapses to
1422
+ // one ref instead of two (`queueChild` is last-writer-wins per `(topic, child)`). Sourcing from the
1423
+ // registry rather than the queue is what makes the unlink direction safe: an already-released child is
1424
+ // simply absent from `linkedChildren()`, and nothing between here and `drain()` awaits, so a
1425
+ // re-advertisement can never race a same-round unlink. Bounded to one extra frame per engine per
1426
+ // heartbeat, and the round is deliberately non-idle — the frame is the carrier.
1427
+ // NOTE: this heals a missed *link*, never a missed *unlink* — absence is not advertised, so a member
1428
+ // that dropped an unlink frame over-counts until it hears another delta for that child. Both consumers
1429
+ // fail conservatively there (demotion stays blocked; a seeker sweeps a no-longer-promoted tier —
1430
+ // wasteful, not wrong). If over-counting ever needs healing too, advertise the tombstones as well.
1431
+ if (lastChildReadvertAt === undefined || now - lastChildReadvertAt >= ctx.willingnessHeartbeatMs) {
1432
+ const linked = childRegistry.linkedChildren();
1433
+ for (const child of linked) {
1434
+ pending.childLink(child.topicId, child.childCohortCoord, child.effectiveAt);
1435
+ }
1436
+ // Only start the clock once something was actually enqueued: an engine that parents nothing stays
1437
+ // silent and re-advertises immediately when it first does hold a child.
1438
+ if (linked.length > 0) {
1439
+ lastChildReadvertAt = now;
1440
+ }
1441
+ }
1304
1442
  const { records, evicted, childLinks, childUnlinks } = pending.drain();
1305
1443
  const idle = topicSummaries.length === 0 && records.length === 0 && evicted.length === 0
1306
1444
  && childLinks.length === 0 && childUnlinks.length === 0;
@@ -1335,7 +1473,7 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
1335
1473
  // parent coord) via the same path a promotion uses. Skipped without a key (verify-only signer can't
1336
1474
  // assemble); for the single-cohort tier-0 milestone the lifecycle never demotes (the root has no parent).
1337
1475
  const demotionTick = async (now) => {
1338
- if (!canPublish) {
1476
+ if (closed || !canPublish) {
1339
1477
  return;
1340
1478
  }
1341
1479
  for (const topicId of residentTopics()) {
@@ -1352,14 +1490,39 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
1352
1490
  }
1353
1491
  }
1354
1492
  };
1493
+ // The exhaustive engine-owned-state census the registry's eviction ranking consults. One entry per
1494
+ // EngineStateKind — the total-Record type makes forgetting a new kind here a compile error. `children`
1495
+ // derives from the *linked* set (tombstones excluded), so a parent whose every child demoted away is
1496
+ // reclaimable again; `promotion` counts only adopted transition state (a `promoted` flag or a
1497
+ // `lastEffectiveAt` high-water — growth samples alone are rebuilt from the store and do not count).
1498
+ // NOTE: `evictOne` runs this once per resident engine per eviction — up to `coordEnginesMax` (2048)
1499
+ // censuses for one reclaim. Three of the four arms short-circuit and allocate nothing; `records` still
1500
+ // materializes every registration via `store.listAll()` only to read `.length` (pre-dating this census —
1501
+ // the old idle predicate did the same). Unmeasured, and bounded by a registry that is full of *cold*
1502
+ // engines in the case that matters. If eviction ever shows up in a profile, give `RegistrationStore` an
1503
+ // `isEmpty()` (its `byTopic` map already knows) and read that here.
1504
+ const liveness = () => ({
1505
+ records: store.listAll().length > 0,
1506
+ forwarders: coldStart.hasForwarders(),
1507
+ children: childRegistry.hasLinkedChildren(),
1508
+ promotion: promotion.hasAdoptedState(),
1509
+ });
1355
1510
  return {
1356
1511
  servedCoord,
1357
1512
  treeTier,
1358
1513
  engine,
1359
1514
  cohort,
1360
1515
  cohortIdentityAt: (epoch) => rotationState.membersAt(bytesToB64url(epoch)),
1361
- hasState: () => store.listAll().length > 0,
1362
- hasForwarders: () => coldStart.hasForwarders(),
1516
+ liveness,
1517
+ // Derivations of `liveness()` kept on the interface for existing callers/specs, but sourced from
1518
+ // the same census so the two views cannot drift.
1519
+ // NOTE: each call runs the FULL census (allocating one record, and touching the child registry and
1520
+ // promotion lifecycle) where it used to be a bare `store.listAll()` check. Free today: as of this
1521
+ // writing nothing in `src/` calls either accessor — the registry ranks via `liveness()` directly and
1522
+ // the only callers are specs. If a hot path (a per-frame dispatch gate, say) ever starts calling one
1523
+ // per message, give it a direct single-field read instead of routing it through the census.
1524
+ hasState: () => liveness().records,
1525
+ hasForwarders: () => liveness().forwarders,
1363
1526
  holds: (topicId, participantId) => store.getByParticipant(topicId, participantId) !== undefined,
1364
1527
  records: (topicId) => store.listByTopic(topicId),
1365
1528
  topicTraffic: (topicId) => traffic.snapshot(topicId),
@@ -1400,7 +1563,10 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
1400
1563
  pumpMembership: (now) => publishMembership(now, true),
1401
1564
  gossipRound,
1402
1565
  demotionTick,
1403
- close: () => bus.close(),
1566
+ close: () => {
1567
+ closed = true;
1568
+ bus.close();
1569
+ },
1404
1570
  };
1405
1571
  }
1406
1572
  /**
@@ -1624,14 +1790,53 @@ export async function handleSignRequest(request, fromPeerStr, deps) {
1624
1790
  return { v: 1, signer: bytesToB64url(deps.selfMember), signature: bytesToB64url(signature) };
1625
1791
  }
1626
1792
  /**
1627
- * Hard cap on tracked per-served-coord high-water entries; the least-recently-touched are evicted beyond
1628
- * this. A modest bound is plenty — only verified applies grow the map, so it never evicts under legitimate
1629
- * load — but it caps the otherwise retain-forever shape on a long-lived node.
1793
+ * Hard cap on tracked adopted-transition entries; the least-recently-touched are evicted beyond this. A
1794
+ * modest bound is plenty — only adopted transitions grow the map but it caps the otherwise
1795
+ * retain-forever shape on a long-lived node.
1796
+ *
1797
+ * NOTE: this counts `(coord, tier, topic)` triples, NOT engines, so it is not a per-engine guarantee: a
1798
+ * node at the {@link DEFAULT_COORD_ENGINES_MAX} (2048) registry cap that has transitioned more than ~4
1799
+ * topics per coord can overflow it, and the least-recently-touched entries go first. That is safe while
1800
+ * the owning engine is resident (its `lastEffectiveAt` still orders replays); if a node ever runs that
1801
+ * wide AND evicts engines, raise this cap rather than relying on the engine layer.
1630
1802
  */
1631
- export const PROMOTE_HIGHWATER_MAX_KEYS = 8192;
1803
+ export const PROMOTE_TRANSITIONS_MAX_KEYS = 8192;
1632
1804
  /** Build the default {@link PromoteGate} from the (optional) anti-DoS rate-limiter config. */
1633
1805
  export function createPromoteGate(rateLimiterConfig) {
1634
- return { rateLimiter: createRegisterRateLimiter(rateLimiterConfig), highWater: new LruMap(PROMOTE_HIGHWATER_MAX_KEYS) };
1806
+ return { rateLimiter: createRegisterRateLimiter(rateLimiterConfig), transitions: new LruMap(PROMOTE_TRANSITIONS_MAX_KEYS) };
1807
+ }
1808
+ /** The {@link PromoteGate.transitions} key for one `(cohortCoord, tier, topicId)` (all b64url-encoded parts). */
1809
+ export function transitionKey(cohortCoordB64, tier, topicIdB64) {
1810
+ return `${cohortCoordB64}|${tier}|${topicIdB64}`;
1811
+ }
1812
+ /**
1813
+ * The {@link transitionKey} a notice's own fields address: the served `cohortCoord` it was decided at, the
1814
+ * tier the deciding cohort serves the topic at (`fromTier` on a promotion, `tier` on a demotion — both the
1815
+ * engine's own tree tier for a notice at its served coord), and the topic. A demotion also fans to the
1816
+ * parent coord, but that parent-unlink path is deliberately outside this ordering — see
1817
+ * {@link applyDemotionUnlinkAtParent}.
1818
+ */
1819
+ export function noticeTransitionKey(notice) {
1820
+ return transitionKey(notice.cohortCoord, "parentCohortCoord" in notice ? notice.tier : notice.fromTier, notice.topicId);
1821
+ }
1822
+ /**
1823
+ * Record `notice` as the last adopted transition for its `(coord, tier, topic)` — monotonic per key (an
1824
+ * older or equal `effectiveAt` never overwrites), with the direction taken from the notice's shape (a
1825
+ * promotion sets `promoted = true`, a demotion `false`). Called from BOTH adopt paths: the verified
1826
+ * inbound apply in {@link handleInboundNotice} and the host's `broadcastNotice` origination seam.
1827
+ */
1828
+ export function recordAdoptedTransition(gate, notice) {
1829
+ // NOTE: this write is monotonic but `promote()` / `demote()` origination is not — the lifecycle stamps
1830
+ // `effectiveAt = now` without consulting its own `lastEffectiveAt`. A backwards local clock can therefore
1831
+ // originate a transition the record refuses, leaving the record's direction stale relative to the engine
1832
+ // until the next forward-clock transition. Harmless today (the wall clock only skews backwards on an NTP
1833
+ // step, and the same skew already perturbs every `effectiveAt`-ordered path); if origination ever gains a
1834
+ // monotonic clock, drop the divergence by ordering `promote()` / `demote()` against `lastEffectiveAt` too.
1835
+ const key = noticeTransitionKey(notice);
1836
+ const held = gate.transitions.get(key);
1837
+ if (held === undefined || notice.effectiveAt > held.effectiveAt) {
1838
+ gate.transitions.set(key, { effectiveAt: notice.effectiveAt, promoted: !("parentCohortCoord" in notice) });
1839
+ }
1635
1840
  }
1636
1841
  /**
1637
1842
  * Decode a `promote`-protocol frame as a {@link PromotionNoticeV1} or {@link DemotionNoticeV1} (try one,
@@ -1726,11 +1931,12 @@ export async function verifyAndApplyNotice(inbound, target, verifier, now) {
1726
1931
  * - `"unlinked"` — verified; the child was unrecorded (or was already released — the child registry's own
1727
1932
  * per-`(topic, childCoord)` freshness makes a replay an idempotent no-op).
1728
1933
  *
1729
- * **Freshness is the child registry's, not the promote-gate high-water.** The sibling-adopt high-water is keyed
1730
- * by the child coord and advanced only on a sibling-adopt `"applied"`; the unlink is ordered independently by
1731
- * the child registry's per-child `lastEffectiveAt`, so a demotion that is a stale no-op for the sibling-adopt
1732
- * target still applies the unlink at the parent, and vice-versa. The verify carries the same
1733
- * {@link PROMOTE_REFETCH_MIN_INTERVAL_MS} bound as the sibling-adopt, so it cannot amplify into dials.
1934
+ * **Freshness is the child registry's, not the promote-gate transition record.** The sibling-adopt record is
1935
+ * keyed by the child coord and, on the inbound path, advanced only on a sibling-adopt `"applied"`; the unlink
1936
+ * is ordered independently by the child registry's per-child `lastEffectiveAt`, so a demotion that is a stale
1937
+ * no-op for the sibling-adopt target still applies the unlink at the parent, and vice-versa. The verify
1938
+ * carries the same {@link PROMOTE_REFETCH_MIN_INTERVAL_MS} bound as the sibling-adopt, so it cannot amplify
1939
+ * into dials.
1734
1940
  */
1735
1941
  export async function applyDemotionUnlinkAtParent(notice, registry, verifier, now) {
1736
1942
  const parent = registry.findByCoord(b64urlToBytes(notice.parentCohortCoord));
@@ -1764,16 +1970,17 @@ export async function applyDemotionUnlinkAtParent(notice, registry, verifier, no
1764
1970
  * work:
1765
1971
  *
1766
1972
  * ```
1767
- * decode → per-(peer,topic) rate limit → resolve engine by carried cohortCoord → effectiveAt high-water → verify+apply
1768
- * ↘ (demotion) parent-unlink at parentCohortCoord
1973
+ * decode → per-(peer,topic) rate limit → resolve engine by carried cohortCoord → adopted-transition stale gate → verify+apply
1974
+ * ↘ (demotion) parent-unlink at parentCohortCoord
1769
1975
  * ```
1770
1976
  *
1771
1977
  * A **demotion** carries a second, independent apply semantics: beyond the sibling-adopt above, it also
1772
1978
  * releases the demoting child at its parent cohort ({@link applyDemotionUnlinkAtParent}). Both paths may fire
1773
1979
  * on one node (one that serves both the child coord and the parent coord). The parent-unlink runs OUTSIDE the
1774
- * sibling-adopt high-water (which is keyed by the child coord and would otherwise stale-drop the parent-coord
1775
- * frame after the child-coord frame advanced it); its freshness is the child registry's own per-child key.
1776
- * `"unlinked"` is returned when the unlink fired but the sibling-adopt did not (a parent-only node).
1980
+ * sibling-adopt transition record (which is keyed by the child coord and would otherwise stale-drop the
1981
+ * parent-coord frame after the child-coord frame advanced it); its freshness is the child registry's own
1982
+ * per-child key. `"unlinked"` is returned when the unlink fired but the sibling-adopt did not (a parent-only
1983
+ * node).
1777
1984
  *
1778
1985
  * - **Rate limit** (`gate.rateLimiter`) keys on `(from, topicId)`; an over-rate peer is dropped before the
1779
1986
  * coord lookup and the verify, so a peer cannot amplify junk into verify/network work.
@@ -1781,10 +1988,11 @@ export async function applyDemotionUnlinkAtParent(notice, registry, verifier, no
1781
1988
  * notice was decided for, covered by its signature. A node serving several sibling cohorts for one
1782
1989
  * `(topic, tier)` applies the notice to the cohort that produced it, never a first-match `(topic, tier)`
1783
1990
  * scan; a coord this node does not serve is dropped.
1784
- * - **High-water** (`gate.highWater`, keyed per served `cohortCoord`) drops a notice whose `effectiveAt` is
1785
- * at or below the last *applied* one — a replay / out-of-order frame — before `verifyMessage`. It is
1786
- * advanced **only** on an `"applied"` outcome, so a forged frame (which never verifies) cannot poison it.
1787
- * Keying by coord (not `(topic, tier)`) keeps two sibling cohorts on one node from sharing a water.
1991
+ * - **Stale gate** (`gate.transitions`, keyed per `(cohortCoord, tier, topicId)`) drops a notice whose
1992
+ * `effectiveAt` is at or below the last *adopted* transition — a replay / out-of-order frame — before
1993
+ * `verifyMessage`. On this inbound path it advances **only** on an `"applied"` outcome, so a forged frame
1994
+ * (which never verifies) cannot poison it. Keying by coord keeps two sibling cohorts on one node from
1995
+ * sharing an entry; keying by topic keeps two topics at one coord ordering independently.
1788
1996
  * - The receiver-side `cohortEpoch` is intentionally **not** gated on: the epoch rotates on every
1789
1997
  * membership change, so a legitimately in-flight notice can briefly carry the prior epoch right after a
1790
1998
  * rotation — making an epoch check a brittle, false-positive-prone filter. The rate limiter + high-water
@@ -1820,21 +2028,20 @@ export async function handleInboundNotice(frame, from, registry, verifier, gate,
1820
2028
  siblingOutcome = "dropped";
1821
2029
  }
1822
2030
  else {
1823
- // Freshness / replay gate: drop an at-or-below-high-water notice before the expensive verify. Keyed by the
1824
- // served coord (which uniquely identifies the cohort) so two sibling cohorts on one node do not share a
1825
- // high-water an applied notice for cohort A must not stale-drop a legitimate cohort-B notice. `tier` is
1826
- // kept in the key only for readability.
1827
- const waterKey = `${inbound.notice.cohortCoord}|${tier}`;
1828
- const water = gate.highWater.get(waterKey);
1829
- if (water !== undefined && inbound.notice.effectiveAt <= water) {
1830
- 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);
2031
+ // Freshness / replay gate: drop a notice at or below the last adopted transition for its
2032
+ // `(coord, tier, topic)` before the expensive verify. Keyed per coord (so two sibling cohorts on one
2033
+ // node never share an entry) AND per topic (so an applied notice for topic A never stale-drops a
2034
+ // legitimate topic-B notice at the same coord).
2035
+ const held = gate.transitions.get(noticeTransitionKey(inbound.notice));
2036
+ if (held !== undefined && inbound.notice.effectiveAt <= held.effectiveAt) {
2037
+ log("promote: stale %s notice for topic %s tier %d (effectiveAt %d <= last adopted %d)", inbound.kind, inbound.notice.topicId, tier, inbound.notice.effectiveAt, held.effectiveAt);
1831
2038
  siblingOutcome = "stale";
1832
2039
  }
1833
2040
  else {
1834
2041
  siblingOutcome = await verifyAndApplyNotice(inbound, target, verifier, now);
1835
2042
  if (siblingOutcome === "applied") {
1836
- // Advance the high-water only on a *verified-and-applied* notice, so a forged frame cannot poison it.
1837
- gate.highWater.set(waterKey, inbound.notice.effectiveAt);
2043
+ // Record only a *verified-and-applied* notice, so a forged frame cannot poison the ordering.
2044
+ recordAdoptedTransition(gate, inbound.notice);
1838
2045
  }
1839
2046
  else {
1840
2047
  log("promote: %s %s notice for topic %s tier %d", siblingOutcome, inbound.kind, inbound.notice.topicId, tier);
@@ -1846,12 +2053,12 @@ export async function handleInboundNotice(frame, from, registry, verifier, gate,
1846
2053
  }
1847
2054
  // --- Parent-unlink path (demotion only): additionally release the demoting child at its parent cohort. ---
1848
2055
  // A demotion is fanned to BOTH the child coord (sibling-adopt, above) and the parent coord (this unlink),
1849
- // arriving as two independent frames. This path is deliberately OUTSIDE the sibling-adopt high-water: that
1850
- // water is keyed by the child coord and advanced only on a sibling-adopt apply, so — on a node serving both
1851
- // coords — the child-coord frame would advance the water and stale-drop the parent-coord frame before it
1852
- // could unrecord. The child registry's own per-`(topic, childCoord)` freshness orders the unlink instead, so
1853
- // a replay is an idempotent no-op. The verify is against the child cohort cert (same as the sibling-adopt),
1854
- // so a forged demotion cannot unrecord.
2056
+ // arriving as two independent frames. This path is deliberately OUTSIDE the sibling-adopt transition
2057
+ // record: that record is keyed by the child coord and advanced on a sibling-adopt apply, so — on a node
2058
+ // serving both coords — the child-coord frame would advance it and stale-drop the parent-coord frame before
2059
+ // it could unrecord. The child registry's own per-`(topic, childCoord)` freshness orders the unlink instead,
2060
+ // so a replay is an idempotent no-op. The verify is against the child cohort cert (same as the
2061
+ // sibling-adopt), so a forged demotion cannot unrecord.
1855
2062
  const unlink = await applyDemotionUnlinkAtParent(inbound.notice, registry, verifier, now);
1856
2063
  if (siblingOutcome === "applied") {
1857
2064
  return "applied"; // a node serving both coords: the sibling-adopt is the primary reported outcome