@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
package/src/cohort-topic/host.ts
CHANGED
|
@@ -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`
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
createLoadBarometer,
|
|
77
78
|
createTierAddressing,
|
|
78
79
|
createRegisterRateLimiter,
|
|
80
|
+
DEFAULT_RATE_WINDOW_MS,
|
|
79
81
|
createCorrelationReplayGuard,
|
|
80
82
|
createTopicBudget,
|
|
81
83
|
createBootstrapEvidence,
|
|
@@ -84,18 +86,19 @@ import {
|
|
|
84
86
|
DEFAULT_MIN_SIGS,
|
|
85
87
|
DEFAULT_MAX_NO_POW_TIER,
|
|
86
88
|
DEFAULT_TRAFFIC_WINDOW_SECONDS,
|
|
87
|
-
DEFAULT_TTL_MS,
|
|
88
89
|
bytesToB64url,
|
|
89
90
|
b64urlToBytes,
|
|
90
91
|
bytesEqual,
|
|
91
|
-
compareBytes,
|
|
92
92
|
encodeCohortMessage,
|
|
93
93
|
decodeCohortMessage,
|
|
94
|
+
decodeCohortGossipV1,
|
|
94
95
|
membershipCertSignable,
|
|
95
96
|
membershipCertSigningPayload,
|
|
96
97
|
toCohortTopicSummary,
|
|
97
98
|
validateRegisterV1,
|
|
98
99
|
validateRenewV1,
|
|
100
|
+
validateChildLinkV1,
|
|
101
|
+
validateChildLinkReplyV1,
|
|
99
102
|
validateSignRequestV1,
|
|
100
103
|
validateSignReplyV1,
|
|
101
104
|
validatePromotionNoticeV1,
|
|
@@ -105,8 +108,12 @@ import {
|
|
|
105
108
|
cohortGossipSigningPayload,
|
|
106
109
|
promotionNoticeSigningPayload,
|
|
107
110
|
demotionNoticeSigningPayload,
|
|
111
|
+
childLinkSigningPayload,
|
|
108
112
|
type BootstrapEvidence,
|
|
109
113
|
type BootstrapEvidenceDeps,
|
|
114
|
+
type ChildLinkRefV1,
|
|
115
|
+
type ChildLinkV1,
|
|
116
|
+
type ChildLinkReplyV1,
|
|
110
117
|
type CohortGossipV1,
|
|
111
118
|
type CohortGossipSignable,
|
|
112
119
|
type CohortTopicService,
|
|
@@ -144,11 +151,10 @@ import {
|
|
|
144
151
|
type IMembershipSource,
|
|
145
152
|
type TrustRoot,
|
|
146
153
|
} from "@optimystic/db-core";
|
|
147
|
-
import { randomBytes } from "@libp2p/crypto";
|
|
148
154
|
import { peerIdFromString } from "@libp2p/peer-id";
|
|
149
155
|
import { FretTopicRouter } from "./topic-router.js";
|
|
150
156
|
import { FretCohortGossipTransport, type CohortPeerResolver } from "./cohort-gossip-transport.js";
|
|
151
|
-
import { buildCohortGossip, createPendingDeltas, DEFAULT_GOSSIP_INTERVAL_MS } from "./cohort-gossip-driver.js";
|
|
157
|
+
import { buildCohortGossip, createPendingDeltas, DEFAULT_GOSSIP_INTERVAL_MS, DEFAULT_WILLINGNESS_HEARTBEAT_MS } from "./cohort-gossip-driver.js";
|
|
152
158
|
import { FretMembershipSource } from "./membership-source.js";
|
|
153
159
|
import { FretMembershipPublishSink } from "./membership-publish-sink.js";
|
|
154
160
|
import { FretCohortThresholdCrypto, createVerifyOnlyThresholdCrypto } from "./threshold-crypto.js";
|
|
@@ -185,6 +191,16 @@ export interface CohortTopicHostOptions {
|
|
|
185
191
|
* self-gate on elapsed time, so a fast tick is safe.
|
|
186
192
|
*/
|
|
187
193
|
readonly gossipIntervalMs?: number;
|
|
194
|
+
/**
|
|
195
|
+
* `T_willingness_heartbeat` (ms): how often a genuinely-**idle but willing** {@link CoordEngine}
|
|
196
|
+
* re-broadcasts a willingness-only heartbeat so a cold cohort can bootstrap (siblings hear it, instantiate
|
|
197
|
+
* their own engine, and reciprocate — §Cold-start instantiation). Default
|
|
198
|
+
* {@link DEFAULT_WILLINGNESS_HEARTBEAT_MS} (~30 s). The first idle round after an engine is created emits
|
|
199
|
+
* immediately regardless of this interval; a record-carrying round resets the clock, so this only paces the
|
|
200
|
+
* steady-state re-broadcast of an idle willing cohort. Independent of {@link gossipIntervalMs} (the tick
|
|
201
|
+
* cadence): the tick can fire fast while heartbeats stay throttled.
|
|
202
|
+
*/
|
|
203
|
+
readonly willingnessHeartbeatMs?: number;
|
|
188
204
|
/**
|
|
189
205
|
* The node's libp2p Ed25519 private key. Required for the live participant signer: register/renew
|
|
190
206
|
* bodies are peer-key-signed over their canonical image, and inbound register/`reattach` signatures
|
|
@@ -264,6 +280,15 @@ export interface CohortTopicAntiDosOptions {
|
|
|
264
280
|
readonly replayGuard?: CorrelationReplayGuardConfig;
|
|
265
281
|
/** Per-cohort forwarder-state budget. Default `topics_max = 2048`, LRU by participant count. */
|
|
266
282
|
readonly topicBudget?: TopicBudgetConfig;
|
|
283
|
+
/**
|
|
284
|
+
* Hard cap on the number of live per-coord cohort engines this node keeps. The served coord is a hash
|
|
285
|
+
* over attacker-chosen `(treeTier, participantCoord, topicId)`, and an engine is created **before** the
|
|
286
|
+
* per-coord anti-DoS gates run, so without this cap one peer spraying distinct coords forces unbounded
|
|
287
|
+
* engine allocation. On overflow the registry evicts the least-recently-used **idle** engine (no records,
|
|
288
|
+
* no cold-start forwarder) and tears it down; if every slot holds a live cohort it refuses the new coord.
|
|
289
|
+
* Default {@link DEFAULT_COORD_ENGINES_MAX}.
|
|
290
|
+
*/
|
|
291
|
+
readonly coordEnginesMax?: number;
|
|
267
292
|
/**
|
|
268
293
|
* Bootstrap-evidence verifiers for cold-root instantiation. db-core embeds no PoW / reputation /
|
|
269
294
|
* committed-work scheme; inject the real checks here. Any verifier supplied wins over the defaults
|
|
@@ -332,6 +357,13 @@ export interface CoordEngine {
|
|
|
332
357
|
cohortIdentityAt(epoch: Uint8Array): readonly string[] | undefined;
|
|
333
358
|
/** True iff this engine currently holds any registration record (a cold probe leaves it empty). */
|
|
334
359
|
hasState(): boolean;
|
|
360
|
+
/**
|
|
361
|
+
* True iff this engine currently holds any cold-start forwarder (possibly `awaiting_parent`). Together
|
|
362
|
+
* with {@link hasState}, this is the "engine is idle / safe to reclaim" predicate the coord-engine
|
|
363
|
+
* registry's LRU eviction reads: an engine with neither a record nor a forwarder is a throwaway (an
|
|
364
|
+
* attacker-sprayed cold coord), an engine with either holds genuine cohort state and is never evicted.
|
|
365
|
+
*/
|
|
366
|
+
hasForwarders(): boolean;
|
|
335
367
|
/** True iff this engine holds the record for `(topicId, participantId)` — the renewal lookup key. */
|
|
336
368
|
holds(topicId: Uint8Array, participantId: Uint8Array): boolean;
|
|
337
369
|
/**
|
|
@@ -348,9 +380,19 @@ export interface CoordEngine {
|
|
|
348
380
|
* lagging at most one gossip round (it reflects the last frozen summaries, never raw mid-round counts).
|
|
349
381
|
*/
|
|
350
382
|
topicTraffic(topicId: Uint8Array): TopicTrafficV1;
|
|
383
|
+
/**
|
|
384
|
+
* Record that an application-level query (matchmaking `QueryV1`, and any future genuine cohort query
|
|
385
|
+
* such as the `AggregateCountV1` sweep) was served against `topicId` at `now`. Feeds `queriesPerMin`.
|
|
386
|
+
* Pure passthrough to this engine's `TrafficCounters` — no store lookup, no instantiation. The counter
|
|
387
|
+
* surfaces in {@link topicTraffic} only after the next {@link gossipRound} freezes it (lags ≤ one round,
|
|
388
|
+
* exactly like the arrivals counter). Non-fatal / synchronous — a separate mutating accessor from the
|
|
389
|
+
* pure-read {@link topicTraffic}; the two are never overloaded.
|
|
390
|
+
*/
|
|
391
|
+
recordQuery(topicId: Uint8Array, now: number): void;
|
|
351
392
|
/**
|
|
352
393
|
* Publish a fresh threshold-signed `MembershipCertV1` on a cohort-membership-change / stabilization
|
|
353
|
-
* event (republishes only when the
|
|
394
|
+
* event (republishes only when the cohort epoch — `H(sorted members)` — changed, i.e. on any member
|
|
395
|
+
* change, head or tail). Returns the cert if published.
|
|
354
396
|
* Resolves `undefined` if no republish was needed or the quorum was unreachable this round.
|
|
355
397
|
*/
|
|
356
398
|
onStabilized(now: number): Promise<MembershipCertV1 | undefined>;
|
|
@@ -394,6 +436,24 @@ export interface CoordEngine {
|
|
|
394
436
|
* Exposes the parent-link lifecycle (`awaiting_parent` → `serving`) for the cold-start wiring (gap 7).
|
|
395
437
|
*/
|
|
396
438
|
forwarder(topicId: Uint8Array): Forwarder | undefined;
|
|
439
|
+
/**
|
|
440
|
+
* Record a verified child cohort (served at `childCohortCoord`) as this cohort's child for `topicId`.
|
|
441
|
+
* Freshness-ordered per child coord (a stale replay cannot flip a newer state) and idempotent (re-linking
|
|
442
|
+
* an already-linked coord is a no-op). Driven by the parent-side child-link dispatch. See
|
|
443
|
+
* {@link CoordEngine.childCohortCount}.
|
|
444
|
+
*/
|
|
445
|
+
recordChild(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): void;
|
|
446
|
+
/**
|
|
447
|
+
* Release a child cohort (served at `childCohortCoord`) this cohort parented — driven by the demoting
|
|
448
|
+
* child's threshold-signed demotion notice fanned to the parent coord. Freshness-ordered per child coord
|
|
449
|
+
* and idempotent (a stale/duplicate release is a no-op); unrecording a never-seen child writes a
|
|
450
|
+
* `linked = false` tombstone (never a negative count) so a later stale link cannot resurrect it. The child
|
|
451
|
+
* set converges across the parent cohort via gossip, so once the last child is released the parent's
|
|
452
|
+
* `childCohortCount` falls to 0 and it can demote in turn. See {@link CoordEngine.childCohortCount}.
|
|
453
|
+
*/
|
|
454
|
+
unrecordChild(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): void;
|
|
455
|
+
/** Count of distinct linked child cohorts this engine parents for `topicId` (the demotion-gate input). */
|
|
456
|
+
childCohortCount(topicId: Uint8Array): number;
|
|
397
457
|
/** Whether `topicId` is in promoted mode here — reflects both locally-originated and remotely-applied state. */
|
|
398
458
|
isPromoted(topicId: Uint8Array): boolean;
|
|
399
459
|
/** Adopt a verified promotion notice into this cohort's local state (see {@link NoticeApplyTarget}). */
|
|
@@ -411,6 +471,12 @@ export interface CoordRegistry {
|
|
|
411
471
|
* `participantCoord` seed a freshly-created engine's coord-derived tier inputs (ignored if the
|
|
412
472
|
* engine already exists). Synchronous, so concurrent activity callbacks for the same coord share
|
|
413
473
|
* one engine without a second being constructed.
|
|
474
|
+
*
|
|
475
|
+
* **Capacity.** The registry is hard-capped (see {@link CohortTopicAntiDosOptions.coordEnginesMax}). A
|
|
476
|
+
* lookup that returns an already-resident engine always succeeds. A *creation* over a full registry
|
|
477
|
+
* first evicts the least-recently-used idle engine; when every slot holds a live cohort it throws
|
|
478
|
+
* {@link CoordEngineRegistryFullError} rather than growing unbounded — callers on the register /
|
|
479
|
+
* child-link / cold-sibling paths catch it and answer a clean capacity refusal.
|
|
414
480
|
*/
|
|
415
481
|
forCoord(coord: RingCoord, treeTier: number, participantCoord: Uint8Array): CoordEngine;
|
|
416
482
|
/** The engine holding the record for `(topicId, participantId)`, or `undefined` (renewal dispatch). */
|
|
@@ -423,10 +489,16 @@ export interface CoordRegistry {
|
|
|
423
489
|
*/
|
|
424
490
|
findByCoord(coord: RingCoord): CoordEngine | undefined;
|
|
425
491
|
/**
|
|
426
|
-
* The engine serving `topicId` at `treeTier`, or `undefined
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
492
|
+
* The **first** engine serving `topicId` at `treeTier`, or `undefined`. Used by the tier-0 read paths —
|
|
493
|
+
* matchmaking query serve and the reactivity direct-subscriber lookup — that resolve a served engine by
|
|
494
|
+
* `(topic, tier)` rather than by an exact coord.
|
|
495
|
+
*
|
|
496
|
+
* **First-match caveat.** At `d ≥ 1` a node can serve several sibling cohorts for one `(topic, tier)` under
|
|
497
|
+
* distinct served coords, and this returns whichever iterates first — exact at the single-cohort / tier-0
|
|
498
|
+
* milestone, but not a coord-precise lookup. The promote/demote notice path deliberately does **not** use
|
|
499
|
+
* this: it routes by the notice's signed `cohortCoord` via {@link findByCoord} so a multi-cohort node
|
|
500
|
+
* applies each notice to the cohort that produced it. Reconciling these tier-0 readers with multi-cohort
|
|
501
|
+
* serving is follow-on work (`cohort-topic-followon-derivation`).
|
|
430
502
|
*/
|
|
431
503
|
findServing(topicId: Uint8Array, treeTier: number): CoordEngine | undefined;
|
|
432
504
|
/** Every live engine (stop + sweep). */
|
|
@@ -460,6 +532,13 @@ export interface CohortTopicHost {
|
|
|
460
532
|
* limiter's `size` and the `highWater` `LruMap` — and so the gossip-cadence sweep wiring is observable.
|
|
461
533
|
*/
|
|
462
534
|
readonly promoteGate: PromoteGate;
|
|
535
|
+
/**
|
|
536
|
+
* The node's local membership-cert cache. Exposed for test/diagnostic introspection: seed an entry via
|
|
537
|
+
* `cache(coord, encoded)` to prime the verifier's stale-refetch path over the real `/membership` protocol
|
|
538
|
+
* without marking it trusted (unlike `verifier.cache()`, which records the cert as self-published), and
|
|
539
|
+
* read it back via `current(coord)` to observe that a `/membership` refetch replaced a stale cached view.
|
|
540
|
+
*/
|
|
541
|
+
readonly membershipSource: { cache(coord: RingCoord, encoded: Uint8Array): void; current(coord: RingCoord): Promise<Uint8Array | undefined> };
|
|
463
542
|
/** Unregister the four protocols and tear down every coord engine. */
|
|
464
543
|
stop(): Promise<void>;
|
|
465
544
|
}
|
|
@@ -476,6 +555,8 @@ interface CoordEngineContext {
|
|
|
476
555
|
readonly wantK: number;
|
|
477
556
|
readonly minSigs: number;
|
|
478
557
|
readonly maxBytes: number;
|
|
558
|
+
/** `T_willingness_heartbeat` (ms): idle-but-willing heartbeat throttle (§Cold-start instantiation). */
|
|
559
|
+
readonly willingnessHeartbeatMs: number;
|
|
479
560
|
/** Membership-cert sink the per-coord publisher serves through (node-wide; serves this node's cohort). */
|
|
480
561
|
readonly publishSink: FretMembershipPublishSink;
|
|
481
562
|
/**
|
|
@@ -544,6 +625,7 @@ export async function createCohortTopicHost(node: Libp2p, fret: FretService, opt
|
|
|
544
625
|
const fanout = options.fanout ?? 16;
|
|
545
626
|
const maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
546
627
|
const gossipIntervalMs = options.gossipIntervalMs ?? DEFAULT_GOSSIP_INTERVAL_MS;
|
|
628
|
+
const willingnessHeartbeatMs = options.willingnessHeartbeatMs ?? DEFAULT_WILLINGNESS_HEARTBEAT_MS;
|
|
547
629
|
|
|
548
630
|
const hash = new RingHash();
|
|
549
631
|
const selfPeerStr = node.peerId.toString();
|
|
@@ -637,6 +719,12 @@ export async function createCohortTopicHost(node: Libp2p, fret: FretService, opt
|
|
|
637
719
|
// transport's cohort peer resolution.
|
|
638
720
|
const broadcastNotice = (notice: PromotionNoticeV1 | DemotionNoticeV1, servedCoord: RingCoord): void => {
|
|
639
721
|
const frame = encodeCohortMessage(notice, maxBytes);
|
|
722
|
+
// A demotion fans to BOTH the demoting child's served coord (siblings adopt `promoted = false` via the
|
|
723
|
+
// `cohortCoord`-routed apply) and the parent coord (the parent unrecords the child). `handleInboundNotice`
|
|
724
|
+
// runs both apply semantics independently: the `cohortCoord` target does the sibling-adopt, and a demotion
|
|
725
|
+
// additionally resolves `parentCohortCoord` and — on the same threshold verify against the child cohort
|
|
726
|
+
// cert — calls `parent.unrecordChild(...)`. A parent-only node applies only the unlink; a node serving
|
|
727
|
+
// both coords applies both. See `noticeBroadcastCoords` / `handleInboundNotice`.
|
|
640
728
|
for (const coord of noticeBroadcastCoords(notice, servedCoord)) {
|
|
641
729
|
gossipTransport.broadcastOver(protocols.promote, coord, frame);
|
|
642
730
|
}
|
|
@@ -683,6 +771,7 @@ export async function createCohortTopicHost(node: Libp2p, fret: FretService, opt
|
|
|
683
771
|
wantK,
|
|
684
772
|
minSigs,
|
|
685
773
|
maxBytes,
|
|
774
|
+
willingnessHeartbeatMs,
|
|
686
775
|
publishSink,
|
|
687
776
|
privateKey: options.privateKey,
|
|
688
777
|
router,
|
|
@@ -703,9 +792,82 @@ export async function createCohortTopicHost(node: Libp2p, fret: FretService, opt
|
|
|
703
792
|
// Cache this node's own freshly-published cohort cert into the verifier, so an inbound notice
|
|
704
793
|
// signed by this node's cohort verifies locally without a network refetch. `verifier` is declared
|
|
705
794
|
// just below; the closure only runs on a (later) publish, after it is initialized.
|
|
795
|
+
//
|
|
796
|
+
// NOTE (stale trust-lock, drop-the-lock-on-demotion): `verifier.cache()` marks this coord *trusted*,
|
|
797
|
+
// which trust-LOCKS it (no un-anchored refetch can downgrade it). If this node later leaves the coord's
|
|
798
|
+
// cohort it stops republishing and its anchor goes `"unknown"`, so it can be stranded distrusting the
|
|
799
|
+
// coord's later-epoch messages. The verifier self-heals via bounded re-TOFU on a demonstrated chain gap
|
|
800
|
+
// (`staleGapRecoveryStrikes`, see `db-core/.../membership/verifier.ts` + `docs/cohort-topic.md`
|
|
801
|
+
// §Bootstrapping trust). The *root-cause* fix is for the host to drop the lock here on demotion, but
|
|
802
|
+
// that needs an engine-reclaim / demotion signal the host does not emit today. `createCoordRegistry`
|
|
803
|
+
// now evicts, but only IDLE engines (no records → never published a cert), so it never strands a
|
|
804
|
+
// trust-lock and does not resolve this on its own (see the NOTE at `evictOneIdle`). When a demotion /
|
|
805
|
+
// cert-publishing-engine reclaim signal lands, add a `verifier.forget(coord)` / downgrade call on
|
|
806
|
+
// demotion and prefer it over (or alongside) the strike-counter heuristic.
|
|
706
807
|
onCertPublished: (cert: MembershipCertV1): void => verifier.cache(cert),
|
|
707
808
|
};
|
|
708
|
-
const registry = createCoordRegistry(ctx);
|
|
809
|
+
const registry = createCoordRegistry(ctx, options.antiDos?.coordEnginesMax);
|
|
810
|
+
|
|
811
|
+
// --- cold-sibling engine instantiation on a verified co-member gossip frame (§Cold-start instantiation) ---
|
|
812
|
+
// A brand-new multi-node cohort deadlocks otherwise: FRET lands every register for a coord on the ONE
|
|
813
|
+
// nearest member, so its siblings are never independently woken by a routed register — they hold no engine,
|
|
814
|
+
// are not subscribed to the coord's gossip, and silently drop the willingness/record frames the served
|
|
815
|
+
// member sends. So replication/failover never materialise. This gate lets a co-member's frame (e.g. the
|
|
816
|
+
// idle-but-willing willingness heartbeat) instantiate the sibling engine, which then joins the gossip and
|
|
817
|
+
// reciprocates its own willingness. Called from the `/cohort-gossip` handler BEFORE `deliver`, so the fresh
|
|
818
|
+
// bus is subscribed in time to merge the very frame that woke it.
|
|
819
|
+
//
|
|
820
|
+
// Bounded to genuine co-members by the existing `verifyGossip` auth check (peer-key signature verifies for
|
|
821
|
+
// `fromMember` AND `fromMember` ∈ `cohortAround(coord).members`), so a peer can only make us instantiate an
|
|
822
|
+
// engine for a coord where FRET assembly agrees we are both members. Live-signer mode only: without a key
|
|
823
|
+
// there is no co-member gate, and unauthenticated engine creation would be a DoS vector, so key-less/interim
|
|
824
|
+
// mode keeps today's behaviour (drop gossip for an unknown coord).
|
|
825
|
+
//
|
|
826
|
+
// Scope: tier-0 only (`treeTier === 0`). A tier-`d > 0` frame carries no topic/participantCoord context a
|
|
827
|
+
// bare willingness heartbeat could seed the parent-coord derivation from, and overlaps the parent-child
|
|
828
|
+
// link work — so a tier-`d > 0` frame for an unknown coord falls through to today's drop (the bus has no
|
|
829
|
+
// engine subscribed to it). See `docs/cohort-topic.md` §Cold-start instantiation.
|
|
830
|
+
//
|
|
831
|
+
// The registry is hard-capped with LRU eviction of idle engines (`createCoordRegistry`), so a
|
|
832
|
+
// gossip-instantiated cold sibling is no longer a permanent per-co-member-coord cost: an idle one is
|
|
833
|
+
// reclaimed under memory pressure like any other cold engine, and a creation over a full-of-live registry
|
|
834
|
+
// is refused (`CoordEngineRegistryFullError`) and dropped here rather than crashing the gossip handler.
|
|
835
|
+
const maybeInstantiateColdSibling = (frame: Uint8Array): void => {
|
|
836
|
+
if (verifyGossip === undefined) {
|
|
837
|
+
return; // key-less / interim mode: no co-member gate, so never auto-instantiate
|
|
838
|
+
}
|
|
839
|
+
let g: CohortGossipV1;
|
|
840
|
+
try {
|
|
841
|
+
g = decodeCohortGossipV1(frame, maxBytes);
|
|
842
|
+
} catch {
|
|
843
|
+
return; // malformed → the normal deliver path drops it
|
|
844
|
+
}
|
|
845
|
+
if (g.treeTier !== 0) {
|
|
846
|
+
return; // tier-0 milestone only (a tier-d>0 unknown-coord frame falls through to drop)
|
|
847
|
+
}
|
|
848
|
+
const coord = b64urlToBytes(g.coord);
|
|
849
|
+
// NOTE: findByCoord bumps LRU recency as a side effect, and this runs BEFORE the verifyGossip
|
|
850
|
+
// co-member gate — so an unverified gossip frame naming a coord we already serve can pin that engine
|
|
851
|
+
// against idle-eviction. Marginal today (an outsider can only pin engines that already exist, bounded
|
|
852
|
+
// by real co-membership; it cannot create engines — that path is gated below). If gossip-flood
|
|
853
|
+
// recency-pinning ever matters, add a touch-free `has(coord)` lookup for this existence probe.
|
|
854
|
+
if (registry.findByCoord(coord) !== undefined) {
|
|
855
|
+
return; // already serving this coord — nothing to instantiate
|
|
856
|
+
}
|
|
857
|
+
if (!verifyGossip(g, coord)) {
|
|
858
|
+
return; // co-member gate: bad signature or non-member → do not instantiate
|
|
859
|
+
}
|
|
860
|
+
// The dummy `participantCoord` seeds only the tier-`d > 0` parent-coord derivation, which a tier-0
|
|
861
|
+
// engine never exercises (demotion is gated on `treeTier > 0`); self's member bytes are a safe filler.
|
|
862
|
+
try {
|
|
863
|
+
registry.forCoord(coord, g.treeTier, selfMemberBytes);
|
|
864
|
+
} catch (err) {
|
|
865
|
+
if (err instanceof CoordEngineRegistryFullError) {
|
|
866
|
+
return; // registry full of live cohorts — drop the cold-sibling instantiation (same as any drop)
|
|
867
|
+
}
|
|
868
|
+
throw err;
|
|
869
|
+
}
|
|
870
|
+
};
|
|
709
871
|
|
|
710
872
|
// --- intra-cohort sign endorsement (the `/sign` handler body) ---
|
|
711
873
|
// A member dials us to endorse a threshold-signed artifact; we sign the exact request payload iff we
|
|
@@ -805,22 +967,86 @@ export async function createCohortTopicHost(node: Libp2p, fret: FretService, opt
|
|
|
805
967
|
// `d` this equals the participant's `coord_d(self, topicId)` routing key by construction, i.e. the
|
|
806
968
|
// coordinate FRET routed to (§Tier addressing).
|
|
807
969
|
const servedCoord = addressing.coord(reg.treeTier, participantCoord, topicId);
|
|
808
|
-
|
|
970
|
+
let coordEngine: CoordEngine;
|
|
971
|
+
try {
|
|
972
|
+
coordEngine = registry.forCoord(servedCoord, reg.treeTier, participantCoord);
|
|
973
|
+
} catch (err) {
|
|
974
|
+
if (err instanceof CoordEngineRegistryFullError) {
|
|
975
|
+
// Every engine slot holds a live cohort — refuse this new coord cleanly. `unwilling_cohort`
|
|
976
|
+
// with a back-off is the walk's "retry in time / restart at d_max" signal (§Capacity barometer),
|
|
977
|
+
// the same shape the per-coord topic budget answers when full-of-populated.
|
|
978
|
+
log("cohort-topic: register refused — coord-engine registry full");
|
|
979
|
+
return { v: 1, result: "unwilling_cohort", retryAfterMs: DEFAULT_RATE_WINDOW_MS, reason: "coord-engine registry full" };
|
|
980
|
+
}
|
|
981
|
+
throw err;
|
|
982
|
+
}
|
|
809
983
|
if (fretCohort !== undefined) {
|
|
810
984
|
crossCheckCohort(fret, wantK, servedCoord, fretCohort);
|
|
811
985
|
}
|
|
812
986
|
// `parentCoord` for a cold-start forwarder's parent registration; undefined at the root.
|
|
813
987
|
const parentCoord = reg.treeTier > 0 ? addressing.coord(reg.treeTier - 1, participantCoord, topicId) : undefined;
|
|
814
|
-
|
|
988
|
+
// Derive `followOn` straight from the participant-asserted wire flag. This is the information the
|
|
989
|
+
// child cohort genuinely cannot infer locally (the tier-addressing hash decorrelates parent/child
|
|
990
|
+
// coords, and FRET carries no breadcrumb of the redirect), so it must be carried on the frame. It is
|
|
991
|
+
// forgeable, so the engine's step-1 bootstrap-evidence gate — which demands the same proof for
|
|
992
|
+
// `followOn: true` as for a `bootstrap: true` cold-root — is what keeps it honest (§Anti-DoS).
|
|
993
|
+
const followOn = reg.followOn === true;
|
|
994
|
+
return coordEngine.engine.handleRegister(reg, { followOn, treeTier: reg.treeTier, parentCoord }, now);
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
// --- child-link dispatch: verify + record a child cohort (parent side) ---
|
|
998
|
+
// A routed `ChildLinkV1` reaches this node's parent engine through the SAME two entry points a register
|
|
999
|
+
// uses (the FRET activity handler + the direct-dial `register` handler). Live-key mode verifies the child
|
|
1000
|
+
// cohort's threshold signature against the child cohort's cert; key-less mode is permissive (mirrors the
|
|
1001
|
+
// `verifyRegisterSig` fallback) so unit/mock flows still link + record.
|
|
1002
|
+
const verifyChildLinkSig = options.privateKey === undefined
|
|
1003
|
+
? undefined
|
|
1004
|
+
: async (link: ChildLinkV1, at: number): Promise<boolean> => {
|
|
1005
|
+
// Live-key: an UNSIGNED link (misconfigured mixed mode) is never permissive-accepted — reject it,
|
|
1006
|
+
// never a silent record.
|
|
1007
|
+
if (link.thresholdSig.length === 0) {
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
let signers: Uint8Array[];
|
|
1011
|
+
let sig: Uint8Array;
|
|
1012
|
+
try {
|
|
1013
|
+
signers = link.signers.map(b64urlToBytes);
|
|
1014
|
+
sig = b64urlToBytes(link.thresholdSig);
|
|
1015
|
+
} catch {
|
|
1016
|
+
return false; // a signer / sig that is not valid base64url cannot verify
|
|
1017
|
+
}
|
|
1018
|
+
// Verify against the CHILD cohort's cert at the child coord/tier — identical to the notice verify at
|
|
1019
|
+
// `verifyAndApplyNotice`, with the same bounded refetch so a link flood cannot amplify into dials.
|
|
1020
|
+
const result = await verifier.verifyMessage(
|
|
1021
|
+
signers,
|
|
1022
|
+
b64urlToBytes(link.childCohortCoord),
|
|
1023
|
+
link.childTier,
|
|
1024
|
+
childLinkSigningPayload(link),
|
|
1025
|
+
sig,
|
|
1026
|
+
{ minRefetchIntervalMs: PROMOTE_REFETCH_MIN_INTERVAL_MS, now: at },
|
|
1027
|
+
);
|
|
1028
|
+
return result === "verified";
|
|
1029
|
+
};
|
|
1030
|
+
const childLinkDeps: DispatchChildLinkDeps = {
|
|
1031
|
+
coord: (tier: number, pc: Uint8Array, topicId: Uint8Array): RingCoord => addressing.coord(tier, pc, topicId),
|
|
1032
|
+
resolveParent: (parentServedCoord: RingCoord, parentTier: number, childParticipantCoord: Uint8Array): CoordEngine =>
|
|
1033
|
+
registry.forCoord(parentServedCoord, parentTier, childParticipantCoord),
|
|
1034
|
+
verifyChildLinkSig,
|
|
815
1035
|
};
|
|
816
1036
|
|
|
817
1037
|
// --- protocol handlers + activity callback ---
|
|
818
1038
|
// Await registration so the host is not returned (and dialed) before the five handlers are live —
|
|
819
1039
|
// and, crucially, before the gossip driver below starts ticking (no tick may run on a half-wired node).
|
|
820
|
-
await registerProtocolHandlers(node, protocols, registry, dispatchRegister, signEndorse, verifier, promoteGate, gossipTransport, publishSink, membershipSource, selfCoord, maxBytes);
|
|
1040
|
+
await registerProtocolHandlers(node, protocols, registry, dispatchRegister, childLinkDeps, minSigs, signEndorse, verifier, promoteGate, gossipTransport, maybeInstantiateColdSibling, publishSink, membershipSource, selfCoord, maxBytes);
|
|
821
1041
|
fret.setActivityHandler(async (activity: string, cohort: string[]): Promise<{ commitCertificate: string }> => {
|
|
822
|
-
const
|
|
823
|
-
|
|
1042
|
+
const decoded = decodeCohortMessage(b64urlToBytes(activity), maxBytes);
|
|
1043
|
+
// Decode-and-branch: a `ChildLinkV1` (a child cohort registering with this parent) runs the child-link
|
|
1044
|
+
// dispatch; everything else is an ordinary participant `RegisterV1`. The two shapes are disjoint (a
|
|
1045
|
+
// child-link has no `treeTier`/`signature`; a register has no `childCohortCoord`/`childTier`).
|
|
1046
|
+
const link = tryValidate(() => validateChildLinkV1(decoded, minSigs));
|
|
1047
|
+
const reply = link !== undefined
|
|
1048
|
+
? await dispatchChildLink(link, childLinkDeps, Date.now())
|
|
1049
|
+
: await dispatchRegister(validateRegisterV1(decoded), cohort, Date.now());
|
|
824
1050
|
return { commitCertificate: bytesToB64url(encodeCohortMessage(reply, maxBytes)) };
|
|
825
1051
|
});
|
|
826
1052
|
|
|
@@ -873,6 +1099,7 @@ export async function createCohortTopicHost(node: Libp2p, fret: FretService, opt
|
|
|
873
1099
|
profile,
|
|
874
1100
|
gossipTransport,
|
|
875
1101
|
promoteGate,
|
|
1102
|
+
membershipSource,
|
|
876
1103
|
stop: async (): Promise<void> => {
|
|
877
1104
|
stopped = true;
|
|
878
1105
|
clearInterval(timer);
|
|
@@ -1047,39 +1274,57 @@ interface ForwarderLink {
|
|
|
1047
1274
|
readonly treeTier: number;
|
|
1048
1275
|
/** The topic's capacity tier (T0–T3); stamps the link frame's `tier`. Defaults to 0 if absent. */
|
|
1049
1276
|
readonly opTier?: number;
|
|
1050
|
-
/** The participant coord that seeded this engine —
|
|
1277
|
+
/** The participant coord that seeded this engine — the child-link's `childParticipantCoord`. */
|
|
1051
1278
|
readonly participantCoord: Uint8Array;
|
|
1279
|
+
/** This child engine's served coord `coord_d(participantCoord, topicId)` — the link's `childCohortCoord`. */
|
|
1280
|
+
readonly childCohortCoord: Uint8Array;
|
|
1281
|
+
/** The child cohort's current epoch (raw bytes), stamped + signed into the link. */
|
|
1282
|
+
readonly cohortEpoch: () => Uint8Array;
|
|
1283
|
+
/**
|
|
1284
|
+
* The child cohort's `"childlink"` threshold signer, or `undefined` in the key-less interim (the link then
|
|
1285
|
+
* ships unsigned and the parent permissive-accepts it).
|
|
1286
|
+
*/
|
|
1287
|
+
readonly signChildLink?: CohortSigner;
|
|
1052
1288
|
}
|
|
1053
1289
|
|
|
1054
1290
|
/**
|
|
1055
|
-
* Route a
|
|
1291
|
+
* Route a child-cohort→parent link to `parentCoord` and resolve **only on a `linked` ack**.
|
|
1056
1292
|
*
|
|
1057
|
-
* The
|
|
1058
|
-
*
|
|
1059
|
-
* `
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
* forwarder `awaiting_parent
|
|
1293
|
+
* The frame is a dedicated {@link ChildLinkV1} (not a participant `RegisterV1`) routed over
|
|
1294
|
+
* {@link ITopicRouter.routeAndAct} keyed at the parent coord: it carries the child's served coord
|
|
1295
|
+
* (`childCohortCoord`) and its seed `childParticipantCoord`, so the parent recomputes
|
|
1296
|
+
* `coord_{d−1}(childParticipantCoord, topicId) == parentCoord` and `coord_d(...) == childCohortCoord`,
|
|
1297
|
+
* binding the relationship. In live-key mode the child cohort threshold-signs the link over its own coord
|
|
1298
|
+
* at its current epoch; key-less it ships unsigned (empty `thresholdSig`/`signers`). The parent authenticates
|
|
1299
|
+
* + records the child and replies {@link ChildLinkReplyV1}; a `rejected` (or unreachable) reply propagates as
|
|
1300
|
+
* a throw so the cold-start manager keeps the forwarder `awaiting_parent` for a later retry.
|
|
1065
1301
|
*/
|
|
1066
1302
|
async function registerForwarderWithParent(ctx: CoordEngineContext, link: ForwarderLink): Promise<void> {
|
|
1067
|
-
const
|
|
1303
|
+
const childTier = link.treeTier;
|
|
1304
|
+
const frame: ChildLinkV1 = {
|
|
1068
1305
|
v: 1,
|
|
1069
1306
|
topicId: bytesToB64url(link.topicId),
|
|
1307
|
+
childCohortCoord: bytesToB64url(link.childCohortCoord),
|
|
1308
|
+
childParticipantCoord: bytesToB64url(link.participantCoord),
|
|
1309
|
+
childTier,
|
|
1070
1310
|
tier: clampTier(link.opTier ?? 0),
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
bootstrap: false,
|
|
1076
|
-
timestamp: Date.now(),
|
|
1077
|
-
correlationId: bytesToB64url(randomBytes(16)),
|
|
1078
|
-
// Interim: the forwarder cohort cannot sign as the participant; the dedicated child-link frame
|
|
1079
|
-
// (follow-on) carries the cohort threshold signature instead.
|
|
1080
|
-
signature: "",
|
|
1311
|
+
effectiveAt: Date.now(),
|
|
1312
|
+
thresholdSig: "",
|
|
1313
|
+
signers: [],
|
|
1314
|
+
cohortEpoch: bytesToB64url(link.cohortEpoch()),
|
|
1081
1315
|
};
|
|
1082
|
-
|
|
1316
|
+
// Live-key: threshold-sign over the canonical child-link image (signature covers only the signable fields,
|
|
1317
|
+
// so filling `thresholdSig`/`signers` after does not alter what was signed). Key-less: leave it unsigned.
|
|
1318
|
+
if (link.signChildLink !== undefined) {
|
|
1319
|
+
const { thresholdSig, signers } = await link.signChildLink.thresholdSign(childLinkSigningPayload(frame));
|
|
1320
|
+
frame.thresholdSig = bytesToB64url(thresholdSig);
|
|
1321
|
+
frame.signers = signers.map(bytesToB64url);
|
|
1322
|
+
}
|
|
1323
|
+
const replyBytes = await ctx.router.routeAndAct(link.parentCoord, encodeCohortMessage(frame, ctx.maxBytes), { wantK: ctx.wantK, minSigs: ctx.minSigs });
|
|
1324
|
+
const reply = validateChildLinkReplyV1(decodeCohortMessage(replyBytes, ctx.maxBytes));
|
|
1325
|
+
if (reply.result !== "linked") {
|
|
1326
|
+
throw new Error(`cohort-topic child-link rejected by parent${reply.reason !== undefined ? `: ${reply.reason}` : ""}`);
|
|
1327
|
+
}
|
|
1083
1328
|
}
|
|
1084
1329
|
|
|
1085
1330
|
/** Clamp an op tier to the valid T0–T3 range so the link frame validates at a (future) real parent. */
|
|
@@ -1092,9 +1337,87 @@ function clampTier(tier: number): number {
|
|
|
1092
1337
|
|
|
1093
1338
|
// --- registry + coord engine ---
|
|
1094
1339
|
|
|
1095
|
-
/**
|
|
1096
|
-
|
|
1340
|
+
/**
|
|
1341
|
+
* Default hard cap on the number of live per-coord cohort engines (see
|
|
1342
|
+
* {@link CohortTopicAntiDosOptions.coordEnginesMax}). Aligned in order of magnitude with the topic-budget
|
|
1343
|
+
* `topics_max` (2048): the same "this many distinct served units before an anti-abuse ceiling bites" scale.
|
|
1344
|
+
*/
|
|
1345
|
+
export const DEFAULT_COORD_ENGINES_MAX = 2048;
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Thrown by {@link CoordRegistry.forCoord} when it must create a new engine but the registry is full of
|
|
1349
|
+
* **live** cohorts (every slot holds records or a cold-start forwarder, so nothing is idle-evictable).
|
|
1350
|
+
* Signals a capacity refusal, not a bug — the register / child-link / cold-sibling dispatch paths catch it
|
|
1351
|
+
* and answer a clean refusal (`unwilling_cohort` / `rejected` / drop) rather than letting it escape.
|
|
1352
|
+
*/
|
|
1353
|
+
export class CoordEngineRegistryFullError extends Error {
|
|
1354
|
+
constructor(maxEngines: number) {
|
|
1355
|
+
super(`cohort-topic coord-engine registry full (max=${maxEngines}) — every slot holds a live cohort`);
|
|
1356
|
+
this.name = "CoordEngineRegistryFullError";
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Build the lazy `servedCoord → CoordEngine` registry over the shared collaborators, hard-capped at
|
|
1362
|
+
* `maxEngines` with least-recently-used eviction of **idle** engines.
|
|
1363
|
+
*
|
|
1364
|
+
* The served coord is a hash over attacker-chosen `(treeTier, participantCoord, topicId)`, and `forCoord`
|
|
1365
|
+
* runs on the register hot path **before** the per-coord anti-DoS gates — so, uncapped, one peer spraying
|
|
1366
|
+
* distinct coords drives unbounded engine allocation (each engine owns a store, gossip bus, rate limiter,
|
|
1367
|
+
* replay guard, topic budget, …). The cap bounds that: on a creation over a full registry we evict the
|
|
1368
|
+
* least-recently-used **idle** engine (no records, no cold-start forwarder — a throwaway cold coord) and
|
|
1369
|
+
* tear it down; when every slot holds a live cohort we refuse the new coord ({@link CoordEngineRegistryFullError})
|
|
1370
|
+
* so a legitimate multi-cohort node keeps working while attacker-driven cold engines cannot pile up.
|
|
1371
|
+
*
|
|
1372
|
+
* Recency is bumped on every lookup that hands back an engine (`forCoord` / `findByCoord` / `findHolder` /
|
|
1373
|
+
* `findServing`), so a hot cohort under load is never the eviction victim.
|
|
1374
|
+
*/
|
|
1375
|
+
function createCoordRegistry(ctx: CoordEngineContext, maxEngines: number = DEFAULT_COORD_ENGINES_MAX): CoordRegistry {
|
|
1376
|
+
if (!Number.isInteger(maxEngines) || maxEngines <= 0) {
|
|
1377
|
+
throw new RangeError(`coordEnginesMax must be a positive integer, got ${maxEngines}`);
|
|
1378
|
+
}
|
|
1097
1379
|
const engines = new Map<string, CoordEngine>();
|
|
1380
|
+
// LRU recency by engine key: a monotonic touch sequence (higher = more recently used). Every engine in
|
|
1381
|
+
// `engines` has an entry; entries are dropped alongside their engine on eviction / close.
|
|
1382
|
+
const recency = new Map<string, number>();
|
|
1383
|
+
let seq = 0;
|
|
1384
|
+
const touch = (key: string): void => { recency.set(key, ++seq); };
|
|
1385
|
+
|
|
1386
|
+
// An engine is idle-evictable iff it holds no registration record AND no cold-start forwarder — i.e. no
|
|
1387
|
+
// genuine cohort state to lose. A live engine (records or a forwarder) is never a throwaway.
|
|
1388
|
+
const isIdle = (engine: CoordEngine): boolean => !engine.hasState() && !engine.hasForwarders();
|
|
1389
|
+
|
|
1390
|
+
// Evict the least-recently-used idle engine to free a slot; returns true iff one was freed. Tears the
|
|
1391
|
+
// victim down (`close()` drops its gossip-bus subscription) so eviction does not leak the subscription.
|
|
1392
|
+
//
|
|
1393
|
+
// NOTE (verifier trust-lock, cohort-topic-treetier-bound-engine-cap): only IDLE engines are evicted here,
|
|
1394
|
+
// and an idle engine (`hasState() === false`) has never published a membership cert — so there is no
|
|
1395
|
+
// verifier trust-lock (`onCertPublished` → `verifier.cache`, above) to drop for its coord. If this policy
|
|
1396
|
+
// is ever widened to evict a cert-publishing engine, add a `verifier.forget(coord)` / downgrade here:
|
|
1397
|
+
// otherwise the stale trust-lock strands the coord's later-epoch messages (the drop-the-lock tripwire the
|
|
1398
|
+
// `onCertPublished` NOTE describes). Do NOT widen without that.
|
|
1399
|
+
const evictOneIdle = (): boolean => {
|
|
1400
|
+
let victimKey: string | undefined;
|
|
1401
|
+
let victimSeq = Infinity;
|
|
1402
|
+
for (const [key, engine] of engines) {
|
|
1403
|
+
if (!isIdle(engine)) {
|
|
1404
|
+
continue; // live cohort (records) or mid-link cold-start forwarder — never evicted
|
|
1405
|
+
}
|
|
1406
|
+
const s = recency.get(key) ?? 0;
|
|
1407
|
+
if (s < victimSeq) {
|
|
1408
|
+
victimSeq = s;
|
|
1409
|
+
victimKey = key;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
if (victimKey === undefined) {
|
|
1413
|
+
return false;
|
|
1414
|
+
}
|
|
1415
|
+
engines.get(victimKey)!.close();
|
|
1416
|
+
engines.delete(victimKey);
|
|
1417
|
+
recency.delete(victimKey);
|
|
1418
|
+
return true;
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1098
1421
|
return {
|
|
1099
1422
|
forCoord(coord: RingCoord, treeTier: number, participantCoord: Uint8Array): CoordEngine {
|
|
1100
1423
|
const key = bytesToB64url(coord);
|
|
@@ -1102,25 +1425,39 @@ function createCoordRegistry(ctx: CoordEngineContext): CoordRegistry {
|
|
|
1102
1425
|
// share one engine rather than racing to construct a second.
|
|
1103
1426
|
let engine = engines.get(key);
|
|
1104
1427
|
if (engine === undefined) {
|
|
1428
|
+
if (engines.size >= maxEngines && !evictOneIdle()) {
|
|
1429
|
+
// Full of live cohorts — refuse rather than grow unbounded. Callers turn this into a clean
|
|
1430
|
+
// capacity reply/drop (see the paths listed on `CoordEngineRegistryFullError`).
|
|
1431
|
+
log("cohort-topic: coord-engine registry full (max=%d) — refusing new coord %s", maxEngines, key);
|
|
1432
|
+
throw new CoordEngineRegistryFullError(maxEngines);
|
|
1433
|
+
}
|
|
1105
1434
|
engine = createCoordEngine(ctx, coord, treeTier, participantCoord);
|
|
1106
1435
|
engines.set(key, engine);
|
|
1107
1436
|
}
|
|
1437
|
+
touch(key);
|
|
1108
1438
|
return engine;
|
|
1109
1439
|
},
|
|
1110
1440
|
findByCoord(coord: RingCoord): CoordEngine | undefined {
|
|
1111
|
-
|
|
1441
|
+
const key = bytesToB64url(coord);
|
|
1442
|
+
const engine = engines.get(key);
|
|
1443
|
+
if (engine !== undefined) {
|
|
1444
|
+
touch(key);
|
|
1445
|
+
}
|
|
1446
|
+
return engine;
|
|
1112
1447
|
},
|
|
1113
1448
|
findHolder(topicId: Uint8Array, participantId: Uint8Array): CoordEngine | undefined {
|
|
1114
|
-
for (const engine of engines
|
|
1449
|
+
for (const [key, engine] of engines) {
|
|
1115
1450
|
if (engine.holds(topicId, participantId)) {
|
|
1451
|
+
touch(key);
|
|
1116
1452
|
return engine;
|
|
1117
1453
|
}
|
|
1118
1454
|
}
|
|
1119
1455
|
return undefined;
|
|
1120
1456
|
},
|
|
1121
1457
|
findServing(topicId: Uint8Array, treeTier: number): CoordEngine | undefined {
|
|
1122
|
-
for (const engine of engines
|
|
1458
|
+
for (const [key, engine] of engines) {
|
|
1123
1459
|
if (engine.treeTier === treeTier && engine.servesTopic(topicId)) {
|
|
1460
|
+
touch(key);
|
|
1124
1461
|
return engine;
|
|
1125
1462
|
}
|
|
1126
1463
|
}
|
|
@@ -1134,6 +1471,7 @@ function createCoordRegistry(ctx: CoordEngineContext): CoordRegistry {
|
|
|
1134
1471
|
engine.close();
|
|
1135
1472
|
}
|
|
1136
1473
|
engines.clear();
|
|
1474
|
+
recency.clear();
|
|
1137
1475
|
},
|
|
1138
1476
|
};
|
|
1139
1477
|
}
|
|
@@ -1155,8 +1493,9 @@ interface CohortIdentity {
|
|
|
1155
1493
|
* Two roles, one small object:
|
|
1156
1494
|
*
|
|
1157
1495
|
* - **Producer** — {@link predecessor} is the identity of the *last published* cert; a publish whose
|
|
1158
|
-
*
|
|
1159
|
-
* round (its epoch is `prevEpoch`, its members are the
|
|
1496
|
+
* epoch (`H(sorted members)`) differs from it is a rotation — any member change, head or tail — and the
|
|
1497
|
+
* predecessor identity scopes the rotation `/sign` round (its epoch is `prevEpoch`, its members are the
|
|
1498
|
+
* outgoing cohort to collect from).
|
|
1160
1499
|
* - **Endorser** — {@link membersAt} answers "was I a member of the cohort at `epoch`?" over a two-deep
|
|
1161
1500
|
* observed-epoch history ({@link current} + {@link prior}), kept fresh by {@link observe} on every
|
|
1162
1501
|
* cohort assembly. A request for an epoch past that window is refused (the rapid-double-rotation gap).
|
|
@@ -1205,15 +1544,102 @@ class RotationState {
|
|
|
1205
1544
|
}
|
|
1206
1545
|
}
|
|
1207
1546
|
|
|
1547
|
+
/**
|
|
1548
|
+
* A per-{@link CoordEngine} registry of the child cohorts this cohort parents, keyed by topic then by child
|
|
1549
|
+
* served coord. Both mutators are freshness-ordered per child coord (a stale/out-of-order replay cannot flip a
|
|
1550
|
+
* newer state) and idempotent (re-applying the same state is a no-op), mirroring `PromotionState.lastEffectiveAt`:
|
|
1551
|
+
* `recordChild` links, `unrecordChild` releases (on a child demotion). Each returns whether it actually changed
|
|
1552
|
+
* state, so the caller re-gossips only real changes. `count` feeds the demotion gate / gossip summary / traffic
|
|
1553
|
+
* snapshot.
|
|
1554
|
+
*
|
|
1555
|
+
* **Cohort-wide convergence.** FRET routes a child-link to ONE parent member, so a per-member count is a shard,
|
|
1556
|
+
* not the total. Every member gossips its own link/unlink deltas ({@link CohortGossipV1.childLinks} /
|
|
1557
|
+
* `childUnlinks`) and merges inbound ones straight into this registry (via the gossip bus `onChildDeltas`
|
|
1558
|
+
* callback), so after a round every parent member holds the same **converged union** and `count` is consistent
|
|
1559
|
+
* cohort-wide. Because merge is last-writer-wins by `effectiveAt`, a link and a later unlink converge in any
|
|
1560
|
+
* arrival order, and a never-seen unlink writes a `linked = false` tombstone that a subsequently-arriving stale
|
|
1561
|
+
* link cannot resurrect. The union is keyed by child coord, never the parent epoch, so a parent membership
|
|
1562
|
+
* rotation does not reset it. NOTE: a link/unlink is broadcast once (drained from the pending delta queue);
|
|
1563
|
+
* a parent member that joins the cohort *after* the delta drained (rotation cold-start) learns the child set
|
|
1564
|
+
* only on the next local record/unrecord for that child — see `debt-cohort-topic-child-set-late-joiner-resync`.
|
|
1565
|
+
*/
|
|
1566
|
+
interface ChildRegistry {
|
|
1567
|
+
/** Link the child; returns true iff this advanced the state (a fresh link or a strictly-newer effectiveAt). */
|
|
1568
|
+
recordChild(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): boolean;
|
|
1569
|
+
/** Release the child (linked = false); returns true iff this advanced the state. A never-seen child writes a tombstone. */
|
|
1570
|
+
unrecordChild(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): boolean;
|
|
1571
|
+
count(topicId: Uint8Array): number;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
interface ChildEntry {
|
|
1575
|
+
linked: boolean;
|
|
1576
|
+
lastEffectiveAt: number;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function createChildRegistry(): ChildRegistry {
|
|
1580
|
+
const byTopic = new Map<string, Map<string, ChildEntry>>();
|
|
1581
|
+
// Freshness-ordered write shared by link/unlink: apply `linked` only if `effectiveAt` is strictly newer than
|
|
1582
|
+
// the entry's high-water (or the entry is new), so a stale/out-of-order replay is dropped and an idempotent
|
|
1583
|
+
// re-apply is a no-op. Returns whether the state actually advanced (so the caller re-gossips only real changes).
|
|
1584
|
+
const apply = (topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number, linked: boolean): boolean => {
|
|
1585
|
+
const topicKey = bytesToB64url(topicId);
|
|
1586
|
+
let children = byTopic.get(topicKey);
|
|
1587
|
+
if (children === undefined) {
|
|
1588
|
+
children = new Map<string, ChildEntry>();
|
|
1589
|
+
byTopic.set(topicKey, children);
|
|
1590
|
+
}
|
|
1591
|
+
const childKey = bytesToB64url(childCohortCoord);
|
|
1592
|
+
const existing = children.get(childKey);
|
|
1593
|
+
if (existing === undefined) {
|
|
1594
|
+
// A never-seen unlink writes a `linked = false` tombstone (never a negative count), so a later stale
|
|
1595
|
+
// link with an earlier effectiveAt cannot resurrect a demoted child.
|
|
1596
|
+
children.set(childKey, { linked, lastEffectiveAt: effectiveAt });
|
|
1597
|
+
return true;
|
|
1598
|
+
}
|
|
1599
|
+
if (effectiveAt <= existing.lastEffectiveAt) {
|
|
1600
|
+
return false;
|
|
1601
|
+
}
|
|
1602
|
+
const changed = existing.linked !== linked;
|
|
1603
|
+
existing.linked = linked;
|
|
1604
|
+
existing.lastEffectiveAt = effectiveAt;
|
|
1605
|
+
return changed;
|
|
1606
|
+
};
|
|
1607
|
+
return {
|
|
1608
|
+
recordChild(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): boolean {
|
|
1609
|
+
return apply(topicId, childCohortCoord, effectiveAt, true);
|
|
1610
|
+
},
|
|
1611
|
+
unrecordChild(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): boolean {
|
|
1612
|
+
return apply(topicId, childCohortCoord, effectiveAt, false);
|
|
1613
|
+
},
|
|
1614
|
+
count(topicId: Uint8Array): number {
|
|
1615
|
+
const children = byTopic.get(bytesToB64url(topicId));
|
|
1616
|
+
if (children === undefined) {
|
|
1617
|
+
return 0;
|
|
1618
|
+
}
|
|
1619
|
+
let n = 0;
|
|
1620
|
+
for (const entry of children.values()) {
|
|
1621
|
+
if (entry.linked) {
|
|
1622
|
+
n++;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
return n;
|
|
1626
|
+
},
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1208
1630
|
/**
|
|
1209
1631
|
* Compose one {@link CoordEngine} bound to `servedCoord`. The cohort it threshold-signs / shards with
|
|
1210
1632
|
* is the FRET assembly around `servedCoord` (not the node's own ring position). The promotion tier
|
|
1211
1633
|
* inputs are coord-derived: `treeTier` is fixed at instantiation; `parentCoord` is
|
|
1212
1634
|
* `coord_{d-1}(participantCoord, topicId)` (the shard's parent shares the prefix, so any participant
|
|
1213
|
-
* routed here yields the same parent); `childCohortCount` is
|
|
1635
|
+
* routed here yields the same parent); `childCohortCount` is the converged union of recorded child cohorts
|
|
1636
|
+
* (per-cohort child registry, gossip-replicated), `0` until this cohort parents a child.
|
|
1214
1637
|
*/
|
|
1215
1638
|
function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, treeTier: number, participantCoord: Uint8Array): CoordEngine {
|
|
1216
1639
|
const store = createRegistrationStore();
|
|
1640
|
+
// The child cohorts this cohort parents (recorded by the parent-side child-link dispatch). Its `count`
|
|
1641
|
+
// feeds the demotion gate, the gossip summary, and the traffic snapshot below (was hardcoded 0).
|
|
1642
|
+
const childRegistry = createChildRegistry();
|
|
1217
1643
|
// Epoch-rotation bookkeeping. `cohort()` observes every assembly so the endorser history stays fresh
|
|
1218
1644
|
// (the gossip-cadence driver assembles each round); the producer reads `predecessor()` on publish.
|
|
1219
1645
|
const rotationState = new RotationState();
|
|
@@ -1251,6 +1677,19 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1251
1677
|
topicBudget.touch(topicId, store.directParticipants(topicId));
|
|
1252
1678
|
}
|
|
1253
1679
|
},
|
|
1680
|
+
// Cross-member child-set convergence: merge inbound child link/unlink deltas straight into this engine's
|
|
1681
|
+
// child registry (last-writer-wins by effectiveAt), so every parent member reads the same converged union
|
|
1682
|
+
// — not only the FRET-routed member that recorded the child. A direct write (no re-enqueue) so a received
|
|
1683
|
+
// delta is not re-gossiped; one broadcast already reaches the whole cohort. (`childRegistry` is declared
|
|
1684
|
+
// just above, so it is fully initialized by the time this callback fires on an inbound merge.)
|
|
1685
|
+
onChildDeltas: (childLinks: readonly ChildLinkRefV1[], childUnlinks: readonly ChildLinkRefV1[]): void => {
|
|
1686
|
+
for (const ref of childLinks) {
|
|
1687
|
+
childRegistry.recordChild(b64urlToBytes(ref.topicId), b64urlToBytes(ref.childCohortCoord), ref.effectiveAt);
|
|
1688
|
+
}
|
|
1689
|
+
for (const ref of childUnlinks) {
|
|
1690
|
+
childRegistry.unrecordChild(b64urlToBytes(ref.topicId), b64urlToBytes(ref.childCohortCoord), ref.effectiveAt);
|
|
1691
|
+
}
|
|
1692
|
+
},
|
|
1254
1693
|
});
|
|
1255
1694
|
const view = bus.view();
|
|
1256
1695
|
const selfMember = bytesToB64url(ctx.selfMemberBytes);
|
|
@@ -1281,6 +1720,10 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1281
1720
|
};
|
|
1282
1721
|
const noticeSigner = makeCoordSigner("promotion");
|
|
1283
1722
|
const membershipSigner = makeCoordSigner("membership");
|
|
1723
|
+
// The child cohort threshold-signs its own child-link exactly as it signs a promotion notice — over its
|
|
1724
|
+
// own served coord at its current epoch (a sibling of `noticeSigner`). Verify-only key-less (the child
|
|
1725
|
+
// then emits an UNSIGNED link and the parent permissive-accepts it, matching the register-sig fallback).
|
|
1726
|
+
const childLinkSigner = makeCoordSigner("childlink");
|
|
1284
1727
|
|
|
1285
1728
|
// Cohort-side membership-cert publisher: threshold-signs a MembershipCertV1 over this coord's cohort
|
|
1286
1729
|
// and serves it through the node's publish sink. Driven by the onStabilized / pumpMembership hooks.
|
|
@@ -1301,12 +1744,16 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1301
1744
|
const canPublish = ctx.privateKey !== undefined;
|
|
1302
1745
|
|
|
1303
1746
|
// --- epoch-rotation attestation production (cohort-topic-trust-anchor-rotation-production) ---
|
|
1304
|
-
//
|
|
1305
|
-
// (mirrors the publisher's own republish gate —
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1747
|
+
// Any change to the cohort identity (`epochKey = H(sorted members)`) is a rotation — head OR tail
|
|
1748
|
+
// (mirrors the publisher's own republish gate — both now key on the epoch, so the two agree on what is
|
|
1749
|
+
// a rotation).
|
|
1750
|
+
// NOTE: the `/sign` "rotation" endorsement gate remembers only the current + immediately-prior observed
|
|
1751
|
+
// epoch (RotationState.membersAt). That two-deep bound is orthogonal to attesting on any epoch change —
|
|
1752
|
+
// RotationState.observe already shifts on every observed epoch change (any member change rotates the
|
|
1753
|
+
// epoch), so rapid churn could age a predecessor epoch out of the window regardless. If rapid multi-step
|
|
1754
|
+
// churn ever makes rotation attestations frequently unproducible, that is a history-depth concern in
|
|
1755
|
+
// RotationState, not this trigger.
|
|
1756
|
+
const epochChanged = (a: CohortIdentity, b: CohortIdentity): boolean => a.epochKey !== b.epochKey;
|
|
1310
1757
|
|
|
1311
1758
|
/**
|
|
1312
1759
|
* Threshold-sign the new cert's canonical payload under the **predecessor** cohort identity, producing the
|
|
@@ -1341,10 +1788,10 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1341
1788
|
};
|
|
1342
1789
|
|
|
1343
1790
|
/**
|
|
1344
|
-
* Publish (or refresh) this cohort's membership cert, attaching a rotation attestation when the
|
|
1345
|
-
*
|
|
1791
|
+
* Publish (or refresh) this cohort's membership cert, attaching a rotation attestation when the cohort
|
|
1792
|
+
* identity (epoch) changed since the last publish. `refresh` selects the publisher path: `false` for a
|
|
1346
1793
|
* stabilization event ({@link CoordEngine.onStabilized}), `true` for the periodic refresh
|
|
1347
|
-
* ({@link CoordEngine.pumpMembership}).
|
|
1794
|
+
* ({@link CoordEngine.pumpMembership}). An epoch change is a stabilization regardless of which hook
|
|
1348
1795
|
* fired, so it routes through `onStabilized` (which republishes promptly on the change) carrying the
|
|
1349
1796
|
* attestation; the `/sign` round runs only on that change, so it costs one round per rotation, never per
|
|
1350
1797
|
* tick. Key-less interim mode no-ops (the verify-only signer cannot assemble).
|
|
@@ -1356,7 +1803,7 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1356
1803
|
const snapshot = snapshotAt(now); // also observes the current identity (snapshotAt → cohort())
|
|
1357
1804
|
const current = identityOf(snapshot);
|
|
1358
1805
|
const predecessor = rotationState.predecessor();
|
|
1359
|
-
const rotating = predecessor !== undefined &&
|
|
1806
|
+
const rotating = predecessor !== undefined && epochChanged(predecessor, current);
|
|
1360
1807
|
let published: MembershipCertV1 | undefined;
|
|
1361
1808
|
if (rotating) {
|
|
1362
1809
|
const rotation = await produceRotation(snapshot, predecessor!);
|
|
@@ -1378,16 +1825,23 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1378
1825
|
primaryTopicCount: (tier: Tier): number => countPrimaryTopics(store, ctx.selfMemberBytes, tier),
|
|
1379
1826
|
config: { cohortSize: ctx.wantK },
|
|
1380
1827
|
});
|
|
1381
|
-
const traffic = createTrafficCounters({ view, store, selfMember });
|
|
1828
|
+
const traffic = createTrafficCounters({ view, store, selfMember, childCohortCount: (topicId: Uint8Array): number => childRegistry.count(topicId) });
|
|
1382
1829
|
const promotion = createPromotionLifecycle({
|
|
1383
1830
|
store,
|
|
1384
1831
|
loadBucket: (topicId: Uint8Array): number => ctx.barometer.bucket(tierOfTopic(store, topicId)),
|
|
1385
|
-
//
|
|
1386
|
-
|
|
1832
|
+
// Real child count off this engine's local registry — a converged union across the parent cohort (the
|
|
1833
|
+
// FRET-routed member that records a child gossips a child-link delta; every member merges inbound ones),
|
|
1834
|
+
// so this count is consistent cohort-wide. Blocks demotion while any child is linked; once the last
|
|
1835
|
+
// child demotes (its notice unlinks it here and cohort-wide) the count falls to 0 and the parent can
|
|
1836
|
+
// demote in turn (`promotion.ts` demotionTriggered).
|
|
1837
|
+
childCohortCount: (topicId: Uint8Array): number => childRegistry.count(topicId),
|
|
1387
1838
|
treeTier: (): number => treeTier,
|
|
1388
1839
|
// `coord_{d-1}(P, topicId)`; never invoked at the root (demotion is gated on `treeTier > 0`), so
|
|
1389
1840
|
// the `d = 0` branch (clamped to `coord_0`) is a well-formed placeholder that the lifecycle skips.
|
|
1390
1841
|
parentCoord: (topicId: Uint8Array): Uint8Array => ctx.addressing.coord(Math.max(0, treeTier - 1), participantCoord, topicId),
|
|
1842
|
+
// The served coord this engine was instantiated at — stamped on every notice as `cohortCoord` and
|
|
1843
|
+
// covered by its threshold signature, so a receiver routes + verifies the notice by exactly this coord.
|
|
1844
|
+
cohortCoord: (): Uint8Array => servedCoord,
|
|
1391
1845
|
cohortEpoch: localEpoch,
|
|
1392
1846
|
signer: noticeSigner,
|
|
1393
1847
|
// Production defaults (cap_promote = 64, …) unless the host was given a promotion override — the
|
|
@@ -1408,7 +1862,20 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1408
1862
|
const coldStart = createColdStartManager({
|
|
1409
1863
|
parentRegistrar: {
|
|
1410
1864
|
registerWithParent: (topicId: Uint8Array, parentCoord: Uint8Array, tier: number, opTier?: number): Promise<void> =>
|
|
1411
|
-
registerForwarderWithParent(ctx, {
|
|
1865
|
+
registerForwarderWithParent(ctx, {
|
|
1866
|
+
topicId,
|
|
1867
|
+
parentCoord,
|
|
1868
|
+
treeTier: tier,
|
|
1869
|
+
opTier,
|
|
1870
|
+
participantCoord,
|
|
1871
|
+
// This engine's served coord IS `coord_d(participantCoord, topicId)` — the child cohort coord the
|
|
1872
|
+
// parent verifies + records against.
|
|
1873
|
+
childCohortCoord: servedCoord,
|
|
1874
|
+
cohortEpoch: localEpoch,
|
|
1875
|
+
// Live-key: the child threshold-signs the link over its own coord/epoch. Key-less interim: no
|
|
1876
|
+
// signer, so the link ships unsigned and the parent permissive-accepts it.
|
|
1877
|
+
signChildLink: canPublish ? childLinkSigner : undefined,
|
|
1878
|
+
}),
|
|
1412
1879
|
},
|
|
1413
1880
|
});
|
|
1414
1881
|
|
|
@@ -1420,7 +1887,19 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1420
1887
|
// so a probe flood cannot exhaust a participant's register budget at this coord, or vice-versa.
|
|
1421
1888
|
const probeRateLimiter = createRegisterRateLimiter(ctx.antiDos.rateLimiter);
|
|
1422
1889
|
const replayGuard = createCorrelationReplayGuard(ctx.antiDos.replayGuard);
|
|
1423
|
-
|
|
1890
|
+
// Reconcile the cold-start forwarder set + traffic window with the topic budget on eviction. When the
|
|
1891
|
+
// budget is full and evicts the coldest (zero-participant) resident to make room for a new topic, tear
|
|
1892
|
+
// down the evicted topic's forwarder and traffic counters too — otherwise `servesTopic` stays true off
|
|
1893
|
+
// the leftover forwarder, the topic is served forever with no budget slot, and the forwarder map grows
|
|
1894
|
+
// unbounded. `coldStart` (declared above at the createColdStartManager call) and `traffic` are both
|
|
1895
|
+
// initialized before this point, so the callback closes over live instances.
|
|
1896
|
+
const topicBudget = createTopicBudget({
|
|
1897
|
+
...ctx.antiDos.topicBudget,
|
|
1898
|
+
onEvict: (topicId: Uint8Array): void => {
|
|
1899
|
+
coldStart.remove(topicId);
|
|
1900
|
+
traffic.forget(topicId);
|
|
1901
|
+
},
|
|
1902
|
+
});
|
|
1424
1903
|
const renewal = createRenewalCohortSide({
|
|
1425
1904
|
store,
|
|
1426
1905
|
self: ctx.selfMemberBytes,
|
|
@@ -1434,6 +1913,10 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1434
1913
|
evicted: (rec): void => pending.evicted(rec),
|
|
1435
1914
|
},
|
|
1436
1915
|
verifyParticipantSig: ctx.verifyParticipantSig,
|
|
1916
|
+
// Freshness gate for the privileged withdraw/reattach paths. Share the register path's skew config
|
|
1917
|
+
// (`ctx.antiDos.replayGuard`, the same `{ maxAgeMs, maxFutureSkewMs }` `createCorrelationReplayGuard`
|
|
1918
|
+
// consumes above) so an operator tuning the window moves the register and renew paths together.
|
|
1919
|
+
freshness: ctx.antiDos.replayGuard,
|
|
1437
1920
|
});
|
|
1438
1921
|
|
|
1439
1922
|
const engine = createCohortMemberEngine({
|
|
@@ -1476,9 +1959,19 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1476
1959
|
return [...byKey.values()];
|
|
1477
1960
|
};
|
|
1478
1961
|
|
|
1962
|
+
// Timestamp of the last frame this engine actually emitted (any frame carries willingness). Drives the
|
|
1963
|
+
// idle-but-willing heartbeat throttle: an idle round heartbeats only if this engine has never emitted
|
|
1964
|
+
// (first idle round → immediate, so bootstrap converges fast) or `T_willingness_heartbeat` has elapsed.
|
|
1965
|
+
// A record-carrying round emits every round and updates this clock, so the throttle governs only
|
|
1966
|
+
// genuinely-idle engines. `undefined` until the first emit.
|
|
1967
|
+
// NOTE: re-broadcasts willingness for every idle-but-willing cohort every T_willingness_heartbeat; if a
|
|
1968
|
+
// node ever serves very many idle cohorts, batch the heartbeats or lengthen the interval.
|
|
1969
|
+
let lastGossipAt: number | undefined;
|
|
1970
|
+
|
|
1479
1971
|
// One gossip round: sweep stale records (firing the `evicted` deltas), freeze each resident topic's
|
|
1480
|
-
// traffic summary, drain the touch/evicted deltas, then assemble + sign + broadcast the frame.
|
|
1481
|
-
//
|
|
1972
|
+
// traffic summary, drain the touch/evicted deltas, then assemble + sign + broadcast the frame. An idle
|
|
1973
|
+
// engine (no topics, no deltas) normally builds no frame — except a willingness heartbeat, where an idle
|
|
1974
|
+
// but willing engine still emits a willingness/load-only frame so a cold cohort can bootstrap.
|
|
1482
1975
|
const gossipRound = async (now: number): Promise<CohortGossipV1 | undefined> => {
|
|
1483
1976
|
engine.sweepStale(now);
|
|
1484
1977
|
const topicSummaries = residentTopics().map((topicId) =>
|
|
@@ -1486,26 +1979,36 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1486
1979
|
tier: tierOfTopic(store, topicId),
|
|
1487
1980
|
directParticipants: store.directParticipants(topicId),
|
|
1488
1981
|
promoted: promotion.isPromoted(topicId),
|
|
1489
|
-
//
|
|
1490
|
-
|
|
1982
|
+
// Real child count off this engine's local registry — a converged union across the parent cohort
|
|
1983
|
+
// (every member gossips its child link/unlink deltas; inbound ones merge here), so this is
|
|
1984
|
+
// consistent cohort-wide, not a single-member shard.
|
|
1985
|
+
childCohortCount: childRegistry.count(topicId),
|
|
1491
1986
|
}),
|
|
1492
1987
|
);
|
|
1493
|
-
const { records, evicted } = pending.drain();
|
|
1988
|
+
const { records, evicted, childLinks, childUnlinks } = pending.drain();
|
|
1989
|
+
const idle = topicSummaries.length === 0 && records.length === 0 && evicted.length === 0
|
|
1990
|
+
&& childLinks.length === 0 && childUnlinks.length === 0;
|
|
1991
|
+
const heartbeat = idle && (lastGossipAt === undefined || now - lastGossipAt >= ctx.willingnessHeartbeatMs);
|
|
1494
1992
|
const g = buildCohortGossip({
|
|
1495
1993
|
fromMember: selfMember,
|
|
1496
1994
|
coord: bytesToB64url(servedCoord),
|
|
1497
1995
|
cohortEpoch: bytesToB64url(localEpoch()),
|
|
1996
|
+
treeTier,
|
|
1997
|
+
heartbeat,
|
|
1498
1998
|
profile: ctx.profile,
|
|
1499
1999
|
barometer: ctx.barometer,
|
|
1500
2000
|
windowSeconds: DEFAULT_TRAFFIC_WINDOW_SECONDS,
|
|
1501
2001
|
topicSummaries,
|
|
1502
2002
|
records,
|
|
1503
2003
|
evicted,
|
|
2004
|
+
childLinks,
|
|
2005
|
+
childUnlinks,
|
|
1504
2006
|
timestamp: now,
|
|
1505
2007
|
});
|
|
1506
2008
|
if (g === undefined) {
|
|
1507
2009
|
return undefined;
|
|
1508
2010
|
}
|
|
2011
|
+
lastGossipAt = now;
|
|
1509
2012
|
if (ctx.signGossip !== undefined) {
|
|
1510
2013
|
g.signature = await ctx.signGossip(g);
|
|
1511
2014
|
}
|
|
@@ -1541,16 +2044,42 @@ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, tree
|
|
|
1541
2044
|
cohort,
|
|
1542
2045
|
cohortIdentityAt: (epoch: Uint8Array): readonly string[] | undefined => rotationState.membersAt(bytesToB64url(epoch)),
|
|
1543
2046
|
hasState: (): boolean => store.listAll().length > 0,
|
|
2047
|
+
hasForwarders: (): boolean => coldStart.hasForwarders(),
|
|
1544
2048
|
holds: (topicId: Uint8Array, participantId: Uint8Array): boolean =>
|
|
1545
2049
|
store.getByParticipant(topicId, participantId) !== undefined,
|
|
1546
2050
|
records: (topicId: Uint8Array): readonly RegistrationRecord[] => store.listByTopic(topicId),
|
|
1547
2051
|
topicTraffic: (topicId: Uint8Array): TopicTrafficV1 => traffic.snapshot(topicId),
|
|
2052
|
+
// NOTE: the only production caller today is the matchmaking QueryV1 serve handler
|
|
2053
|
+
// (matchmaking-query-accounting-seam). When the AggregateCountV1 sweep RPC lands it is another
|
|
2054
|
+
// genuine cohort query and should bump the same seam — reuse `engine.recordQuery` at that call site.
|
|
2055
|
+
// NOTE: unlike `recordArrival` (whose topic always becomes resident via the store), a query can be
|
|
2056
|
+
// recorded against a NON-resident topic (the recordless `findByCoord` serve fallback). `gossipRound`
|
|
2057
|
+
// only `publish`es `residentTopics()`, and `traffic.forget` fires only on topic-budget eviction, so
|
|
2058
|
+
// such a topic's query window prunes only at epoch `reset`. Harmless now (bounded by epoch lifetime +
|
|
2059
|
+
// the serve gate; a few timestamps per query). If a node ever serves high-volume queries for topics it
|
|
2060
|
+
// holds no records for, prune non-resident windows on a timer or forget them when their engine idles.
|
|
2061
|
+
recordQuery: (topicId: Uint8Array, now: number): void => traffic.recordQuery(topicId, now),
|
|
1548
2062
|
cohortView: (): CohortView => view,
|
|
1549
2063
|
servesTopic: (topicId: Uint8Array): boolean =>
|
|
1550
2064
|
store.directParticipants(topicId) > 0 || coldStart.get(topicId) !== undefined,
|
|
1551
2065
|
budgetHasTopic: (topicId: Uint8Array): boolean => topicBudget.has(topicId),
|
|
1552
2066
|
budgetParticipantCount: (topicId: Uint8Array): number | undefined => topicBudget.participantCount(topicId),
|
|
1553
2067
|
forwarder: (topicId: Uint8Array): Forwarder | undefined => coldStart.get(topicId),
|
|
2068
|
+
// A local record/unrecord (parent-side child-link dispatch / demotion-notice unlink) also enqueues the
|
|
2069
|
+
// corresponding gossip delta on a real state change, so the child set converges across the parent cohort.
|
|
2070
|
+
// A gossip-merged delta writes the registry directly (via the bus `onChildDeltas` callback) and is NOT
|
|
2071
|
+
// re-enqueued here — one broadcast reaches the whole cohort.
|
|
2072
|
+
recordChild: (topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): void => {
|
|
2073
|
+
if (childRegistry.recordChild(topicId, childCohortCoord, effectiveAt)) {
|
|
2074
|
+
pending.childLink(topicId, childCohortCoord, effectiveAt);
|
|
2075
|
+
}
|
|
2076
|
+
},
|
|
2077
|
+
unrecordChild: (topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): void => {
|
|
2078
|
+
if (childRegistry.unrecordChild(topicId, childCohortCoord, effectiveAt)) {
|
|
2079
|
+
pending.childUnlink(topicId, childCohortCoord, effectiveAt);
|
|
2080
|
+
}
|
|
2081
|
+
},
|
|
2082
|
+
childCohortCount: (topicId: Uint8Array): number => childRegistry.count(topicId),
|
|
1554
2083
|
isPromoted: (topicId: Uint8Array): boolean => promotion.isPromoted(topicId),
|
|
1555
2084
|
applyPromotionNotice: (notice, now): void => promotion.applyPromotionNotice(notice, now),
|
|
1556
2085
|
applyDemotionNotice: (notice, now): void => promotion.applyDemotionNotice(notice, now),
|
|
@@ -1579,6 +2108,78 @@ export function resolveRenew(registry: CoordRegistry, renew: RenewV1, now: numbe
|
|
|
1579
2108
|
return holder.engine.handleRenew(renew, now);
|
|
1580
2109
|
}
|
|
1581
2110
|
|
|
2111
|
+
// --- parent-side child-link dispatch (record a verified child cohort) ---
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* Dependencies for {@link dispatchChildLink}. Small seam so the parent-side child-link handling is unit-
|
|
2115
|
+
* testable without a live node (mirrors {@link handleSignRequest} / {@link verifyAndApplyNotice}).
|
|
2116
|
+
*/
|
|
2117
|
+
export interface DispatchChildLinkDeps {
|
|
2118
|
+
/** Recompute a served coord for a tier — `addressing.coord(tier, participantCoord, topicId)`. */
|
|
2119
|
+
readonly coord: (tier: number, participantCoord: Uint8Array, topicId: Uint8Array) => RingCoord;
|
|
2120
|
+
/**
|
|
2121
|
+
* Resolve (creating if absent) the parent {@link CoordEngine} for its served coord — `registry.forCoord`.
|
|
2122
|
+
* Only its {@link CoordEngine.recordChild} is used. May throw {@link CoordEngineRegistryFullError} when
|
|
2123
|
+
* creating over a full-of-live registry; {@link dispatchChildLink} catches it and replies `rejected`.
|
|
2124
|
+
*/
|
|
2125
|
+
readonly resolveParent: (parentServedCoord: RingCoord, parentTier: number, childParticipantCoord: Uint8Array) => Pick<CoordEngine, "recordChild">;
|
|
2126
|
+
/**
|
|
2127
|
+
* Verify the child cohort threshold signature against the child cohort's cert (live-key mode). `undefined`
|
|
2128
|
+
* → key-less-permissive: the link is recorded without a signature check (matching the register-sig
|
|
2129
|
+
* fallback). A live-key host supplies this, and an unsigned link then fails it (never a silent record).
|
|
2130
|
+
*/
|
|
2131
|
+
readonly verifyChildLinkSig?: (link: ChildLinkV1, now: number) => Promise<boolean>;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/**
|
|
2135
|
+
* Parent-side handling of an inbound {@link ChildLinkV1}: bind the parent-child relationship, verify the
|
|
2136
|
+
* child cohort's threshold signature, record the child, and ack. Steps:
|
|
2137
|
+
*
|
|
2138
|
+
* 1. **Bind.** Recompute `coord_childTier(childParticipantCoord, topicId)` and reject unless it equals the
|
|
2139
|
+
* signed `childCohortCoord`; `coord_(childTier−1)(...)` is this parent's served coord. An attacker cannot
|
|
2140
|
+
* point the link at an unrelated parent without a `childParticipantCoord` that also hashes to the signed
|
|
2141
|
+
* child coord (which it cannot, absent the prefix-class membership).
|
|
2142
|
+
* 2. **Verify** (live-key) the child cohort threshold sig against the child cohort's cert; key-less-permissive
|
|
2143
|
+
* short-circuits it. A non-verified result → `rejected`.
|
|
2144
|
+
* 3. **Record** the child on the parent engine (freshness-ordered, idempotent).
|
|
2145
|
+
* 4. Reply `linked`.
|
|
2146
|
+
*/
|
|
2147
|
+
export async function dispatchChildLink(link: ChildLinkV1, deps: DispatchChildLinkDeps, now: number): Promise<ChildLinkReplyV1> {
|
|
2148
|
+
const topicId = b64urlToBytes(link.topicId);
|
|
2149
|
+
const childParticipantCoord = b64urlToBytes(link.childParticipantCoord);
|
|
2150
|
+
const childCohortCoord = b64urlToBytes(link.childCohortCoord);
|
|
2151
|
+
// Step 1 — bind the relationship. `validateChildLinkV1` already enforced `childTier >= 1`, so `childTier − 1`
|
|
2152
|
+
// is a well-formed parent tier.
|
|
2153
|
+
const recomputedChild = deps.coord(link.childTier, childParticipantCoord, topicId);
|
|
2154
|
+
if (!bytesEqual(recomputedChild, childCohortCoord)) {
|
|
2155
|
+
return { v: 1, result: "rejected", reason: "coord mismatch" };
|
|
2156
|
+
}
|
|
2157
|
+
const parentServedCoord = deps.coord(link.childTier - 1, childParticipantCoord, topicId);
|
|
2158
|
+
// Step 2 — verify the child cohort threshold signature (live-key). Key-less-permissive short-circuits.
|
|
2159
|
+
if (deps.verifyChildLinkSig !== undefined) {
|
|
2160
|
+
const verified = await deps.verifyChildLinkSig(link, now);
|
|
2161
|
+
if (!verified) {
|
|
2162
|
+
return { v: 1, result: "rejected", reason: "child cohort signature not verified" };
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
// Step 3 — record on the parent engine (resolving it by its served coord). Resolving may create the parent
|
|
2166
|
+
// engine, so it can hit the registry cap: a full-of-live registry refuses, which we turn into a clean
|
|
2167
|
+
// `rejected` (the child re-links on a later round / after the cohort drains an idle engine) rather than an
|
|
2168
|
+
// unhandled throw on the stream.
|
|
2169
|
+
let parent: Pick<CoordEngine, "recordChild">;
|
|
2170
|
+
try {
|
|
2171
|
+
parent = deps.resolveParent(parentServedCoord, link.childTier - 1, childParticipantCoord);
|
|
2172
|
+
} catch (err) {
|
|
2173
|
+
if (err instanceof CoordEngineRegistryFullError) {
|
|
2174
|
+
return { v: 1, result: "rejected", reason: "parent cohort capacity" };
|
|
2175
|
+
}
|
|
2176
|
+
throw err;
|
|
2177
|
+
}
|
|
2178
|
+
parent.recordChild(topicId, childCohortCoord, link.effectiveAt);
|
|
2179
|
+
// Step 4 — ack.
|
|
2180
|
+
return { v: 1, result: "linked" };
|
|
2181
|
+
}
|
|
2182
|
+
|
|
1582
2183
|
// --- intra-cohort sign endorsement ---
|
|
1583
2184
|
|
|
1584
2185
|
/** Dependencies for the `/sign` endorsement policy ({@link handleSignRequest}). */
|
|
@@ -1624,6 +2225,7 @@ const SIGNABLE_IMAGE_TAG: Record<Exclude<SignKind, "rotation">, string> = {
|
|
|
1624
2225
|
membership: "MembershipCertV1",
|
|
1625
2226
|
promotion: "PromotionNoticeV1",
|
|
1626
2227
|
demotion: "DemotionNoticeV1",
|
|
2228
|
+
childlink: "ChildLinkV1",
|
|
1627
2229
|
};
|
|
1628
2230
|
|
|
1629
2231
|
/**
|
|
@@ -1743,6 +2345,9 @@ export async function handleSignRequest(request: SignRequestV1, fromPeerStr: str
|
|
|
1743
2345
|
// All kinds — bind the payload-internal `cohortEpoch` to our own current epoch (closes the falsified-internal
|
|
1744
2346
|
// -epoch hole, for promotion / demotion too). It is `image[2]` for a MembershipCertV1 image and the last
|
|
1745
2347
|
// element for promotion / demotion (see `sig/payloads.ts`).
|
|
2348
|
+
// NOTE: this reads the notice epoch positionally as the LAST element — `sig/payloads.ts` deliberately keeps
|
|
2349
|
+
// `cohortEpoch` last (with the newer `cohortCoord` inserted just before it) to preserve this. Do not append
|
|
2350
|
+
// a field after `cohortEpoch` in those images without updating this read.
|
|
1746
2351
|
const currentEpochB64 = bytesToB64url(deps.currentEpoch(coord));
|
|
1747
2352
|
const embeddedEpoch = request.kind === "membership" ? image[2] : image[image.length - 1];
|
|
1748
2353
|
if (embeddedEpoch !== currentEpochB64) {
|
|
@@ -1792,10 +2397,13 @@ export type NoticeOutcome = "applied" | "untrusted" | "dropped";
|
|
|
1792
2397
|
*
|
|
1793
2398
|
* - `"undecodable"` — the frame is neither a promotion nor a demotion notice.
|
|
1794
2399
|
* - `"rate-limited"` — the dialing `(peer, topic)` is over its `register_rate_per_peer` ceiling.
|
|
1795
|
-
* - `"stale"` — the notice's `effectiveAt` is at or below the last *applied* notice for its
|
|
1796
|
-
*
|
|
2400
|
+
* - `"stale"` — the notice's `effectiveAt` is at or below the last *applied* notice for its served
|
|
2401
|
+
* cohort coord (a replay / out-of-order frame); dropped before `verifyMessage`.
|
|
2402
|
+
* - `"unlinked"` — a demotion notice that did not sibling-adopt on this node (no local child-coord engine,
|
|
2403
|
+
* or that path was stale) but **did** verify + unrecord the demoting child at its parent cohort here (the
|
|
2404
|
+
* parent-unlink path). Distinct from `"applied"` (a sibling-adopt) so a test can assert the parent-only case.
|
|
1797
2405
|
*/
|
|
1798
|
-
export type InboundNoticeResult = NoticeOutcome | "undecodable" | "rate-limited" | "stale";
|
|
2406
|
+
export type InboundNoticeResult = NoticeOutcome | "undecodable" | "rate-limited" | "stale" | "unlinked";
|
|
1799
2407
|
|
|
1800
2408
|
/**
|
|
1801
2409
|
* Node-level anti-abuse state for the `promote` handler (`cohort-topic-promote-handler-verify-amplification`).
|
|
@@ -1809,10 +2417,12 @@ export interface PromoteGate {
|
|
|
1809
2417
|
*/
|
|
1810
2418
|
readonly rateLimiter: RegisterRateLimiter;
|
|
1811
2419
|
/**
|
|
1812
|
-
* Per
|
|
1813
|
-
* water is a replay / out-of-order frame and is dropped before
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
2420
|
+
* Per-served-coord high-water (key: `` `${cohortCoord}|${tier}` ``) of the last *applied* notice's
|
|
2421
|
+
* `effectiveAt`. A notice at or below the water is a replay / out-of-order frame and is dropped before
|
|
2422
|
+
* verification. Keyed by the served coord — not `(topic, tier)` — so two sibling cohorts a node serves for
|
|
2423
|
+
* one `(topic, tier)` do not share an entry (an applied notice for one must not stale-drop a legitimate
|
|
2424
|
+
* notice for the other). Updated **only** on an `"applied"` outcome (never on an unverified frame), so a
|
|
2425
|
+
* forged notice carrying `effectiveAt = Infinity` cannot poison the water and lock out legitimate notices.
|
|
1816
2426
|
*
|
|
1817
2427
|
* **Bounded.** An {@link LruMap} capped at {@link PROMOTE_HIGHWATER_MAX_KEYS} so the retain-forever shape
|
|
1818
2428
|
* cannot leak on a long-lived node. Unlike the limiter this is *not* attacker-growable (it is written only
|
|
@@ -1827,7 +2437,7 @@ export interface PromoteGate {
|
|
|
1827
2437
|
}
|
|
1828
2438
|
|
|
1829
2439
|
/**
|
|
1830
|
-
* Hard cap on tracked
|
|
2440
|
+
* Hard cap on tracked per-served-coord high-water entries; the least-recently-touched are evicted beyond
|
|
1831
2441
|
* this. A modest bound is plenty — only verified applies grow the map, so it never evicts under legitimate
|
|
1832
2442
|
* load — but it caps the otherwise retain-forever shape on a long-lived node.
|
|
1833
2443
|
*/
|
|
@@ -1840,13 +2450,15 @@ export function createPromoteGate(rateLimiterConfig?: RegisterRateLimiterConfig)
|
|
|
1840
2450
|
|
|
1841
2451
|
/**
|
|
1842
2452
|
* The slice of a {@link CoordEngine} the inbound notice path needs: the cohort coord the signers should
|
|
1843
|
-
* belong to (for verification)
|
|
1844
|
-
*
|
|
2453
|
+
* belong to (for verification), the sibling-adopt apply hooks, and — for the demotion parent-unlink path
|
|
2454
|
+
* ({@link applyDemotionUnlinkAtParent}) — {@link CoordEngine.unrecordChild}. {@link CoordEngine} satisfies
|
|
2455
|
+
* this; tests can pass a minimal stand-in.
|
|
1845
2456
|
*/
|
|
1846
2457
|
export interface NoticeApplyTarget {
|
|
1847
2458
|
readonly servedCoord: RingCoord;
|
|
1848
2459
|
applyPromotionNotice(notice: PromotionNoticeV1, now: number): void;
|
|
1849
2460
|
applyDemotionNotice(notice: DemotionNoticeV1, now: number): void;
|
|
2461
|
+
unrecordChild(topicId: Uint8Array, childCohortCoord: Uint8Array, effectiveAt: number): void;
|
|
1850
2462
|
}
|
|
1851
2463
|
|
|
1852
2464
|
/**
|
|
@@ -1880,8 +2492,8 @@ export const PROMOTE_REFETCH_MIN_INTERVAL_MS = 60_000;
|
|
|
1880
2492
|
* Verify an inbound notice's threshold signature against the cohort `MembershipCertV1` for
|
|
1881
2493
|
* `target.servedCoord` and, on success, apply it to the target's promotion lifecycle. Returns:
|
|
1882
2494
|
*
|
|
1883
|
-
* - `"dropped"` — no local engine serves the notice's
|
|
1884
|
-
* parent that does not
|
|
2495
|
+
* - `"dropped"` — no local engine serves the notice's carried `cohortCoord` (e.g. a demotion arriving at a
|
|
2496
|
+
* parent-only node that does not serve the demoting child's coord); nothing to apply to.
|
|
1885
2497
|
* - `"untrusted"` — the `signers` are not a `≥ minSigs` subset of the cohort cert, or the multisig does
|
|
1886
2498
|
* not verify (a forged single-signer / short-quorum notice); local state is left unchanged.
|
|
1887
2499
|
* - `"applied"` — verified and applied.
|
|
@@ -1934,6 +2546,60 @@ export async function verifyAndApplyNotice(
|
|
|
1934
2546
|
return "applied";
|
|
1935
2547
|
}
|
|
1936
2548
|
|
|
2549
|
+
/** Outcome of {@link applyDemotionUnlinkAtParent}. */
|
|
2550
|
+
export type ParentUnlinkOutcome = "unlinked" | "no-parent" | "untrusted";
|
|
2551
|
+
|
|
2552
|
+
/**
|
|
2553
|
+
* Parent-side apply of a demotion notice: release (unrecord) the demoting child at its parent cohort. A
|
|
2554
|
+
* demoting child threshold-signs its {@link DemotionNoticeV1} and fans it to **both** its own served coord
|
|
2555
|
+
* (siblings adopt `promoted = false` via {@link verifyAndApplyNotice}) and its `parentCohortCoord`. This is the
|
|
2556
|
+
* second, independent apply semantics at the parent: resolve the parent engine at `parentCohortCoord`, verify
|
|
2557
|
+
* the notice's threshold signature against the **child** cohort cert (identical verify to the sibling-adopt —
|
|
2558
|
+
* signers ⊆ the child cohort cert at the notice's `tier`, keyed by the child's `cohortCoord`), and on
|
|
2559
|
+
* `verified` call `parent.unrecordChild(topicId, cohortCoord, effectiveAt)`.
|
|
2560
|
+
*
|
|
2561
|
+
* Returns:
|
|
2562
|
+
* - `"no-parent"` — this node does not serve `parentCohortCoord`; nothing to unrecord (a pure sibling-adopt node).
|
|
2563
|
+
* - `"untrusted"` — the signature does not verify against the child cohort cert (a forged / under-quorum notice).
|
|
2564
|
+
* - `"unlinked"` — verified; the child was unrecorded (or was already released — the child registry's own
|
|
2565
|
+
* per-`(topic, childCoord)` freshness makes a replay an idempotent no-op).
|
|
2566
|
+
*
|
|
2567
|
+
* **Freshness is the child registry's, not the promote-gate high-water.** The sibling-adopt high-water is keyed
|
|
2568
|
+
* by the child coord and advanced only on a sibling-adopt `"applied"`; the unlink is ordered independently by
|
|
2569
|
+
* the child registry's per-child `lastEffectiveAt`, so a demotion that is a stale no-op for the sibling-adopt
|
|
2570
|
+
* target still applies the unlink at the parent, and vice-versa. The verify carries the same
|
|
2571
|
+
* {@link PROMOTE_REFETCH_MIN_INTERVAL_MS} bound as the sibling-adopt, so it cannot amplify into dials.
|
|
2572
|
+
*/
|
|
2573
|
+
export async function applyDemotionUnlinkAtParent(
|
|
2574
|
+
notice: DemotionNoticeV1,
|
|
2575
|
+
registry: CoordRegistry,
|
|
2576
|
+
verifier: MembershipVerifier,
|
|
2577
|
+
now: number,
|
|
2578
|
+
): Promise<ParentUnlinkOutcome> {
|
|
2579
|
+
const parent = registry.findByCoord(b64urlToBytes(notice.parentCohortCoord));
|
|
2580
|
+
if (parent === undefined) {
|
|
2581
|
+
return "no-parent";
|
|
2582
|
+
}
|
|
2583
|
+
let signers: Uint8Array[];
|
|
2584
|
+
let sig: Uint8Array;
|
|
2585
|
+
try {
|
|
2586
|
+
signers = notice.signers.map(b64urlToBytes);
|
|
2587
|
+
sig = b64urlToBytes(notice.thresholdSig);
|
|
2588
|
+
} catch {
|
|
2589
|
+
return "untrusted"; // a signer / sig that is not valid base64url cannot verify
|
|
2590
|
+
}
|
|
2591
|
+
// Verify against the CHILD cohort cert (the demoting cohort's served coord `notice.cohortCoord`), NOT the
|
|
2592
|
+
// parent's — the demotion is threshold-signed by the child cohort. Same bounded refetch as the sibling-adopt.
|
|
2593
|
+
const payload = demotionNoticeSigningPayload(notice);
|
|
2594
|
+
const result = await verifier.verifyMessage(signers, b64urlToBytes(notice.cohortCoord), notice.tier, payload, sig, { minRefetchIntervalMs: PROMOTE_REFETCH_MIN_INTERVAL_MS, now });
|
|
2595
|
+
if (result !== "verified") {
|
|
2596
|
+
return "untrusted";
|
|
2597
|
+
}
|
|
2598
|
+
// `cohortCoord` is the child's served coord — the key the parent recorded the child under.
|
|
2599
|
+
parent.unrecordChild(b64urlToBytes(notice.topicId), b64urlToBytes(notice.cohortCoord), notice.effectiveAt);
|
|
2600
|
+
return "unlinked";
|
|
2601
|
+
}
|
|
2602
|
+
|
|
1937
2603
|
/**
|
|
1938
2604
|
* Full inbound `promote`-frame pipeline with the anti-abuse gate, exported so it is unit-testable without a
|
|
1939
2605
|
* live node (`cohort-topic-promote-handler-verify-amplification`). Runs the cheapest checks first — each
|
|
@@ -1941,14 +2607,27 @@ export async function verifyAndApplyNotice(
|
|
|
1941
2607
|
* work:
|
|
1942
2608
|
*
|
|
1943
2609
|
* ```
|
|
1944
|
-
* decode → per-(peer,topic) rate limit →
|
|
2610
|
+
* decode → per-(peer,topic) rate limit → resolve engine by carried cohortCoord → effectiveAt high-water → verify+apply
|
|
2611
|
+
* ↘ (demotion) parent-unlink at parentCohortCoord
|
|
1945
2612
|
* ```
|
|
1946
2613
|
*
|
|
2614
|
+
* A **demotion** carries a second, independent apply semantics: beyond the sibling-adopt above, it also
|
|
2615
|
+
* releases the demoting child at its parent cohort ({@link applyDemotionUnlinkAtParent}). Both paths may fire
|
|
2616
|
+
* on one node (one that serves both the child coord and the parent coord). The parent-unlink runs OUTSIDE the
|
|
2617
|
+
* sibling-adopt high-water (which is keyed by the child coord and would otherwise stale-drop the parent-coord
|
|
2618
|
+
* frame after the child-coord frame advanced it); its freshness is the child registry's own per-child key.
|
|
2619
|
+
* `"unlinked"` is returned when the unlink fired but the sibling-adopt did not (a parent-only node).
|
|
2620
|
+
*
|
|
1947
2621
|
* - **Rate limit** (`gate.rateLimiter`) keys on `(from, topicId)`; an over-rate peer is dropped before the
|
|
1948
|
-
*
|
|
1949
|
-
* - **
|
|
1950
|
-
*
|
|
1951
|
-
*
|
|
2622
|
+
* coord lookup and the verify, so a peer cannot amplify junk into verify/network work.
|
|
2623
|
+
* - **Resolve engine by `cohortCoord`** ({@link CoordRegistry.findByCoord}) — the exact served coord the
|
|
2624
|
+
* notice was decided for, covered by its signature. A node serving several sibling cohorts for one
|
|
2625
|
+
* `(topic, tier)` applies the notice to the cohort that produced it, never a first-match `(topic, tier)`
|
|
2626
|
+
* scan; a coord this node does not serve is dropped.
|
|
2627
|
+
* - **High-water** (`gate.highWater`, keyed per served `cohortCoord`) drops a notice whose `effectiveAt` is
|
|
2628
|
+
* at or below the last *applied* one — a replay / out-of-order frame — before `verifyMessage`. It is
|
|
2629
|
+
* advanced **only** on an `"applied"` outcome, so a forged frame (which never verifies) cannot poison it.
|
|
2630
|
+
* Keying by coord (not `(topic, tier)`) keeps two sibling cohorts on one node from sharing a water.
|
|
1952
2631
|
* - The receiver-side `cohortEpoch` is intentionally **not** gated on: the epoch rotates on every
|
|
1953
2632
|
* membership change, so a legitimately in-flight notice can briefly carry the prior epoch right after a
|
|
1954
2633
|
* rotation — making an epoch check a brittle, false-positive-prone filter. The rate limiter + high-water
|
|
@@ -1975,34 +2654,68 @@ export async function handleInboundNotice(
|
|
|
1975
2654
|
const tier = inbound.kind === "promotion" ? inbound.notice.fromTier : inbound.notice.tier;
|
|
1976
2655
|
const topicId = b64urlToBytes(inbound.notice.topicId);
|
|
1977
2656
|
|
|
1978
|
-
// Per-(peer, topic) rate limit — before the
|
|
2657
|
+
// Per-(peer, topic) rate limit — before the coord lookup and the verify.
|
|
1979
2658
|
if (gate.rateLimiter.check(from, topicId, now).ok === false) {
|
|
1980
2659
|
log("promote: rate-limited %s notice for topic %s tier %d", inbound.kind, inbound.notice.topicId, tier);
|
|
1981
2660
|
return "rate-limited";
|
|
1982
2661
|
}
|
|
1983
2662
|
|
|
1984
|
-
|
|
2663
|
+
// --- Sibling-adopt path: apply to the cohort that produced the notice (its signed `cohortCoord`). ---
|
|
2664
|
+
// Route by the notice's signed `cohortCoord` — the exact served coord the deciding cohort sits at. A node
|
|
2665
|
+
// serving several sibling cohorts for one `(topic, tier)` (possible at `d ≥ 1`) thus applies the notice to
|
|
2666
|
+
// the cohort that produced it, and `verifyAndApplyNotice` verifies against that same coord's cert. The coord
|
|
2667
|
+
// is covered by the threshold signature, so it cannot be rewritten to hijack a sibling. A coord this node
|
|
2668
|
+
// does not serve → `dropped` here (e.g. a demotion fanned to a parent-only node — the parent-unlink path
|
|
2669
|
+
// below still runs).
|
|
2670
|
+
const target = registry.findByCoord(b64urlToBytes(inbound.notice.cohortCoord));
|
|
2671
|
+
let siblingOutcome: InboundNoticeResult;
|
|
1985
2672
|
if (target === undefined) {
|
|
1986
|
-
|
|
1987
|
-
|
|
2673
|
+
siblingOutcome = "dropped";
|
|
2674
|
+
} else {
|
|
2675
|
+
// Freshness / replay gate: drop an at-or-below-high-water notice before the expensive verify. Keyed by the
|
|
2676
|
+
// served coord (which uniquely identifies the cohort) so two sibling cohorts on one node do not share a
|
|
2677
|
+
// high-water — an applied notice for cohort A must not stale-drop a legitimate cohort-B notice. `tier` is
|
|
2678
|
+
// kept in the key only for readability.
|
|
2679
|
+
const waterKey = `${inbound.notice.cohortCoord}|${tier}`;
|
|
2680
|
+
const water = gate.highWater.get(waterKey);
|
|
2681
|
+
if (water !== undefined && inbound.notice.effectiveAt <= water) {
|
|
2682
|
+
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);
|
|
2683
|
+
siblingOutcome = "stale";
|
|
2684
|
+
} else {
|
|
2685
|
+
siblingOutcome = await verifyAndApplyNotice(inbound, target, verifier, now);
|
|
2686
|
+
if (siblingOutcome === "applied") {
|
|
2687
|
+
// Advance the high-water only on a *verified-and-applied* notice, so a forged frame cannot poison it.
|
|
2688
|
+
gate.highWater.set(waterKey, inbound.notice.effectiveAt);
|
|
2689
|
+
} else {
|
|
2690
|
+
log("promote: %s %s notice for topic %s tier %d", siblingOutcome, inbound.kind, inbound.notice.topicId, tier);
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
1988
2693
|
}
|
|
1989
2694
|
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
const water = gate.highWater.get(waterKey);
|
|
1993
|
-
if (water !== undefined && inbound.notice.effectiveAt <= water) {
|
|
1994
|
-
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);
|
|
1995
|
-
return "stale";
|
|
2695
|
+
if (inbound.kind !== "demotion") {
|
|
2696
|
+
return siblingOutcome;
|
|
1996
2697
|
}
|
|
1997
2698
|
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2699
|
+
// --- Parent-unlink path (demotion only): additionally release the demoting child at its parent cohort. ---
|
|
2700
|
+
// A demotion is fanned to BOTH the child coord (sibling-adopt, above) and the parent coord (this unlink),
|
|
2701
|
+
// arriving as two independent frames. This path is deliberately OUTSIDE the sibling-adopt high-water: that
|
|
2702
|
+
// water is keyed by the child coord and advanced only on a sibling-adopt apply, so — on a node serving both
|
|
2703
|
+
// coords — the child-coord frame would advance the water and stale-drop the parent-coord frame before it
|
|
2704
|
+
// could unrecord. The child registry's own per-`(topic, childCoord)` freshness orders the unlink instead, so
|
|
2705
|
+
// a replay is an idempotent no-op. The verify is against the child cohort cert (same as the sibling-adopt),
|
|
2706
|
+
// so a forged demotion cannot unrecord.
|
|
2707
|
+
const unlink = await applyDemotionUnlinkAtParent(inbound.notice, registry, verifier, now);
|
|
2708
|
+
if (siblingOutcome === "applied") {
|
|
2709
|
+
return "applied"; // a node serving both coords: the sibling-adopt is the primary reported outcome
|
|
2004
2710
|
}
|
|
2005
|
-
|
|
2711
|
+
if (unlink === "unlinked") {
|
|
2712
|
+
return "unlinked";
|
|
2713
|
+
}
|
|
2714
|
+
if (unlink === "untrusted") {
|
|
2715
|
+
log("promote: untrusted demotion notice at parent for topic %s tier %d", inbound.notice.topicId, tier);
|
|
2716
|
+
return "untrusted";
|
|
2717
|
+
}
|
|
2718
|
+
return siblingOutcome;
|
|
2006
2719
|
}
|
|
2007
2720
|
|
|
2008
2721
|
/**
|
|
@@ -2036,7 +2749,7 @@ function crossCheckCohort(fret: FretService, wantK: number, servedCoord: RingCoo
|
|
|
2036
2749
|
}
|
|
2037
2750
|
}
|
|
2038
2751
|
|
|
2039
|
-
/** Positional equality over two string lists (the
|
|
2752
|
+
/** Positional equality over two string lists (the endorser's sorted-member-list image check). */
|
|
2040
2753
|
function sameStringOrder(a: readonly string[], b: readonly string[]): boolean {
|
|
2041
2754
|
if (a.length !== b.length) {
|
|
2042
2755
|
return false;
|
|
@@ -2080,23 +2793,33 @@ async function registerProtocolHandlers(
|
|
|
2080
2793
|
protocols: CohortTopicProtocols,
|
|
2081
2794
|
registry: CoordRegistry,
|
|
2082
2795
|
dispatchRegister: (reg: RegisterV1, fretCohort: readonly string[] | undefined, now: number) => Promise<RegisterReplyV1>,
|
|
2796
|
+
/** Parent-side child-link dispatch deps + the quorum bound used to structurally validate an inbound link. */
|
|
2797
|
+
childLinkDeps: DispatchChildLinkDeps,
|
|
2798
|
+
minSigs: number,
|
|
2083
2799
|
signEndorse: (request: SignRequestV1, fromPeerStr: string) => Promise<SignReplyV1>,
|
|
2084
2800
|
verifier: MembershipVerifier,
|
|
2085
2801
|
promoteGate: PromoteGate,
|
|
2086
2802
|
gossipTransport: FretCohortGossipTransport,
|
|
2803
|
+
/** Instantiate a cold sibling's coord engine off a verified co-member frame (§Cold-start instantiation). */
|
|
2804
|
+
maybeInstantiateColdSibling: (frame: Uint8Array) => void,
|
|
2087
2805
|
publishSink: FretMembershipPublishSink,
|
|
2088
2806
|
membershipSource: FretMembershipSource,
|
|
2089
2807
|
selfCoord: RingCoord,
|
|
2090
2808
|
maxBytes: number,
|
|
2091
2809
|
): Promise<void> {
|
|
2092
2810
|
await Promise.all([
|
|
2093
|
-
// register: a direct dial carries
|
|
2811
|
+
// register: a direct dial carries a RenewV1 (ping), a ChildLinkV1 (a child cohort registering with this
|
|
2812
|
+
// parent), or a RegisterV1 (re-attach walk fallback). The three shapes are disjoint, so try each in turn.
|
|
2094
2813
|
node.handle(protocols.register, makeFrameHandler(async (frame): Promise<Uint8Array | undefined> => {
|
|
2095
2814
|
const decoded = decodeCohortMessage(frame, maxBytes);
|
|
2096
2815
|
const renew = tryValidate(() => validateRenewV1(decoded));
|
|
2097
2816
|
if (renew !== undefined) {
|
|
2098
2817
|
return encodeCohortMessage(resolveRenew(registry, renew, Date.now()), maxBytes);
|
|
2099
2818
|
}
|
|
2819
|
+
const link = tryValidate(() => validateChildLinkV1(decoded, minSigs));
|
|
2820
|
+
if (link !== undefined) {
|
|
2821
|
+
return encodeCohortMessage(await dispatchChildLink(link, childLinkDeps, Date.now()), maxBytes);
|
|
2822
|
+
}
|
|
2100
2823
|
const reg = validateRegisterV1(decoded);
|
|
2101
2824
|
// Direct dial (not FRET-routed): no cohort member list to cross-check against.
|
|
2102
2825
|
const reply = await dispatchRegister(reg, undefined, Date.now());
|
|
@@ -2105,7 +2828,10 @@ async function registerProtocolHandlers(
|
|
|
2105
2828
|
|
|
2106
2829
|
// cohort-gossip: feed inbound gossip into the shared transport (one-way). It fans the frame to
|
|
2107
2830
|
// every coord engine's bus; per-bus epoch matching governs which engine merges the record deltas.
|
|
2831
|
+
// First, if this is a verified co-member frame for a coord we hold no engine for, instantiate that
|
|
2832
|
+
// engine (§Cold-start instantiation) so its freshly-subscribed bus merges this very frame on `deliver`.
|
|
2108
2833
|
node.handle(protocols.gossip, makeFrameHandler(async (frame, from): Promise<Uint8Array | undefined> => {
|
|
2834
|
+
maybeInstantiateColdSibling(frame);
|
|
2109
2835
|
gossipTransport.deliver(from.toString(), frame);
|
|
2110
2836
|
return undefined;
|
|
2111
2837
|
}, maxBytes)),
|