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