@optimystic/db-p2p 0.24.0 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/src/cluster/service.d.ts +8 -0
  2. package/dist/src/cluster/service.d.ts.map +1 -1
  3. package/dist/src/cluster/service.js +16 -4
  4. package/dist/src/cluster/service.js.map +1 -1
  5. package/dist/src/cohort-topic/host.js +34 -11
  6. package/dist/src/cohort-topic/host.js.map +1 -1
  7. package/dist/src/cohort-topic/stream-util.d.ts +25 -11
  8. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  9. package/dist/src/cohort-topic/stream-util.js +31 -19
  10. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  11. package/dist/src/libp2p-key-network.d.ts +68 -0
  12. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  13. package/dist/src/libp2p-key-network.js +123 -14
  14. package/dist/src/libp2p-key-network.js.map +1 -1
  15. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  16. package/dist/src/libp2p-node-base.js +8 -5
  17. package/dist/src/libp2p-node-base.js.map +1 -1
  18. package/dist/src/logger.d.ts +2 -2
  19. package/dist/src/logger.js +2 -2
  20. package/dist/src/matchmaking/query-transport.js +3 -3
  21. package/dist/src/matchmaking/query-transport.js.map +1 -1
  22. package/dist/src/peer-address-book.d.ts +69 -0
  23. package/dist/src/peer-address-book.d.ts.map +1 -1
  24. package/dist/src/peer-address-book.js +110 -15
  25. package/dist/src/peer-address-book.js.map +1 -1
  26. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  27. package/dist/src/reactivity/notify-transport.js +6 -6
  28. package/dist/src/reactivity/notify-transport.js.map +1 -1
  29. package/dist/src/reactivity/push-state-gossip.js +2 -2
  30. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  31. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  32. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  33. package/dist/src/reactivity/recover-transport.js +7 -3
  34. package/dist/src/reactivity/recover-transport.js.map +1 -1
  35. package/dist/src/repo/service.d.ts +6 -0
  36. package/dist/src/repo/service.d.ts.map +1 -1
  37. package/dist/src/repo/service.js +12 -2
  38. package/dist/src/repo/service.js.map +1 -1
  39. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  40. package/dist/src/routing/libp2p-known-peers.js +5 -0
  41. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  42. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  43. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  44. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  45. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/cluster/service.ts +305 -293
  48. package/src/cohort-topic/host.ts +2932 -2901
  49. package/src/cohort-topic/stream-util.ts +147 -135
  50. package/src/libp2p-key-network.ts +1235 -1120
  51. package/src/libp2p-node-base.ts +1678 -1675
  52. package/src/logger.ts +27 -27
  53. package/src/matchmaking/query-transport.ts +492 -492
  54. package/src/peer-address-book.ts +266 -149
  55. package/src/reactivity/notify-transport.ts +144 -144
  56. package/src/reactivity/push-state-gossip.ts +291 -291
  57. package/src/reactivity/recover-transport.ts +412 -408
  58. package/src/repo/service.ts +323 -313
  59. package/src/routing/libp2p-known-peers.ts +31 -26
  60. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
@@ -1,1675 +1,1678 @@
1
- import { createLibp2p, type Libp2p } from 'libp2p';
2
- import { noise } from '@chainsafe/libp2p-noise';
3
- import { yamux } from '@chainsafe/libp2p-yamux';
4
- import { identify, identifyPush } from '@libp2p/identify';
5
- import { ping } from '@libp2p/ping';
6
- import { dcutr } from '@libp2p/dcutr';
7
- import { autoNAT } from '@libp2p/autonat';
8
- import { gossipsub } from '@chainsafe/libp2p-gossipsub';
9
- import { bootstrap } from '@libp2p/bootstrap';
10
- import { circuitRelayServer, type CircuitRelayServerInit } from '@libp2p/circuit-relay-v2';
11
- import { peerIdFromString } from '@libp2p/peer-id';
12
- import { generateKeyPair } from '@libp2p/crypto/keys';
13
- import type { ConnectionGater, PrivateKey } from '@libp2p/interface';
14
- import { clusterService } from './cluster/service.js';
15
- import { blockTransferService } from './cluster/block-transfer-service.js';
16
- import { repoService } from './repo/service.js';
17
- import { StorageRepo, withBlockCommitLatch } from './storage/storage-repo.js';
18
- import { BlockStorage } from './storage/block-storage.js';
19
- import { MemoryRawStorage } from './storage/memory-storage.js';
20
- import type { IRawStorage } from './storage/i-raw-storage.js';
21
- import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
22
- import { clusterMember, type ReconcileBlockCallback, type CommitCertificateSink, type DeriveExpectedClusterCallback } from './cluster/cluster-repo.js';
23
- import { createReconcileBlock } from './cluster/reconcile-block.js';
24
- import { resolveClusterPolicy, type ClusterPolicyOptions } from './cluster/cluster-policy.js';
25
- import { assertClusterSizeCoupling } from './cluster/cluster-size-coupling.js';
26
- import { createCommitCertStore, makeClusterCommitCertExtractor, type CommitCertStore } from './cluster/commit-cert.js';
27
- import { coordinatorRepo } from './repo/coordinator-repo.js';
28
- import { Libp2pKeyPeerNetwork, type NetworkMode, type NetworkStatePersistence } from './libp2p-key-network.js';
29
- import { mergePeerAddresses, type AddressLog } from './peer-address-book.js';
30
- import type { OptimysticNode, OptimysticNodeAttachments } from './optimystic-node.js';
31
- import { ClusterClient } from './cluster/client.js';
32
- import type { IRepo, ICluster, ITransactionValidator, BlockId, IBlockChangeNotifier } from '@optimystic/db-core';
33
- import type { ITransactionStateStore } from './cluster/i-transaction-state-store.js';
34
- import { networkManagerService, type NetworkManagerService } from './network/network-manager-service.js';
35
- import type { SpreadOnChurnConfig, SpreadOnChurnMonitor } from './cluster/spread-on-churn.js';
36
- import { BlockTransferCoordinator } from './cluster/block-transfer.js';
37
- import type { RebalanceMonitorConfig } from './cluster/rebalance-monitor.js';
38
- import { fretService, Libp2pFretService } from 'p2p-fret';
39
- import { syncService } from './sync/service.js';
40
- import { SyncClient } from './sync/client.js';
41
- import type { SyncResponse } from './sync/protocol.js';
42
- import type { ClusterLatestCallback } from './repo/coordinator-repo.js';
43
- import { RestorationCoordinator } from './storage/restoration-coordinator.js';
44
- import { RingSelector } from './storage/ring-selector.js';
45
- import { RingShiftCoordinator } from './storage/ring-shift-coordinator.js';
46
- import { StorageMonitor } from './storage/storage-monitor.js';
47
- import type { StorageMonitorConfig } from './storage/storage-monitor.js';
48
- import { ArachnodeFretAdapter } from './storage/arachnode-fret-adapter.js';
49
- import type { RestoreCallback, BlockArchive } from './storage/struct.js';
50
- import type { FretService } from 'p2p-fret';
51
- import { createCohortTopicHost, type CohortTopicHostOptions } from './cohort-topic/host.js';
52
- import { attachCohortChangeBridge } from './cohort-topic/change-bridge.js';
53
- import { createReactivitySelfMembershipGate, reactivityTailBytes } from './cohort-topic/reactivity-membership-gate.js';
54
- import { Libp2pReactivityNotifyTransport, registerNotifyHandler } from './reactivity/notify-transport.js';
55
- import {
56
- Libp2pReactivityRecoverTransport,
57
- createLibp2pRecoverDialer,
58
- registerRecoverHandler,
59
- createRecoverRequestSigners,
60
- } from './reactivity/recover-transport.js';
61
- import { ReactivityForwarderHost, reactivityDirectSubscribers, reactivityNotificationTopicId } from './reactivity/forwarder-host.js';
62
- import { ReactivityOriginationManager } from './reactivity/origination-manager.js';
63
- import { ReactivityPushStateGossipDriver, registerPushStateGossipHandler, type ReactivityGossipCollection } from './reactivity/push-state-gossip.js';
64
- import { RotationReRegistrationScheduler } from './reactivity/rotation-rereg-scheduler.js';
65
- import { ReactivitySubscriberRegistry } from './reactivity/subscriber-registry.js';
66
- import { DEFAULT_REACTIVITY_PROTOCOLS, reactivityProtocolList } from './reactivity/protocols.js';
67
- import { registerMatchmakingQueryHandler } from './matchmaking/query-transport.js';
68
- import { DEFAULT_MATCHMAKING_PROTOCOLS, matchmakingProtocolList } from './matchmaking/protocols.js';
69
- import { signPeer } from './cohort-topic/peer-sig.js';
70
- import {
71
- createNotificationVerifier,
72
- createCorrelationReplayGuard,
73
- createStickyCohortHintCache,
74
- reactivityNodePolicy,
75
- createTierAddressing,
76
- createRingHash,
77
- Tier,
78
- b64urlToBytes,
79
- bytesToB64url,
80
- type NotificationV1,
81
- type CohortRef,
82
- type PushStateGossipV1,
83
- type PushStateInit,
84
- type NotificationVerifier,
85
- } from '@optimystic/db-core';
86
- import { PartitionDetector } from './cluster/partition-detector.js';
87
- import { assertSuperMajorityCoupling } from './cluster/supermajority-coupling.js';
88
- import { createLogger } from './logger.js';
89
- import { PeerReputationService } from './reputation/peer-reputation.js';
90
- import type { IPeerReputation } from './reputation/types.js';
91
- import type { AuthorizeInboundStream, InboundStreamAuthorizationInit } from './inbound-authorization.js';
92
- import { DisputeService } from './dispute/dispute-service.js';
93
- import { DisputeClient } from './dispute/client.js';
94
- import { sampleArbitrators } from './dispute/arbitrator-selection.js';
95
- import type { DisputeConfig } from './dispute/types.js';
96
-
97
- type Libp2pInit = NonNullable<Parameters<typeof createLibp2p>[0]>;
98
- export type Libp2pTransports = NonNullable<Libp2pInit['transports']>;
99
-
100
- /** A service that accepts post-construction injection of the running libp2p node. */
101
- interface SetLibp2pCapable {
102
- setLibp2p(libp2p: Libp2p): void;
103
- }
104
-
105
- /** A service that accepts post-start injection of the peer-reputation view. */
106
- interface SetReputationCapable {
107
- setReputation(reputation: IPeerReputation): void;
108
- }
109
-
110
- /**
111
- * The custom services that receive post-assembly dependency injection. Reaching them through this
112
- * typed record (rather than `(node as any).services?.fret?.setLibp2p?.(...)`) removes the silent
113
- * optional-chaining skips: a call like `wired.fret.setLibp2p(node)` is checked against these
114
- * interfaces at build time (a caller-side typo or wrong-arity call fails tsc), and if the service is
115
- * absent at runtime the property access throws (fail-fast) instead of being quietly no-op'd. Note the
116
- * config-side `services` map is itself cast (see the comment at its declaration), so a service RENAME
117
- * is caught here at runtime, not by tsc, and a signature change on the real service is caught only at
118
- * that service's own definition. All three services are unconditionally present in that config, so a
119
- * throw here is a genuine wiring bug, not a missing service.
120
- */
121
- type WiredServices = {
122
- fret: SetLibp2pCapable;
123
- networkManager: SetLibp2pCapable & SetReputationCapable;
124
- repo: SetLibp2pCapable;
125
- };
126
-
127
- /** Logger for the reactivity node-wiring (origination/forwarder/recover/rotation composition). */
128
- const reactivityWiringLog = createLogger('reactivity-node-wiring');
129
-
130
- /**
131
- * Logger for the best-effort in-factory service wiring. These injections run during `createLibp2p`
132
- * internals against the unreliable `components.libp2p` proxy; the real node is re-injected
133
- * post-construction (see the load-bearing block after `createLibp2p`), so a failure here is logged,
134
- * not fatal.
135
- */
136
- const wiringLog = createLogger('node-wiring');
137
-
138
- /** Factory function or instance for creating raw storage */
139
- export type RawStorageProvider = IRawStorage | (() => IRawStorage);
140
-
141
- /**
142
- * `ClusterPolicyOptions` is intersected in, not restated: `resolveClusterPolicy` consumes those
143
- * fields structurally, so a second copy of the shape here would let a newly added knob compile and
144
- * be silently ignored. See `cluster/cluster-policy.ts` for what each one resolves to.
145
- */
146
- export type NodeOptions = ClusterPolicyOptions & {
147
- /**
148
- * Network port. Only used by the default `listenAddrs` fallback.
149
- * For non-TCP transports (e.g. WebSockets), set `listenAddrs` explicitly.
150
- */
151
- port?: number;
152
- /**
153
- * WebSocket listen port. When set, the Node `createLibp2pNode` defaulting
154
- * branch adds `webSockets()` to the transports and `/ip4/<wsHost>/tcp/<wsPort>/ws`
155
- * to the listen addrs. Browsers and other WS-only peers (RN, web) can dial here.
156
- * Ignored when `transports`/`listenAddrs` are explicitly provided.
157
- */
158
- wsPort?: number;
159
- /** Interface to bind the WS listener to. Defaults to `0.0.0.0`. */
160
- wsHost?: string;
161
- /**
162
- * Drop the default TCP transport and TCP listen addr. Useful for browser-only
163
- * bootstraps that listen on `/ws` (typically fronted as `/wss`) only.
164
- * Ignored when `transports`/`listenAddrs` are explicitly provided.
165
- */
166
- disableTcp?: boolean;
167
- bootstrapNodes: string[];
168
- networkName: string;
169
- fretProfile?: 'edge' | 'core';
170
- id?: string; // optional peer id
171
- relay?: boolean; // enable relay service
172
- /**
173
- * Init passed to `circuitRelayServer(...)` when `relay` is enabled.
174
- *
175
- * `@libp2p/circuit-relay-v2` defaults to `applyDefaultLimit: true`, which
176
- * stamps every reservation with `Limit { data: 128 KiB, duration: 2 min }`
177
- * and resets the relayed stream once either cap is hit — silently killing
178
- * long-lived service↔browser circuits. Trusted local clusters (e.g. the
179
- * reference-peer service nodes) should pass
180
- * `{ reservations: { applyDefaultLimit: false } }` to lift the cap.
181
- */
182
- relayServerInit?: CircuitRelayServerInit;
183
- /** Storage provider - either an IRawStorage instance or a factory function. Defaults to MemoryRawStorage if not provided. */
184
- storage?: RawStorageProvider;
185
- /** Override libp2p listen multiaddrs. */
186
- listenAddrs?: string[];
187
- /**
188
- * Multiaddrs to advertise INSTEAD OF the listen addrs. For a node behind a NAT / reverse proxy /
189
- * DNS front that binds one address but is reachable at another. When non-empty these REPLACE the
190
- * advertised set entirely — observed/relayed addresses and {@link NodeOptions.appendAnnounceAddrs}
191
- * are all dropped from it. An empty array means "unset" (libp2p's own semantics).
192
- */
193
- announceAddrs?: string[];
194
- /**
195
- * Multiaddrs to advertise IN ADDITION TO the listen addrs. Ignored while
196
- * {@link NodeOptions.announceAddrs} is non-empty.
197
- */
198
- appendAnnounceAddrs?: string[];
199
- /** Override libp2p transports. */
200
- transports?: Libp2pTransports;
201
-
202
- /**
203
- * Responsibility K - the replica set size for determining cluster membership.
204
- * This is distinct from kBucketSize (DHT routing) and clusterSize (consensus quorum).
205
- * On the repo path, a node checks whether it is in the top responsibilityK peers
206
- * (by XOR distance) for the key and redirects to closer peers if not. On the cluster
207
- * update path it is a small-mesh bypass threshold: when the record's peer set is
208
- * smaller than this, the update is processed locally regardless of membership;
209
- * otherwise a non-member redirects to the responsible peers.
210
- * Default: 1 (only the closest/member peer is responsible)
211
- */
212
- responsibilityK?: number;
213
-
214
- /** Arachnode storage configuration */
215
- arachnode?: {
216
- enableRingZulu?: boolean; // default: true
217
- storage?: StorageMonitorConfig;
218
- };
219
-
220
- /**
221
- * Churn-resilient spread protocol tuning. Absent -> enabled with defaults
222
- * (see SpreadOnChurnConfig). Set { enabled: false } to disable spread on this node.
223
- */
224
- spreadOnChurn?: Partial<SpreadOnChurnConfig>;
225
-
226
- /**
227
- * Rebalance reaction tuning. Drives the RebalanceMonitor + BlockTransferCoordinator pull-gained/
228
- * push-lost path when arachnode/FRET are available (the only place fretAdapter + restoration
229
- * coordinator exist). Absent -> enabled with defaults (see RebalanceMonitorConfig). Set
230
- * { enabled: false } to disable the rebalance reaction on this node. When arachnode is disabled
231
- * or FRET is absent the rebalance path stays inert regardless of this flag (rebalance is a
232
- * resilience optimization, not a correctness requirement).
233
- */
234
- rebalance?: Partial<RebalanceMonitorConfig> & { enabled?: boolean };
235
-
236
- /** Transaction validator for cluster consensus */
237
- validator?: ITransactionValidator;
238
-
239
- /** Optional persistence for network state (HWM, FRET table) across restarts */
240
- persistence?: NetworkStatePersistence;
241
-
242
- /** Dispute protocol configuration */
243
- dispute?: Partial<DisputeConfig>;
244
-
245
- /** Optional persistent store for 2PC transaction state (enables crash recovery) */
246
- transactionStateStore?: ITransactionStateStore;
247
-
248
- /**
249
- * Optional sink for the consensus commit certificate, fired per committed action just before the
250
- * commit is applied to local storage (see {@link CommitCertificateSink}). This is the cluster-side
251
- * half of the reactivity origination path: a caller wiring reactivity supplies a
252
- * {@link CommitCertStore}'s `put` here, then resolves it via {@link makeClusterCommitCertExtractor}
253
- * when it installs the change-notifier bridge ({@link attachCohortChangeBridge}) on the running
254
- * node. Absent → zero cost (no cert is assembled).
255
- */
256
- onCommitCertificate?: CommitCertificateSink;
257
-
258
- /**
259
- * Opt-in cohort-topic substrate activation (reactivity / matchmaking origination). Default OFF →
260
- * the node keeps today's bare `blockChangeNotifier = storageRepo` behavior at zero cohort cost (no
261
- * host, no cert store; a caller-supplied {@link onCommitCertificate} is the only sink). When
262
- * `enabled`, the node-base constructs the cohort-topic host post-assembly, builds a real FRET-backed
263
- * `selfIsCohortMember` gate over `coord_0(H(tailId ‖ "reactivity"))`, and installs the change-notifier
264
- * origination bridge — making reactivity origination live for ALL collections created on the node.
265
- *
266
- * A failure to construct the host (or a missing FRET service) **hard-fails** node startup: the
267
- * operator opted in, so silently degrading to the bare notifier would hide misconfiguration.
268
- */
269
- cohortTopic?: {
270
- /** Master switch. Absent/`false` → dormant, zero cost. */
271
- enabled: boolean;
272
- /**
273
- * Requested cohort size; MUST match the host's `wantK` so the membership gate checks the same
274
- * cohort the host serves. Default 16 (the host's default).
275
- */
276
- wantK?: number;
277
- /** Optional pass-through host tuning (profile / minSigs / fanout / gossipIntervalMs / antiDos / promotion). */
278
- host?: Omit<CohortTopicHostOptions, 'privateKey' | 'wantK'>;
279
- };
280
-
281
- /**
282
- * Optional Ed25519 private key for this node. When provided, the libp2p
283
- * node uses this identity instead of generating a fresh keypair. Use this
284
- * to persist peer identity across process restarts.
285
- *
286
- * Accepts a libp2p `PrivateKey` (as returned by `generateKeyPair('Ed25519')`
287
- * or `privateKeyFromProtobuf(...)` from `@libp2p/crypto/keys`).
288
- */
289
- privateKey?: PrivateKey;
290
-
291
- /**
292
- * Optional predicate deciding whether a remote peer may open one of the four Optimystic
293
- * database protocols on this node (`repo`, `cluster`, `sync`, `block-transfer`). It is
294
- * consulted once per inbound stream, before any frame is decoded or any operation executed.
295
- *
296
- * This is deliberately ONE node-level option threaded to all four services rather than four
297
- * per-service options: "is this peer allowed to talk to my database?" is a property of the
298
- * node, not of the protocol, and four independently-settable options make it easy to secure
299
- * three surfaces and silently miss the fourth. (Each service still accepts the same option in
300
- * its own init, so the services stay independently testable and usable outside this factory.)
301
- *
302
- * Absent → no check at all, and today's behavior exactly. Supplied → fail-closed: `false`, a
303
- * throw, a rejection, or a timeout all deny and abort the stream. `remotePeerId` is the
304
- * dialing peer's `PeerId.toString()`. See {@link AuthorizeInboundStream} and
305
- * `docs/internals.md` § Inbound Stream Authorization.
306
- *
307
- * NOTE: this covers the four database protocols only. The dispute, reactivity, matchmaking,
308
- * cohort-topic and libp2p built-in (identify/ping/…) protocols this node also registers are
309
- * NOT gated by it. To refuse a peer at the connection level instead — every protocol at once,
310
- * including identify — use {@link NodeOptions.connectionGater}.
311
- */
312
- authorizeInboundStream?: AuthorizeInboundStream;
313
-
314
- /**
315
- * Deadline for {@link NodeOptions.authorizeInboundStream}; expiry denies the stream (a hanging
316
- * predicate would otherwise pin an inbound stream slot). Defaults to
317
- * `DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS` (5s). Ignored when no predicate is supplied.
318
- */
319
- authorizeInboundStreamTimeoutMs?: number;
320
-
321
- /**
322
- * Optional libp2p connection gater. The libp2p browser default denies
323
- * dialing insecure WebSockets and private/loopback addresses; callers
324
- * that need to dial local or unsecured bootstraps (web reference dev,
325
- * Playwright e2e, RN simulators) supply a permissive gater here.
326
- */
327
- connectionGater?: ConnectionGater;
328
- };
329
-
330
- function resolveStorage(provider: RawStorageProvider | undefined): IRawStorage {
331
- if (!provider) {
332
- return new MemoryRawStorage();
333
- }
334
- return typeof provider === 'function' ? provider() : provider;
335
- }
336
-
337
- /**
338
- * Resolve the full FRET engine the cohort-topic host needs.
339
- *
340
- * `createCohortTopicHost` consumes the complete {@link FretService} engine surface — notably
341
- * `setActivityHandler` (and `routeAct`, size estimation, …). The value at `node.services.fret` is the
342
- * libp2p `Libp2pFretService` *wrapper*, which re-exports only a subset (`assembleCohort`, `routeAct`, …)
343
- * and keeps the real engine private behind its lazy `ensure()` accessor. By the time activation runs the
344
- * engine is already initialized — the wrapper's `Startable.start()` ran during `node.start()` — and the
345
- * engine and wrapper share one underlying routing store, so the host and the membership gate observe the
346
- * same cohort state. Returns the engine when reachable; otherwise the value as-is (a test may inject a
347
- * raw engine that needs no unwrapping).
348
- */
349
- function resolveFretEngine(fret: FretService | undefined): FretService | undefined {
350
- if (!fret) {
351
- return undefined;
352
- }
353
- const candidate = fret as unknown as { ensure?: () => FretService };
354
- return typeof candidate.ensure === 'function' ? candidate.ensure() : fret;
355
- }
356
-
357
- /**
358
- * The raw topic id bytes of a collection's current served reactivity {@link PushState}, or `undefined` if the
359
- * node serves none. The forwarder host keys its served map by topicId, but a **backfill** recover request
360
- * carries only a collectionId — so the drain-redirect binding resolves the collection's current tail topic
361
- * here (the highest-`lastRevision` served PushState) before consulting `rotationRedirectFor`. While the old
362
- * tail is the only served state this resolves it (and its drain gate redirects); once the new tail is served
363
- * this resolves the new tail (no gate → no redirect), exactly as the recover serve's backfill path intends.
364
- */
365
- function resolveCurrentServedTopic(forwarderHost: ReactivityForwarderHost, collectionId: string): Uint8Array | undefined {
366
- const ps = forwarderHost.pushStateForCollection(collectionId);
367
- return ps === undefined ? undefined : b64urlToBytes(ps.topicId);
368
- }
369
-
370
- export async function createLibp2pNodeBase(
371
- options: NodeOptions,
372
- defaults: {
373
- listenAddrs: string[];
374
- transports: Libp2pTransports;
375
- }
376
- ): Promise<OptimysticNode> {
377
- const rawStorage = resolveStorage(options.storage);
378
-
379
- // Create placeholder restore callback (will be replaced after node starts)
380
- let restoreCallback: RestoreCallback = async (_blockId, _rev?) => {
381
- return undefined;
382
- };
383
-
384
- // Create shared storage layers with restoration callback
385
- const storageRepo = new StorageRepo((blockId) =>
386
- new BlockStorage(blockId, rawStorage, restoreCallback)
387
- );
388
-
389
- // Per-block commit-latch runner, ready to thread into the invalidation-apply sink (`onInvalidate`)
390
- // passed to `clusterMember(...)` and into each cascade `CollectionEnv`, the instant either is wired
391
- // here. Sharing the `StorageRepo.commit:<blockId>` latch makes a compensating saveReplica/saveDeletion
392
- // RMW of `meta.latest` mutually exclusive with a concurrent commit on the same block. It is unused
393
- // today only because no `onInvalidate`/cascade driver is wired in the live node (see review handoff);
394
- // it is bound here so that wiring is a one-liner and cannot reach for a divergent latch key.
395
- const blockCommitLatch = withBlockCommitLatch;
396
- void blockCommitLatch;
397
-
398
- let clusterImpl: ICluster | undefined;
399
- let coordinatedRepo: IRepo | undefined;
400
- // The running node, bound immediately after `createLibp2p` below. Service factories that need
401
- // the node at REQUEST time must close over this, never over `components.libp2p`: `components`
402
- // is libp2p's Proxy, whose getter THROWS `MissingServiceError('libp2p not set')` for any key it
403
- // does not hold — and `libp2p` is not a component. The throw happens on the property read, so
404
- // neither `?.` nor a following `if (!libp2p) return` can catch it; it escapes as an application
405
- // error on whatever request touched it. Same reason fret/networkManager/repo take the node via
406
- // setLibp2p (see the injection block after `createLibp2p`).
407
- let liveNode: Libp2p | undefined;
408
-
409
- const clusterProxy: ICluster = {
410
- async update(record) {
411
- if (!clusterImpl) {
412
- throw new Error('ClusterMember not initialized');
413
- }
414
- return await clusterImpl.update(record);
415
- }
416
- };
417
-
418
- const repoProxy: IRepo = {
419
- async get(blockGets, options) {
420
- const target = coordinatedRepo ?? storageRepo;
421
- return await target.get(blockGets, options);
422
- },
423
- async pend(request, options) {
424
- const target = coordinatedRepo ?? storageRepo;
425
- return await target.pend(request, options);
426
- },
427
- async cancel(trxRef, options) {
428
- const target = coordinatedRepo ?? storageRepo;
429
- return await target.cancel(trxRef, options);
430
- },
431
- async commit(request, options) {
432
- const target = coordinatedRepo ?? storageRepo;
433
- return await target.commit(request, options);
434
- }
435
- };
436
-
437
- // The ONE authorization slice, spread verbatim into all four database-protocol service inits
438
- // below. Building it once (rather than repeating two option reads per service) is what makes
439
- // "secured three surfaces, missed the fourth" impossible: adding a fifth protocol service is a
440
- // spread of this object, and dropping it from one is visible at the call site.
441
- // Absent `authorizeInboundStream` → every service constructs its gate as `undefined` and the
442
- // inbound path is byte-for-byte what it was before this option existed.
443
- const inboundAuthorization: InboundStreamAuthorizationInit = {
444
- ...(options.authorizeInboundStream ? { authorizeInboundStream: options.authorizeInboundStream } : {}),
445
- ...(options.authorizeInboundStreamTimeoutMs !== undefined
446
- ? { authorizeInboundStreamTimeoutMs: options.authorizeInboundStreamTimeoutMs }
447
- : {})
448
- };
449
-
450
- const nodePrivateKey = options.privateKey ?? await generateKeyPair('Ed25519');
451
-
452
- const listenAddrs = options.listenAddrs ?? defaults.listenAddrs;
453
- const transports = options.transports ?? defaults.transports;
454
-
455
- // --- cohort-topic substrate activation (opt-in; default off → today's bare behavior, zero cost) ---
456
- const cohortEnabled = options.cohortTopic?.enabled === true;
457
- // Resolve wantK ONCE so the post-assembly host serves and the membership gate checks the SAME cohort.
458
- const cohortWantK = options.cohortTopic?.wantK ?? 16;
459
- // When enabled, the cluster member records the consensus commit cert into this store synchronously,
460
- // BEFORE `storageRepo.commit` emits the change event the bridge's extractor resolves it from (see
461
- // cluster-repo.ts §applyConsensusOperation). Created early because the sink must be passed into
462
- // `clusterMember(...)` below. Composed with any caller-supplied `onCommitCertificate` so both fire.
463
- const certStore: CommitCertStore | undefined = cohortEnabled ? createCommitCertStore() : undefined;
464
- const onCommitCertificate: CommitCertificateSink | undefined = certStore
465
- // `certStore.put` runs FIRST so origination's cert capture cannot be defeated by a throwing caller
466
- // sink: the whole composed call is isolated in `ClusterMember.captureCommitCert`, so a caller sink
467
- // that threw before the store was written would make that commit silently never originate. Ordering
468
- // the store first keeps origination correct regardless of the caller sink (`put` never throws).
469
- ? (actionId, cert): void => { certStore.put(actionId, cert); options.onCommitCertificate?.(actionId, cert); }
470
- : options.onCommitCertificate;
471
-
472
- // Every cluster-policy default lives in `cluster/cluster-policy.ts` — including WHY the admission
473
- // gate and the repair corroboration floor resolve the one operator field
474
- // (`clusterPolicy.assumedClusterSize`) to different values when it is absent. Resolved ONCE, here,
475
- // before anything that reads a cluster size is constructed: `networkManagerService` below,
476
- // `Libp2pKeyPeerNetwork`, and the spread-on-churn monitor init must all read `consensusConfig.clusterSize`
477
- // rather than `options.clusterSize` directly, or they can each apply their own fallback default and
478
- // silently disagree (ticket bug-cluster-size-resolution-single-source). `assertClusterSizeCoupling`
479
- // below is the fail-fast backstop if a future edit reintroduces that split.
480
- const consensusConfig = resolveClusterPolicy(options);
481
-
482
- const libp2pOptions: Libp2pInit = {
483
- start: false,
484
- privateKey: nodePrivateKey,
485
- // NOTE: libp2p's `AddressManagerInit` also carries `noAnnounce` and `announceFilter`; neither is
486
- // exposed on `NodeOptions`. Add them here the same way if a deployment ever needs to suppress a
487
- // specific advertised address rather than replace the whole set.
488
- addresses: {
489
- listen: listenAddrs,
490
- ...(options.announceAddrs ? { announce: options.announceAddrs } : {}),
491
- ...(options.appendAnnounceAddrs ? { appendAnnounce: options.appendAnnounceAddrs } : {})
492
- },
493
- connectionManager: {
494
- // `autoDial`, `minConnections`, and `dialQueue` were stale libp2p option keys silently
495
- // ignored under the former `libp2pOptions as any` (removed with this change). This libp2p
496
- // version has no such keys — auto-dial is now default connection-manager behavior with no
497
- // direct replacement — so they are dropped rather than re-cast. See review handoff.
498
- maxConnections: 16,
499
- // Renamed from the stale `inboundConnectionUpgradeTimeout`. 10_000 equals this version's
500
- // default, so surfacing (and correcting) the key is behavior-preserving; the old key was a no-op.
501
- inboundUpgradeTimeout: 10_000
502
- },
503
- ...(options.connectionGater ? { connectionGater: options.connectionGater } : {}),
504
- transports,
505
- connectionEncrypters: [noise()],
506
- streamMuxers: [yamux()],
507
- // Narrow cast confined to the `services` field: the built-in factories (identify/dcutr/…) are
508
- // typed against a SECOND copy of `@libp2p/interface` pulled in transitively (via `@libp2p/crypto`),
509
- // whose `Uint8Array<ArrayBuffer>` vs `<ArrayBufferLike>` PeerId/key shapes are structurally
510
- // incompatible with the top-level copy — a dependency-dedup artifact, not a real mismatch. The cast
511
- // stays on this field alone so the rest of `libp2pOptions` remains fully typed as `Libp2pInit`.
512
- // NOTE: this cast exists ONLY because of the duplicate @libp2p/interface install; if that dedups
513
- // (or on a libp2p bump) drop `as unknown as NonNullable<Libp2pInit['services']>` and type the map directly.
514
- services: ({
515
- // `@libp2p/identify` is the ONE service here whose protocol id it builds itself:
516
- // `Identify`/`IdentifyPush` both emit `/${protocolPrefix}/id[/push]/1.0.0`, always
517
- // prepending the leading slash (its own default is the BARE `'ipfs'`). So this
518
- // prefix must stay slash-LESS — passing `/optimystic/...` yields the malformed
519
- // double-slash `//optimystic/<net>/id/1.0.0`. Every other service below
520
- // (cluster/repo/sync/blockTransfer) concatenates its own template literal and so
521
- // takes the slash-PREFIXED `protocolPrefix` form; do not unify the two.
522
- // Locked by `identify-protocol-id.spec.ts`.
523
- identify: identify({
524
- protocolPrefix: `optimystic/${options.networkName}`
525
- }),
526
- // identify/push propagates *later* address/protocol changes (relay reservation,
527
- // AutoNAT-learned observed addr, a service registered post-start) to already-connected
528
- // peers. Without it those peers keep the stale snapshot from the initial identify.
529
- // Two consequences, both now covered by tests rather than asserted here:
530
- // - Addresses: a relay-only peer's reservation completes AFTER its first connection to
531
- // the relay, so the circuit address is exactly the one identify cannot have carried.
532
- // The relay's peerStore entry stays empty and a later dial by peer id alone fails
533
- // with NoValidAddressesError against a reachable peer — `relay-address-propagation.spec.ts`
534
- // (its gated control reproduces that failure with push removed).
535
- // - Protocols: `membershipOf` in `libp2p-key-network.ts` classifies a peer serves/
536
- // foreign/unknown purely from the peerStore protocol list, so a cluster/repo handler
537
- // registered post-start never flips an already-connected peer to `serves` —
538
- // `identify-push-propagation.spec.ts`.
539
- identifyPush: identifyPush({
540
- protocolPrefix: `optimystic/${options.networkName}`
541
- }),
542
- ping: ping(),
543
- // DCUtR (hole-punch) upgrades relayed node↔node connections to direct
544
- // ones; AutoNAT learns this node's public reachability via peer dial-back.
545
- // Both are always-on and depend on `identify` above. They are inert where
546
- // the transport can't hole-punch or dial back (e.g. browser/WS-only), which
547
- // is acceptable — they neither throw nor break the build in that case.
548
- dcutr: dcutr(),
549
- autoNAT: autoNAT(),
550
- pubsub: gossipsub({
551
- allowPublishToZeroTopicPeers: true,
552
- heartbeatInterval: 7000
553
- }),
554
- // Circuit relay server - enables this node to relay connections for other peers
555
- ...(options.relay ? { relay: circuitRelayServer(options.relayServerInit) } : {}),
556
-
557
- // Custom services - create wrapper factories that inject dependencies
558
- cluster: (components: any) => {
559
- const addressLog: AddressLog = components.logger.forComponent('db-p2p:peer-address-book');
560
- const serviceFactory = clusterService({
561
- protocolPrefix: `/optimystic/${options.networkName}`,
562
- responsibilityK: options.responsibilityK ?? 1,
563
- ...inboundAuthorization
564
- });
565
- return serviceFactory({
566
- logger: components.logger,
567
- registrar: components.registrar,
568
- cluster: clusterProxy,
569
- // Identity for membership scoping on the update path. peerId is a core
570
- // libp2p component, available at service-construction time.
571
- peerId: components.peerId,
572
- // Fallback addr resolver for redirect targets whose multiaddrs are not
573
- // already embedded in record.peers.
574
- getConnectionAddrs: (peerId: any) => {
575
- const conns = liveNode?.getConnections?.(peerId) ?? [];
576
- const addrs: string[] = [];
577
- for (const c of conns) {
578
- const addr = c.remoteAddr?.toString?.();
579
- if (addr) addrs.push(addr);
580
- }
581
- return addrs;
582
- },
583
- // Inbound cluster records carry each cohort member's multiaddrs. libp2p only
584
- // propagates addresses between directly-connected peers, so for a cohort chosen
585
- // by key position this is often the ONLY way this node learns how to reach a
586
- // relay-only sibling. Same late-binding shape as getConnectionAddrs above:
587
- // `liveNode` resolves at request time, not at service construction.
588
- recordPeerAddresses: (peerId: any, multiaddrs: string[]) => {
589
- if (!liveNode) return;
590
- mergePeerAddresses(liveNode, peerId, multiaddrs, addressLog);
591
- }
592
- });
593
- },
594
-
595
- repo: (components: any) => {
596
- const serviceFactory = repoService({
597
- protocolPrefix: `/optimystic/${options.networkName}`,
598
- responsibilityK: options.responsibilityK ?? 1,
599
- ...inboundAuthorization
600
- });
601
- // RepoService.checkRedirect needs the running node (network manager for the
602
- // responsible-set computation, self id for the membership check, connection
603
- // addrs for redirect targets). The libp2p components.libp2p proxy does NOT
604
- // reliably resolve from inside a service at request time, so the node is
605
- // injected explicitly post-construction via setLibp2p(node) below the same
606
- // mechanism networkManager/fret use rather than forwarded here. checkRedirect
607
- // keys the responsible set on the RAW encoded block id
608
- // (getCluster(encode(blockKey)) hashKey(encode(...))), matching the
609
- // coordinator's findCluster(encode(blockId))same cohort, no spurious redirect.
610
- return serviceFactory({
611
- logger: components.logger,
612
- registrar: components.registrar,
613
- repo: repoProxy
614
- });
615
- },
616
-
617
- sync: (components: any) => {
618
- const serviceFactory = syncService({
619
- protocolPrefix: `/optimystic/${options.networkName}`,
620
- ...inboundAuthorization
621
- });
622
- return serviceFactory({
623
- logger: components.logger,
624
- registrar: components.registrar,
625
- repo: repoProxy
626
- });
627
- },
628
-
629
- // Block-transfer protocol handler for churn re-replication. Wired to the
630
- // *local* storageRepo (not repoProxy): a pushed replica must land in this
631
- // node's own storage, not be re-routed through the cluster-coordinated repo.
632
- blockTransfer: (components: any) => {
633
- const serviceFactory = blockTransferService({
634
- protocolPrefix: `/optimystic/${options.networkName}`,
635
- ...inboundAuthorization
636
- });
637
- return serviceFactory({
638
- registrar: components.registrar,
639
- repo: storageRepo,
640
- // So this service's authorization denials reach the same error sink as the other three.
641
- logger: components.logger
642
- });
643
- },
644
-
645
- networkManager: (components: any) => {
646
- const svcFactory = networkManagerService({
647
- clusterSize: consensusConfig.clusterSize,
648
- expectedRemotes: (options.bootstrapNodes?.length ?? 0) > 0,
649
- allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
650
- clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5
651
- });
652
- const svc = svcFactory(components);
653
- // Best-effort proxy-time injection; the real node is re-injected post-construction below.
654
- try { (svc as SetLibp2pCapable).setLibp2p(components.libp2p); }
655
- catch (err) { wiringLog('networkManager in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err); }
656
- return svc;
657
- },
658
- fret: (components: any) => {
659
- const svcFactory = fretService({
660
- k: 15,
661
- m: 8,
662
- capacity: 2048,
663
- profile: options.fretProfile ?? ((options.bootstrapNodes?.length ?? 0) > 0 ? 'core' : 'edge'),
664
- networkName: options.networkName,
665
- bootstraps: options.bootstrapNodes ?? []
666
- });
667
- const svc = svcFactory(components) as Libp2pFretService;
668
- // Best-effort proxy-time injection; the real node is re-injected post-construction below.
669
- try { (svc as SetLibp2pCapable).setLibp2p(components.libp2p); }
670
- catch (err) { wiringLog('fret in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err); }
671
- return svc;
672
- }
673
-
674
- // [dispute-subsystem-dormant] The /optimystic/<network>/dispute/1.0.0 handler
675
- // (disputeProtocolService / DisputeProtocolService) is intentionally NOT registered here.
676
- // The subsystem is staged dormant pending arbitrator-set anchoring — without it, a peer
677
- // minting throwaway keypairs can forge a synthetic super-majority and pass resolution.
678
- // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
679
- // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
680
- }) as unknown as NonNullable<Libp2pInit['services']>,
681
- // Add bootstrap nodes as needed
682
- peerDiscovery: [
683
- ...(options.bootstrapNodes?.length ? [bootstrap({ list: options.bootstrapNodes })] : [])
684
- ],
685
- };
686
-
687
- const node = await createLibp2p(libp2pOptions);
688
-
689
- // Bind the closure-captured node BEFORE start(): the cluster service's address-learning and
690
- // redirect-addr resolvers read it on every inbound request, and the first one can arrive as
691
- // soon as the protocol handler goes live in start().
692
- liveNode = node;
693
-
694
- // Inject the REAL libp2p node into the services that need it, before start(). These are
695
- // load-bearing and the node has NOT started yet, so any throw fails fast and rejects node
696
- // creation (nothing started leaks) — far better than the service silently falling back to the
697
- // unreliable `components.libp2p` proxy and surfacing later as routing/consensus failures.
698
- const wired = node.services as unknown as WiredServices;
699
- wired.fret.setLibp2p(node);
700
- wired.networkManager.setLibp2p(node);
701
- // RepoService.checkRedirect resolves the network manager / self id / connection
702
- // addrs through this injected node (the components.libp2p proxy is unreliable
703
- // from inside a service at request time). Done before start() so the protocol
704
- // handler is live with a resolvable node from its first request.
705
- wired.repo.setLibp2p(node);
706
-
707
- await node.start();
708
-
709
- // Everything from here to the `return` runs against an ALREADY STARTED node (open transports,
710
- // listening addresses, running services). A rejection out of that span used to hand the caller an
711
- // error and no handle, leaving the node running with its listener port still bound — unrecoverable
712
- // for the caller and enough to block the port for the next start attempt. So the whole post-start
713
- // body rolls back: see the `catch` at the bottom of this function.
714
- try {
715
-
716
- // Initialize peer reputation service
717
- const reputation = new PeerReputationService();
718
-
719
- // Initialize cluster coordination components
720
- const networkMode: NetworkMode = (options.bootstrapNodes?.length ?? 0) > 0 ? 'joining' : 'forming';
721
- // Network-namespaced protocol prefix, threaded into the key network so coordinator/
722
- // cohort selection is scoped to peers that serve THIS network's cluster/repo protocol.
723
- // A peer that only belongs to another network sharing the same physical nodes/
724
- // bootstraps registers a different (network-namespaced) identify protocol, so it is
725
- // never selected and can't drag this network's super-majority below quorum.
726
- const protocolPrefix = `/optimystic/${options.networkName}`;
727
- const keyNetwork = new Libp2pKeyPeerNetwork(node, consensusConfig.clusterSize, undefined, networkMode, options.persistence, reputation, protocolPrefix);
728
- await keyNetwork.initFromPersistedState();
729
- const createClusterClient = (peerId: any) => ClusterClient.create(peerId, keyNetwork, protocolPrefix);
730
-
731
- // Inject reputation into NetworkManagerService. Load-bearing and non-optional: the service is
732
- // unconditionally present, so a throw is a real wiring bug. The node has already started here, but
733
- // no ad-hoc stop is needed: the post-start rollback `catch` at the bottom of this function stops it.
734
- wired.networkManager.setReputation(reputation);
735
-
736
- // Create partition detector and get FRET service
737
- const partitionDetector = new PartitionDetector();
738
- const fretSvc = (node as any).services?.fret as FretService | undefined;
739
-
740
- // Fetch a block archive from one cohort peer over the sync protocol, bounded by a
741
- // per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
742
- // SyncClient query in `clusterLatestCallback`, but returns the full archive (which
743
- // carries the materialized block) rather than only the latest ActionRev.
744
- const fetchArchiveFromPeer = async (peerIdStr: string, blockId: BlockId): Promise<BlockArchive | undefined> => {
745
- let peerId: ReturnType<typeof peerIdFromString>;
746
- try {
747
- peerId = peerIdFromString(peerIdStr);
748
- } catch {
749
- return undefined;
750
- }
751
- if (peerId.equals(node.peerId)) return undefined;
752
- const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
753
- try {
754
- const response = await Promise.race<SyncResponse>([
755
- syncClient.requestBlock({ blockId, rev: undefined }),
756
- new Promise<SyncResponse>(resolve => { setTimeout(() => resolve({ success: false }), 1000).unref(); })
757
- ]);
758
- return response.success ? response.archive : undefined;
759
- } catch {
760
- // Peer unreachable / no data — caller falls back to the next cohort peer.
761
- return undefined;
762
- }
763
- };
764
-
765
- // Active reconciliation for a block this member committed without a materializable base
766
- // (cohort drift, or a refused `missing-base-revision` commit). See `reconcile-block.ts` for
767
- // the corroboration rules — in particular why both quorums are capped by how many peers
768
- // could answer at all, which is what lets a genuinely two-node cohort heal.
769
- // NOTE: this and the CoordinatorRepo below must cap against the SAME
770
- // repairCorroborationClusterSize, or the two restoration paths disagree about how much trust a
771
- // lone peer gets. Safe today because both read the one `resolveClusterPolicy` result above; if
772
- // either ever resolves its own value, add a fail-fast coupling check like
773
- // `assertSuperMajorityCoupling` rather than relying on proximity.
774
- const reconcileBlock: ReconcileBlockCallback = createReconcileBlock({
775
- selfPeerId: node.peerId.toString(),
776
- fetchArchive: fetchArchiveFromPeer,
777
- saveReplicatedBlock: (blockId, block, source) => storageRepo.saveReplicatedBlock(blockId, block, source),
778
- simpleMajorityThreshold: consensusConfig.simpleMajorityThreshold,
779
- repairCorroborationClusterSize: consensusConfig.repairCorroborationClusterSize,
780
- reputation
781
- });
782
-
783
- // Member-side membership derivation for the admission gate: independently re-derive this block's
784
- // responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
785
- // network-size confidence. A member gates a coordinator-declared peer set against this view before
786
- // voting, so a self-shrunk minority-partition set cannot be voted into super-majority (see cluster-repo
787
- // admitMembership). No FRET confidence 0 the gate fails closed for any downsize.
788
- const deriveExpectedCluster: DeriveExpectedClusterCallback = async (blockId) => {
789
- const peers = await keyNetwork.findCluster(new TextEncoder().encode(blockId));
790
- let confidence = 0;
791
- if (fretSvc) {
792
- try {
793
- confidence = fretSvc.getNetworkSizeEstimate().confidence;
794
- } catch {
795
- // Leave confidence 0 → fail closed for downsizing.
796
- }
797
- }
798
- return { peers: peers ?? {}, confidence };
799
- };
800
-
801
- clusterImpl = clusterMember({
802
- storageRepo,
803
- peerNetwork: keyNetwork,
804
- peerId: node.peerId,
805
- privateKey: nodePrivateKey,
806
- protocolPrefix,
807
- partitionDetector,
808
- fretService: fretSvc,
809
- validator: options.validator,
810
- reputation,
811
- consensusConfig,
812
- stateStore: options.transactionStateStore,
813
- reconcileBlock,
814
- onCommitCertificate,
815
- deriveExpectedCluster
816
- // `recomputeArbitratorSet` (invalidation layer-2) is intentionally NOT wired here yet: a live FRET
817
- // recompute needs a churn-tolerance window so it does not false-reject legitimate certificates from
818
- // late-joiners (a liveness regression). Until that is tuned against live topology — and the
819
- // cohort-topic membership-cert trust anchor (layer 3) lands invalidation verification runs on the
820
- // challenger-bound set + membership + dedup (layer 1) and LOGS the residual anchoring gap. See
821
- // `verifyInvalidationCertificate` and `tickets/plan/cohort-topic-membership-cert-trust-anchoring.md`.
822
- });
823
-
824
- // Cleanup cluster member intervals on node stop. Installed HERE, immediately after clusterImpl
825
- // exists, rather than further down: the post-start rollback only unwinds resources whose stop
826
- // wrapper is already installed at the moment of the throw, so a wrapper trailing its resource by
827
- // hundreds of lines leaves those intervals running on a failed startup. Same reasoning as the
828
- // owned-block-feed wrapper below.
829
- {
830
- const previousStop = node.stop.bind(node);
831
- node.stop = async () => {
832
- try {
833
- (clusterImpl as import('./cluster/cluster-repo.js').ClusterMember).dispose();
834
- } finally {
835
- // Never let a dispose failure strand the transports — same try/finally shape every
836
- // other wrapper in this chain uses.
837
- await previousStop();
838
- }
839
- };
840
- }
841
-
842
- const coordinatorRepoFactory = coordinatorRepo(
843
- keyNetwork,
844
- createClusterClient,
845
- {
846
- // clusterSize is now part of consensusConfig (member + coordinator share one reference).
847
- ...consensusConfig
848
- },
849
- fretSvc,
850
- reputation,
851
- options.transactionStateStore
852
- );
853
-
854
- // Create callback for querying cluster peers for their latest block revision. Three-way
855
- // contract (see ClusterLatestCallback): an ActionRev is the peer's claim, a resolved
856
- // `undefined` is the peer answering "I hold nothing", and a REJECTION is silence — the
857
- // coordinator counts it as "did not answer" and refuses to report an authoritative absent
858
- // over it. Transport errors must therefore propagate, not collapse into `undefined` (that
859
- // collapse let a slow two-node cohort report a missing block as authoritatively absent
860
- // ticket cluster-read-consult-cannot-report-unreachable).
861
- const clusterLatestCallback: ClusterLatestCallback = async (peerId, blockId, context?) => {
862
- // Self-read short-circuit: dialling self via SyncClient is a round trip
863
- // with no remote on the other end, and on nodes without listen addresses
864
- // (solo WebSocket-only, bare-RN, etc.) the self-dial can hang the dial
865
- // queue. Read directly from the local storage repo instead. The catch stays:
866
- // a local storage error is not a cohort peer being unreachable, and the
867
- // coordinator ignores a self rejection anyway.
868
- if (peerId.equals(node.peerId)) {
869
- try {
870
- const result = await storageRepo.get({ blockIds: [blockId], context });
871
- return result[blockId]?.state?.latest;
872
- } catch {
873
- return undefined;
874
- }
875
- }
876
- const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
877
- // No try/catch: a dial or protocol failure rejects through to the coordinator, whose
878
- // per-peer deadline also bounds a hung request — slowness needs no race here.
879
- const response = await syncClient.requestBlock({ blockId, rev: undefined });
880
- if (response.success && response.archive) {
881
- const revisions = Object.keys(response.archive.revisions).map(Number);
882
- if (revisions.length > 0) {
883
- const maxRev = Math.max(...revisions);
884
- const revisionData = response.archive.revisions[maxRev];
885
- if (revisionData?.action) {
886
- return { actionId: revisionData.action.actionId, rev: maxRev };
887
- }
888
- }
889
- }
890
- // The peer DID answer, without data: `success:false` is the sync service's "Block not
891
- // found in local storage", and an archive with no usable revisions holds nothing either
892
- // way. Both are absent claims, not silence.
893
- return undefined;
894
- };
895
-
896
- coordinatedRepo = coordinatorRepoFactory({
897
- storageRepo,
898
- localCluster: clusterImpl,
899
- localPeerId: node.peerId,
900
- clusterLatestCallback,
901
- // Read-driven acquisition shares the commit path's reconcile callback verbatim: same bounded
902
- // archive fetch, same (rev, actionId) and content quorums, same monotonic saveReplicatedBlock
903
- // funnel. `clusterLatestCallback` alone can only tell the reader WHICH revision the cohort
904
- // holds; this is what moves the bytes. Only reached once a corroborated revision exists, so a
905
- // genuinely absent block still costs no archive fetch.
906
- acquireBlockFromCohort: reconcileBlock
907
- });
908
-
909
- // Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
910
- // coordinator (what declares a transaction committed on that super-majority) MUST run the same
911
- // threshold, or the node would come up able to disagree with itself mid-consensus. Both are fed from
912
- // the single `consensusConfig` above; this asserts on their RESOLVED values so any future drift throws
913
- // HERE at construction. See `assertSuperMajorityCoupling`.
914
- assertSuperMajorityCoupling(
915
- clusterImpl as import('./cluster/cluster-repo.js').ClusterMember,
916
- coordinatedRepo as import('./repo/coordinator-repo.js').CoordinatorRepo
917
- );
918
-
919
- // Recover persisted transaction state before accepting new requests
920
- if (options.transactionStateStore) {
921
- await (clusterImpl as import('./cluster/cluster-repo.js').ClusterMember).recoverTransactions();
922
- await (coordinatedRepo as import('./repo/coordinator-repo.js').CoordinatorRepo).recoverTransactions();
923
- }
924
-
925
- // --- Shared owned-block set for the resilience monitors ---
926
- // SpreadOnChurnMonitor (sender) and RebalanceMonitor (responsibility tracker) both act on "the
927
- // blocks this node physically holds". They share ONE Set so the two can never drift: a single
928
- // owned-block feed populates it, and the rebalance responsibility-loss signal evicts from it
929
- // (in the rebalance block below). Both monitors take this exact instance via deps.trackedBlocks.
930
- const networkManager = (node as any).services?.networkManager as NetworkManagerService | undefined;
931
-
932
- // See the comment above `consensusConfig` for why every cluster-size consumer must read the SAME
933
- // resolved value. This throws at construction (rather than letting a node come up mismatched) if a
934
- // future edit gives `keyNetwork` or `networkManager` their own fallback again.
935
- assertClusterSizeCoupling(consensusConfig.clusterSize, { keyNetwork, networkManager });
936
-
937
- const ownedBlocks = new Set<string>();
938
- // Single owned-block feed: every block this node commits OR receives as a replica fires
939
- // storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
940
- // node.blockChangeNotifier): the cohort-topic activation block below may replace
941
- // blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its own
942
- // surface regardless of that opt-in. NOTE: this feed does NOT re-emit blocks already durable
943
- // from a previous run; those are seeded once at startup by the storage-enumeration scan wired
944
- // below (seedOwnedBlocksFromStorage), so a restarted node protects on-disk data without waiting
945
- // for each block to be touched again. Registered lazily the first time a
946
- // monitor that reads ownedBlocks is wired, so when BOTH monitors are disabled no subscription
947
- // leaks; torn down exactly once in the stop wrapper below.
948
- let offOwnedBlockFeed: (() => void) | undefined;
949
- const ensureOwnedBlockFeed = (): void => {
950
- if (offOwnedBlockFeed) return;
951
- offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
952
- for (const blockId of e.blockIds) ownedBlocks.add(blockId);
953
- });
954
- };
955
- // Single owned-block-feed teardown. Registered up front (before either monitor's own stop
956
- // wrapper) so it runs regardless of WHICH monitor subscribed the feed - including the
957
- // spread-disabled / rebalance-only case. Idempotent: offOwnedBlockFeed is undefined-guarded.
958
- {
959
- const previousStop = node.stop.bind(node);
960
- node.stop = async () => {
961
- try {
962
- offOwnedBlockFeed?.();
963
- } finally {
964
- await previousStop();
965
- }
966
- };
967
- }
968
-
969
- // --- Churn-resilient spread: drive SpreadOnChurnMonitor on a live node ---
970
- // Nothing previously activated the SENDING side of the churn-resilient spread protocol on a
971
- // real node. Here we init + start the monitor (sharing ownedBlocks) and ensure the single
972
- // owned-block feed is live, so a debounced connection:close re-pushes the node's blocks to
973
- // expansion-cohort peers (the receiver durably persists each push via saveReplicatedBlock).
974
- let spreadMonitor: SpreadOnChurnMonitor | undefined;
975
- if (networkManager && (options.spreadOnChurn?.enabled ?? true) !== false) {
976
- try {
977
- spreadMonitor = networkManager.initSpreadOnChurnMonitor(
978
- partitionDetector,
979
- storageRepo,
980
- keyNetwork,
981
- consensusConfig.clusterSize,
982
- protocolPrefix,
983
- ownedBlocks,
984
- options.spreadOnChurn,
985
- );
986
- await spreadMonitor.start();
987
- ensureOwnedBlockFeed();
988
- } catch (err) {
989
- // Spread is a resilience optimization, not a correctness requirement - a wiring
990
- // failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup, unlike the
991
- // operator-opted-in cohortTopic block. Log and continue with spread inert.
992
- ((node as any).logger?.forComponent?.('db-p2p:spread-on-churn'))?.('init failed: %o', err);
993
- }
994
- }
995
-
996
- // Expose for tests/diagnostics (mirrors node.keyNetwork / node.reputation).
997
- (node as any).spreadOnChurnMonitor = spreadMonitor;
998
-
999
- // Disposal: stop the spread monitor deterministically before the transports close. Composes
1000
- // with the arachnode / clusterMember / cohort-topic stop wrappers (each calls its captured
1001
- // previousStop last). Idempotent (SpreadOnChurnMonitor.stop early-returns when not running), so
1002
- // a double node.stop() does not throw. The owned-block feed teardown is the separate up-front
1003
- // wrapper above (shared across both monitors).
1004
- {
1005
- const previousStop = node.stop.bind(node);
1006
- node.stop = async () => {
1007
- try {
1008
- if (spreadMonitor) await spreadMonitor.stop();
1009
- } finally {
1010
- await previousStop();
1011
- }
1012
- };
1013
- }
1014
-
1015
- // Initialize Arachnode ring membership and restoration
1016
- const enableArachnode = options.arachnode?.enableRingZulu ?? true;
1017
- if (enableArachnode) {
1018
- const log = (node as any).logger?.forComponent?.('db-p2p:arachnode');
1019
- const fret = (node as any).services?.fret as any;
1020
-
1021
- if (fret) {
1022
- const fretAdapter = new ArachnodeFretAdapter(fret, node.peerId.toString());
1023
-
1024
- // Blocks whose shed range has been RELEASED (Phase C of a ring shift, or a confirmed
1025
- // rebalance release). This is the GC-eligibility signal the future storage sweep
1026
- // (`st-storage-sweep-archival-and-capacity-estimate`) must consult: a block's local bytes may
1027
- // be reclaimed ONLY once it appears here, so an unconfirmed / still-served range is never
1028
- // swept. Populated strictly after replication is confirmed. See
1029
- // docs/arachnode-ring-handoff.md § Part 2 (Local bytes vs. tracking).
1030
- // NOTE: no sweep consumes this set yet; it is the coordinated eligibility handoff the sweep
1031
- // ticket will read. Until then it grows unbounded bound it when the sweep lands.
1032
- const gcEligible = new Set<string>();
1033
- (node as any).gcEligibleBlocks = gcEligible;
1034
-
1035
- // The ring-shift state machine (advertise→confirm→release). Wired inside the rebalance block
1036
- // below (it needs the BlockTransferCoordinator confirmer + the cohort-size floor); left
1037
- // undefined when the rebalance reaction is not wired, in which case ring shifts stay inert —
1038
- // a move-out is unsafe without the confirm/release path.
1039
- let ringShift: RingShiftCoordinator | undefined;
1040
-
1041
- const storageMonitor = new StorageMonitor(rawStorage, options.arachnode?.storage ?? {});
1042
- const ringSelector = new RingSelector(fretAdapter, storageMonitor, {
1043
- minCapacity: 100 * 1024 * 1024,
1044
- thresholds: {
1045
- moveOut: 0.85,
1046
- moveIn: 0.40
1047
- },
1048
- // Damping so the ring decision cannot thrash near a boundary
1049
- // (docs/arachnode-ring-handoff.md § Part 1).
1050
- smoothingAlpha: 0.2,
1051
- deadband: 0.5,
1052
- minDwellMs: 10 * 60 * 1000
1053
- });
1054
-
1055
- // Determine and announce ring membership
1056
- const peerId = node.peerId.toString();
1057
- const arachnodeInfo = await ringSelector.createArachnodeInfo(peerId);
1058
- fretAdapter.setArachnodeInfo(arachnodeInfo);
1059
-
1060
- log?.('Announced Arachnode membership: Ring %d', arachnodeInfo.ringDepth);
1061
-
1062
- // Setup restoration coordinator with FRET adapter
1063
- const restorationCoordinatorV2 = new RestorationCoordinator(
1064
- fretAdapter,
1065
- { connect: (pid, protocol) => node.dialProtocol(pid as Parameters<typeof node.dialProtocol>[0], [protocol]) },
1066
- `/optimystic/${options.networkName}`,
1067
- node.peerId.toString()
1068
- );
1069
-
1070
- // Update restore callback to use new coordinator
1071
- const newRestoreCallback: RestoreCallback = async (blockId, rev?) => {
1072
- return await restorationCoordinatorV2.restore(blockId, rev);
1073
- };
1074
-
1075
- // Replace the restore callback (this is a bit hacky, but works for now)
1076
- (storageRepo as any).createBlockStorage = (blockId: string) =>
1077
- new BlockStorage(blockId, rawStorage, newRestoreCallback);
1078
-
1079
- // --- Rebalance reaction: drive RebalanceMonitor + react via BlockTransferCoordinator ---
1080
- // Nothing previously activated the rebalance path on a real node: initRebalanceMonitor was
1081
- // never called, the monitor was never start()ed, and BlockTransferCoordinator (the
1082
- // pull-gained / push-lost reaction primitive) was never constructed in src. This block lives
1083
- // inside the arachnode `if (fret)` gate because both dependencies only exist here — the
1084
- // fretAdapter and the RestorationCoordinator. When arachnode is disabled or FRET is absent the
1085
- // rebalance path stays inert (acceptable: rebalance is a resilience optimization). A wiring
1086
- // failure here is non-fatal (log + continue), unlike the operator-opted-in cohortTopic block.
1087
- if (networkManager && (options.rebalance?.enabled ?? true) !== false) {
1088
- try {
1089
- // repo the LOCAL storageRepo (not repoProxy/coordinatedRepo): a pulled/pushed replica
1090
- // must land in / be read from this node's own storage, same reasoning as the
1091
- // blockTransfer service handler registration. protocolPrefix (/optimystic/<networkName>)
1092
- // MUST match the prefix the node registers its block-transfer handler under, or every
1093
- // lost-block push dials the wrong protocol and fails to connect.
1094
- const coordinator = new BlockTransferCoordinator(
1095
- storageRepo,
1096
- keyNetwork,
1097
- restorationCoordinatorV2,
1098
- partitionDetector,
1099
- protocolPrefix,
1100
- );
1101
-
1102
- const rebalanceMonitor = networkManager.initRebalanceMonitor(
1103
- partitionDetector,
1104
- fretAdapter,
1105
- ownedBlocks,
1106
- options.rebalance,
1107
- );
1108
- await rebalanceMonitor.start();
1109
-
1110
- // onRebalance fires synchronously from the monitor's debounced check; the coordinator's
1111
- // reaction (pull gained / push lost, each partition-guarded) is async, so hop it off the
1112
- // handler rather than blocking the monitor's emit loop. handleRebalanceEvent can REJECT
1113
- // (e.g. RestorationCoordinator.restore() throws while pulling a gained block) and a bare
1114
- // `void` would surface that as an unhandled rejection (process-fatal on Node >=15); the
1115
- // reaction is a resilience optimization, so swallow + log instead.
1116
- //
1117
- // ALONGSIDE dispatching to the coordinator, drive the shared owned-block set off this
1118
- // authoritative responsibility signal. A GAINED block is added immediately so it is
1119
- // tracked even before its next commit/replica touches the feed.
1120
- //
1121
- // A LOST block is NO LONGER released synchronously: doing so stopped spreading a block
1122
- // whose push to the new owners might fail, drop it below the replication floor, and let a
1123
- // later sweep reclaim it (docs/arachnode-ring-handoff.md § Why the current code violates
1124
- // it #2). Instead the release is GATED on confirmation the coordinator returns the lost
1125
- // blocks it confirmed replicated to floor new owners, and ONLY those are untracked
1126
- // (authoritative eviction from the shared set complements spread's lazy self-prune) and
1127
- // marked GC-eligible. A lost block whose push failed / was partition-skipped stays
1128
- // tracked and served, and is retried on the next rebalance.
1129
- //
1130
- // Best-effort iteration safety: this eviction can mutate ownedBlocks while
1131
- // SpreadOnChurnMonitor (or this monitor) is mid for...of over the same Set inside an
1132
- // async loop. Adding/deleting a Set entry during iteration does not throw in JS — entries
1133
- // are visited best-effort which is acceptable for a resilience mechanism, so we
1134
- // document it here rather than add locking.
1135
- rebalanceMonitor.onRebalance((event) => {
1136
- for (const blockId of event.gained) ownedBlocks.add(blockId);
1137
- coordinator.handleRebalanceEvent(event).then((result) => {
1138
- for (const blockId of result.released) {
1139
- rebalanceMonitor.untrackBlock(blockId); // also evicts from the shared ownedBlocks set
1140
- gcEligible.add(blockId); // confirmed replicated → safe to sweep
1141
- }
1142
- }).catch((err) => {
1143
- log?.('rebalance reaction failed: %o', err);
1144
- });
1145
- });
1146
-
1147
- // Ring-shift handoff (advertise→confirm→release). It needs the confirmer (this
1148
- // coordinator) and the cohort-size floor (this monitor), so it is wired here. The
1149
- // `onRelease` callback runs Phase C's local effect: stop serving/spreading the shed
1150
- // range and mark it GC-eligible the same authoritative eviction the confirmed-rebalance
1151
- // release performs.
1152
- ringShift = new RingShiftCoordinator({
1153
- fretAdapter,
1154
- ringSelector,
1155
- fret,
1156
- partitionDetector,
1157
- confirmer: coordinator,
1158
- ownedBlocks,
1159
- selfPeerId: peerId,
1160
- getFloor: () => rebalanceMonitor.getCohortSize(),
1161
- onRelease: (blockIds) => {
1162
- for (const blockId of blockIds) {
1163
- rebalanceMonitor.untrackBlock(blockId);
1164
- gcEligible.add(blockId);
1165
- }
1166
- }
1167
- });
1168
- // Reconcile any stale `moving` advertisement left by a crash mid-handoff (no-op unless
1169
- // arachnode metadata survived a restart still marked `moving`).
1170
- ringShift.reconcileOnStart();
1171
-
1172
- // Feed owned blocks via the SINGLE shared feed (idempotent — already live if the spread
1173
- // block above wired it). Both monitors read the same ownedBlocks set this populates.
1174
- ensureOwnedBlockFeed();
1175
-
1176
- // Expose for tests/diagnostics (mirrors node.spreadOnChurnMonitor).
1177
- (node as any).rebalanceMonitor = rebalanceMonitor;
1178
- (node as any).blockTransferCoordinator = coordinator;
1179
- (node as any).ringShiftCoordinator = ringShift;
1180
-
1181
- // Disposal: stop the monitor before transports close. Composes with the other stop
1182
- // wrappers (each calls its captured previousStop last). Idempotent — RebalanceMonitor.stop()
1183
- // early-returns when not running (NetworkManagerService.stop() also stops it). The shared
1184
- // owned-block feed teardown is the separate up-front wrapper (not duplicated here).
1185
- const previousStop = node.stop.bind(node);
1186
- node.stop = async () => {
1187
- try {
1188
- await rebalanceMonitor.stop();
1189
- } finally {
1190
- await previousStop();
1191
- }
1192
- };
1193
- } catch (err) {
1194
- // Rebalance is a resilience optimization, not a correctness requirement - a wiring
1195
- // failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup.
1196
- log?.('rebalance wiring init failed: %o', err);
1197
- }
1198
- }
1199
-
1200
- // Monitor capacity and adjust ring periodically. The damped `shouldTransition()` decides
1201
- // WHETHER/where to move (docs/arachnode-ring-handoff.md § Part 1); the RingShiftCoordinator
1202
- // carries the move out through the advertise→confirm→release handoff (§ Part 2) so a shift
1203
- // never drops a key below its replication floor. The old unilateral `setArachnodeInfo` flip —
1204
- // which changed advertised responsibility instantly with no data handoff — is gone.
1205
- //
1206
- // Ring shifts run ONLY when `ringShift` is wired (i.e. the rebalance reaction is enabled): a
1207
- // move-out is unsafe without the confirm/release path, so a node with the rebalance reaction
1208
- // disabled stays at its bootstrap ring rather than flipping unsafely.
1209
- const monitorInterval = setInterval(async () => {
1210
- if (!ringShift) return;
1211
- const transition = await ringSelector.shouldTransition();
1212
- if (transition.shouldMove && transition.direction && transition.newRingDepth !== undefined) {
1213
- log?.('Ring transition needed: moving %s to Ring %d', transition.direction, transition.newRingDepth);
1214
- try {
1215
- const outcome = await ringShift.executeShift({
1216
- direction: transition.direction,
1217
- newRingDepth: transition.newRingDepth
1218
- });
1219
- log?.('Ring shift outcome: %o', outcome);
1220
- } catch (err) {
1221
- log?.('Ring shift failed: %o', err);
1222
- } finally {
1223
- // Measure the minimum dwell from the SETTLED shift (completed or rolled back), not
1224
- // just the trigger stamped inside shouldTransition (docs/arachnode-ring-handoff.md §1.3).
1225
- ringSelector.recordShiftSettled();
1226
- }
1227
- }
1228
- }, 60_000);
1229
-
1230
- // Cleanup on node stop
1231
- const originalStop = node.stop.bind(node);
1232
- node.stop = async () => {
1233
- clearInterval(monitorInterval);
1234
- await originalStop();
1235
- };
1236
- } else {
1237
- log?.('FRET service not available, Arachnode disabled');
1238
- }
1239
- }
1240
-
1241
- // --- Seed the shared owned-block set from already-durable storage ---
1242
- // Blocks durable from a previous run are otherwise untracked until next touched (see the
1243
- // onAnyCollectionChange comment above where ownedBlocks is declared). Placed here, AFTER both
1244
- // monitor-wiring blocks (spread ~line 862, rebalance ~line 974) have had their chance to call
1245
- // ensureOwnedBlockFeed():
1246
- // - Gate on offOwnedBlockFeed: only seed when a monitor actually consumes ownedBlocks; if both
1247
- // are disabled the set is unused and the scan (plus the background task) is wasted work.
1248
- // - Feed-before-scan ordering is load-bearing: because the feed is already live, a block
1249
- // committed/replicated DURING the scan is caught by the feed; Set.add is idempotent so the
1250
- // overlap is harmless. Scanning before subscribing would drop a block committed in the gap.
1251
- // - Fire-and-forget so a large store never blocks startup; the .catch keeps a scan rejection
1252
- // from becoming an unhandled rejection.
1253
- // - Cancellable: a stop wrapper flips seedStopping so the scan loop breaks against a
1254
- // stopping/closing backend rather than running the enumeration to completion.
1255
- // NOTE: a concurrent rebalance release can untrackBlock (delete from ownedBlocks) a confirmed-
1256
- // released block while this scan is still running, and the scan could then re-add that id. Benign
1257
- // transient: the block is still in the metadata store (no sweep reclaims metadata yet), so a
1258
- // re-added released block is simply re-evaluated and re-released on the next rebalance tick. Right
1259
- // after a restart, responsibility-loss detection lags this fast metadata scan, so the window is
1260
- // small. Accepted rather than synchronized.
1261
- if (offOwnedBlockFeed && typeof rawStorage.listBlockIds === 'function') {
1262
- let seedStopping = false;
1263
- const previousStop = node.stop.bind(node);
1264
- node.stop = async () => {
1265
- seedStopping = true;
1266
- await previousStop();
1267
- };
1268
- void seedOwnedBlocksFromStorage(rawStorage, ownedBlocks, () => seedStopping)
1269
- .catch((err) => ((node as any).logger?.forComponent?.('db-p2p:owned-block-seed'))?.('seed failed: %o', err));
1270
- }
1271
-
1272
- // [dispute-subsystem-dormant] The DisputeService object is constructed below so tests and
1273
- // getDisputeStatus() work, but it is unreachable from the live network path:
1274
- // - No inbound handler: disputeProtocolService is NOT in the services map above.
1275
- // - onInvalidation is deliberately unset: maybeInvalidate() is a no-op on live nodes.
1276
- // - revalidate is deliberately unset: handleChallenge always votes inconclusive on live nodes.
1277
- // Full activation requires arbitrator-set anchoring before a forged synthetic cohort can pass resolution.
1278
- // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
1279
- // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
1280
- // Initialize dispute service if enabled
1281
- let disputeServiceInstance: DisputeService | undefined;
1282
- if (options.dispute?.disputeEnabled) {
1283
- const createDisputeClient = (peerId: any) => DisputeClient.create(peerId, keyNetwork, protocolPrefix);
1284
- disputeServiceInstance = new DisputeService({
1285
- peerId: node.peerId,
1286
- privateKey: nodePrivateKey,
1287
- peerNetwork: keyNetwork,
1288
- createDisputeClient,
1289
- reputation,
1290
- validator: options.validator,
1291
- config: options.dispute,
1292
- selectArbitrators: async (blockId: string, excludePeers: string[], count: number, round: number, epoch: Uint8Array) => {
1293
- const { hashKey: fretHashKey } = await import('p2p-fret');
1294
- const fret = (node as any).services?.fret as FretService | undefined;
1295
- if (!fret) return [];
1296
- // Dispersed sampling: draw `count` peers from coordinates spread across the whole keyspace
1297
- // (hash(blockId round epoch ‖ i)) rather than the block's XOR neighborhood, so an attacker
1298
- // who owns the block's locale does not thereby own the arbitrators. `assembleCohort` already
1299
- // filters to known members; excluding the original cluster + self keeps arbitrators independent.
1300
- const excludeSet = new Set(excludePeers);
1301
- // NOTE: adding the local node's own id to `exclude` makes the draw node-relative. Cross-node
1302
- // determinism (the verifiable-recompute property) holds today only because the dissent
1303
- // coordinator running this is itself a member of the original cluster, so `self` is already in
1304
- // `excludePeers` the add is a no-op and every honest node excludes the identical set. When a
1305
- // verify-path recompute lands, it MUST reconstruct `exclude` from the challenger's identity
1306
- // (`proof.challengerPeerId`) + original cluster, never the verifier's own id, or re-derivation diverges.
1307
- excludeSet.add(node.peerId.toString());
1308
- const picks = await sampleArbitrators(
1309
- { blockId: new TextEncoder().encode(blockId), round, epoch, count, exclude: excludeSet },
1310
- (coord, wants) => fret.assembleCohort(coord, wants) as string[],
1311
- fretHashKey,
1312
- );
1313
- return picks.map(pid => peerIdFromString(pid));
1314
- },
1315
- });
1316
- }
1317
-
1318
- // The host-facing attachment surface, declared once in `optimystic-node.ts` and written here
1319
- // through ONE object literal so every field is type-checked AND a field added to
1320
- // `OptimysticNodeAttachments` but never assigned here is a compile error rather than an
1321
- // `undefined` a host reads as present. Keeping it typed is load-bearing: when
1322
- // `node.keyNetwork` was reachable only through a cast, three hosts found it easier to build a
1323
- // SECOND Libp2pKeyPeerNetwork from constructor defaults a different cohort width and no
1324
- // network-membership filter than this node's own consensus path uses for the same key
1325
- // (ticket bug-second-key-network-built-with-defaults).
1326
- const attachments: OptimysticNodeAttachments = {
1327
- coordinatedRepo,
1328
- storageRepo,
1329
- // The StorageRepo is the single commit funnel for both the coordinated and
1330
- // direct paths, so it is the node's per-collection change-notifier origin. This is the
1331
- // default; the cohort-topic activation block below REPLACES it with the origination-decorating
1332
- // bridge notifier when the substrate is enabled.
1333
- blockChangeNotifier: storageRepo,
1334
- keyNetwork,
1335
- reputation,
1336
- disputeService: disputeServiceInstance,
1337
- // The node's libp2p Ed25519 identity key. Exposed on the same attachment surface as
1338
- // coordinatedRepo/keyNetwork so a host can bind a client-transaction signer to it (the Quereus
1339
- // collection-factory's getSigner reuses this via signPeer). libp2p does not surface the private
1340
- // key on its public `Libp2p` interface, so this attachment is the sanctioned in-process handle.
1341
- // Ed25519 by construction (options.privateKey defaults to generateKeyPair('Ed25519')).
1342
- peerPrivateKey: nodePrivateKey,
1343
- };
1344
- Object.assign(node, attachments);
1345
-
1346
- // --- Cohort-topic origination activation (post-node: consumes the fully-assembled node + FRET) ---
1347
- // This is the only place that is after the node + FRET are assembled (node.start() done, fretSvc
1348
- // available) yet before any caller can capture `blockChangeNotifier` — the Quereus collection-factory
1349
- // captures it once, immediately after createLibp2pNode returns, and reuses that reference as
1350
- // `localChangeNotifier` for every NetworkTransactor it builds. Installing the bridge here makes the
1351
- // origination path live for ALL collections created on the node.
1352
- if (cohortEnabled) {
1353
- // The host needs the full FRET engine surface; node.services.fret is the wrapper (see resolveFretEngine).
1354
- const fret = resolveFretEngine(fretSvc);
1355
- if (!fret) {
1356
- // Operator opted in; degrading silently to the bare notifier would hide misconfiguration.
1357
- // (The started node is torn down by the post-start rollback `catch` at the bottom of this function.)
1358
- throw new Error('cohortTopic enabled but the FRET service is unavailable on the node');
1359
- }
1360
-
1361
- const host = await createCohortTopicHost(node, fret, {
1362
- ...(options.cohortTopic!.host ?? {}),
1363
- // Wire the node's reputation service in as the production backing for the bootstrap-evidence
1364
- // referee verifier (the `{ isBanned, getScore }` view `PeerReputationService` satisfies), so a
1365
- // configured cohort genuinely gates cold-root `bootstrap: true` (PoW always; reputation when a
1366
- // referee endorsement is offered; a signed reference to an existing parent topic on any tier).
1367
- // The node service is the *default* backing a caller that supplies its own `antiDos.reputation`
1368
- // (or any other `antiDos` override) still wins, since the caller spread comes last.
1369
- antiDos: { reputation, ...(options.cohortTopic!.host?.antiDos) },
1370
- // committedParentTopicReader (the T0/T1 committed-tier parent-reference existence backing) is
1371
- // intentionally left unwired: no coord-keyed committed-membership index exists yet (the
1372
- // transaction-log commit certificate is keyed by action, not by coord_0). So the host default
1373
- // fails T0/T1 parent-ref existence closed — a FRET-cached cert must not vouch for committed-tier
1374
- // existence (committed-tier integrity) while T2/T3 parent-ref consults the FRET membership cache
1375
- // for real. The dedicated committed backing is the follow-on `cohort-topic-parent-ref-tx-log-content`;
1376
- // an operator may still pass one via cohortTopic.host.committedParentTopicReader.
1377
- privateKey: nodePrivateKey, // real k x threshold signing
1378
- wantK: cohortWantK,
1379
- });
1380
-
1381
- // --- Cohort-topic + reactivity + matchmaking teardown ---
1382
- // Installed HERE, immediately after `host` exists and BEFORE the ~230 lines of reactivity /
1383
- // matchmaking wiring below, because the post-start rollback only unwinds resources whose stop
1384
- // wrapper is already installed at the moment of the throw. With the wrapper at the END of the
1385
- // block (where it used to live) a throw mid-wiring left the host's gossip timer and cohort-topic
1386
- // protocol handlers running. The bindings it releases are therefore declared up front and
1387
- // undefined-guarded same idiom as `offOwnedBlockFeed` above so this tears down exactly what
1388
- // has been created so far, whether that is the host alone or the whole wiring.
1389
- //
1390
- // Ordering (load-bearing): release reactivity timers + protocol handlers BEFORE host.stop()
1391
- // (which clears the cohort gossip timer + unhandles the cohort-topic protocols) BEFORE the node's
1392
- // transports close (previousStop). Composes with the existing arachnode + clusterMember stop
1393
- // wrappers (each calls its captured previousStop last). `node.unhandle` on a protocol that was
1394
- // never registered does not throw libp2p's registrar deletes each id from its handler map
1395
- // (a miss is silently ignored) and then re-patches the peer store's advertised protocol list —
1396
- // so the handler releases need no separate registration flags.
1397
- const reactivityProtocols = DEFAULT_REACTIVITY_PROTOCOLS;
1398
- const matchmakingProtocols = DEFAULT_MATCHMAKING_PROTOCOLS;
1399
- let unsubscribeCohortBridge: (() => void) | undefined;
1400
- let offInboundNotify: (() => void) | undefined;
1401
- let pushStateGossip: ReactivityPushStateGossipDriver | undefined;
1402
- let reactivityRotation: RotationReRegistrationScheduler | undefined;
1403
- {
1404
- const previousStop = node.stop.bind(node);
1405
- node.stop = async (): Promise<void> => {
1406
- try {
1407
- reactivityRotation?.stop();
1408
- pushStateGossip?.stop();
1409
- offInboundNotify?.();
1410
- await node.unhandle(reactivityProtocolList(reactivityProtocols));
1411
- await node.unhandle(matchmakingProtocolList(matchmakingProtocols));
1412
- unsubscribeCohortBridge?.();
1413
- await host.stop();
1414
- } finally {
1415
- await previousStop();
1416
- }
1417
- };
1418
- }
1419
-
1420
- // selfIsCohortMember: this node owns the collection's reactivity-topic fan-out iff it is in the
1421
- // FRET cohort around coord_0(H(currentTailId ‖ "reactivity")). Uses db-core's default hashes
1422
- // (createReactivityTopicAnchor / createTierAddressing / createRingHash), byte-identical to the
1423
- // host's internal `new RingHash()` and the subscriber-side anchor, and the SAME cohortWantK as
1424
- // the host so the coord + cohort line up across origination and subscription.
1425
- const selfIsCohortMember = createReactivitySelfMembershipGate({
1426
- fret,
1427
- selfPeerId: node.peerId.toString(),
1428
- wantK: cohortWantK,
1429
- });
1430
-
1431
- unsubscribeCohortBridge = attachCohortChangeBridge(
1432
- node as unknown as { blockChangeNotifier?: IBlockChangeNotifier },
1433
- {
1434
- source: storageRepo,
1435
- service: host.service,
1436
- selfIsCohortMember,
1437
- extractCommitCert: makeClusterCommitCertExtractor(certStore!),
1438
- },
1439
- ).unsubscribe;
1440
-
1441
- // Expose the host so the reactivity origination wiring (and the activation test) can install
1442
- // `CohortTopicService.onLocalCommit`.
1443
- (node as any).cohortTopicHost = host;
1444
-
1445
- // --- Reactivity notification transport (origination → fan-out → inbound delivery → push-state gossip) ---
1446
- // Compose notify + forwarder-host + push-state-gossip onto the cohort-topic host so a committed change
1447
- // on a tail-cohort member actually reaches subscribers on OTHER nodes over real sockets. The change
1448
- // bridge above fires `onLocalCommit`; this is what the emitted notifications travel over.
1449
- // (docs/reactivity.md §Notification origination / §Propagation.) Reactivity reuses the canonical,
1450
- // network-agnostic protocol IDs, matching the cohort-topic family's production default.
1451
- const selfPeerId = node.peerId.toString();
1452
- const reactivityProfile = host.profile; // Edge subscriber-only via the policy gate; Core forwards.
1453
- const reactivityPolicy = reactivityNodePolicy(reactivityProfile);
1454
- // db-core default anchor + tier addressing, byte-identical to the host's `new RingHash()`, the
1455
- // origination gate, and the subscriber-side anchor so coord_0 derivation lines up everywhere.
1456
- const reactivityAddressing = createTierAddressing(createRingHash());
1457
- // Reactivity's forwarder cohort sits at coord_0 TREE tier 0 (peer-independent), distinct from the
1458
- // CAPACITY tier T3 the verifier/willingness use. `registry.findServing` keys on the engine's tree
1459
- // depth, so the served reactivity engine is found at tree tier 0, never at 3.
1460
- const REACTIVITY_FORWARDER_TREE_TIER = 0;
1461
-
1462
- // Node-level subscriber registry: a constructed ReactivitySubscriptionManager registers here so a
1463
- // socket-delivered NotificationV1 reaches it. (The Quereus Database.watch → manager bridge that
1464
- // CONSTRUCTS managers stays the backlog item optimystic-network-reactive-watch-integration-test.)
1465
- const reactivitySubscribers = new ReactivitySubscriberRegistry();
1466
- (node as any).reactivitySubscribers = reactivitySubscribers;
1467
-
1468
- // 1. Notify transport — unicast NotificationV1 send + inbound subscribe. selfPeerId guards self-dials.
1469
- const notify = new Libp2pReactivityNotifyTransport(node, { selfPeerId });
1470
-
1471
- // 2. Forwarder hostturns the forward decision into live fan-out over the notify transport.
1472
- const forwarderHost = new ReactivityForwarderHost({
1473
- transport: notify,
1474
- selfPeerId,
1475
- profile: reactivityProfile,
1476
- pushStateInit: (topicId: Uint8Array, n: NotificationV1): PushStateInit => ({
1477
- collectionId: n.collectionId,
1478
- topicId: bytesToB64url(topicId),
1479
- tailIdAtJoin: n.tailId,
1480
- deltaMaxBytes: reactivityPolicy.deltaMaxBytes,
1481
- }),
1482
- verifierFor: (): NotificationVerifier => createNotificationVerifier({ verifier: host.service.verifier(), tier: Tier.T3 }),
1483
- directSubscribers: (topicId: Uint8Array): string[] => {
1484
- // Find the served reactivity engine at TREE tier 0 (see REACTIVITY_FORWARDER_TREE_TIER) and read
1485
- // its direct-subscriber records. The adapter filters to reactivity appState and maps participantId
1486
- // bytes → dialable peer-id strings (the transport's `peerIdFromString` space) NOT base64url,
1487
- // which would silently fail to dial. `undefined` (no subscriber has registered here yet) [].
1488
- const engine = host.registry.findServing(topicId, REACTIVITY_FORWARDER_TREE_TIER);
1489
- return engine === undefined ? [] : reactivityDirectSubscribers(engine, topicId);
1490
- },
1491
- // No childCohorts until cohort-topic-parent-child-link populates PushState.childCohorts (single
1492
- // tier-0 reach today); wire the resolver anyway. A child cohort's primary is the FRET-nearest member
1493
- // of its coord, returned as a peer-id string (the dial space).
1494
- resolveChildPrimary: (ref: CohortRef): string | undefined => {
1495
- const peers = fret.assembleCohort(b64urlToBytes(ref.coord), cohortWantK);
1496
- return peers.length > 0 ? peers[0] : undefined;
1497
- },
1498
- deliverLocal: (topicId: Uint8Array, n: NotificationV1): void => reactivitySubscribers.deliver(topicId, n),
1499
- });
1500
-
1501
- // Inbound notify frames forwarder host (subscriber role delivers in-process; forwarder role fans out).
1502
- // NOTE: the four `register*Handler` helpers below (notify / pushStateGossip / recover /
1503
- // matchmaking query) all call `node.handle(...)` fire-and-forget (`void`), so a rejected
1504
- // registration escapes the post-start rollback `catch` as an UNHANDLED rejection instead of
1505
- // failing node creation. Harmless today every protocol id here is a fixed constant registered
1506
- // exactly once, so the only realistic rejection is a duplicate, and that needs a caller to pass
1507
- // overlapping custom `cohortTopic.host.protocols`. If any of these ids ever becomes
1508
- // caller-configurable, or a helper grows a registration that can genuinely fail, make them await
1509
- // their `node.handle` so the failure reaches the rollback.
1510
- registerNotifyHandler(node, reactivityProtocols.notify, notify);
1511
- offInboundNotify = notify.onNotification((from, n): void => { void forwarderHost.onInbound(from, n); });
1512
-
1513
- // 3. Origination emit — install onLocalCommit: a member commit builds a NotificationV1 and ingests it.
1514
- const origination = new ReactivityOriginationManager({
1515
- service: host.service,
1516
- resolveContext: (event) => {
1517
- if (event.tailId === undefined) {
1518
- return undefined; // tail-less (read-driven promotion) never originates (the gate also returns first)
1519
- }
1520
- return {
1521
- // MUST reuse the gate's `reactivityTailBytes` (utf8), NOT db-core's double-hashing
1522
- // blockIdToBytes — else origination derives a different coord than subscribers resolve.
1523
- tailId: reactivityTailBytes(event.tailId),
1524
- deltaMaxBytes: reactivityPolicy.deltaMaxBytes,
1525
- // rotationHint stays undefined on a live node: the successor tail id is not knowable at the
1526
- // filling commit (random block ids; gated on 6.5-block-id-derivation). The authoritative,
1527
- // observable rotation signal is `event.tailId` CHANGING, which the manager observes via the
1528
- // `markRotated` binding below. (The pre-announce remains exercised in the mock-tier harness +
1529
- // the design simulator, both of which can synthesize the successor id.)
1530
- };
1531
- },
1532
- // reactivityNotificationTopicId(n) = reactivityTopicId(b64urlToBytes(n.tailId)); since
1533
- // n.tailId = b64url(reactivityTailBytes(tail)), this is the SAME topicId the gate assembled coord_0
1534
- // around and the subscriber/forwarder verifier derives — closing the encoding loop.
1535
- emit: (n): void => { void forwarderHost.ingest(reactivityNotificationTopicId(n), n); },
1536
- // Observe-rotation: when a collection's tail id changes between commits the OLD tail's reactivity
1537
- // topic has rotated. Start its drain so the recover serve begins redirecting to the new tree (the
1538
- // `reactivity-rotation-recover-redirect-drain` markRotated seam). `oldTopicId` is byte-identical to
1539
- // the topic a subscriber subscribed under (both `reactivityTopicId(reactivityTailBytes(tail))`).
1540
- markRotated: (oldTopicId, redirect, now): void => forwarderHost.markRotated(oldTopicId, redirect, now),
1541
- });
1542
- origination.install();
1543
-
1544
- // 4. PushState gossip — periodic intra-cohort convergence so any member (not just the primary) can
1545
- // serve a replay/backfill. Rides the host's cohort gossip transport (no second transport).
1546
- pushStateGossip = new ReactivityPushStateGossipDriver({
1547
- gossipTransport: host.gossipTransport,
1548
- liveCollections: (): ReactivityGossipCollection[] => forwarderHost.livePushStates().map((pushState) => ({
1549
- pushState,
1550
- cohortCoord: reactivityAddressing.coord0(b64urlToBytes(pushState.topicId)),
1551
- })),
1552
- pushStateForGossip: (g: PushStateGossipV1) => forwarderHost.pushStateFor(b64urlToBytes(g.topicId)),
1553
- // Authenticity gate: accept gossip only from a member of the cohort around the frame's reactivity
1554
- // coord (per-frame peer-sig envelope signing is deferred — reactivity-pushstate-gossip's hardening backlog).
1555
- isCohortMember: (fromPeerId: string, g: PushStateGossipV1): boolean =>
1556
- fret.assembleCohort(reactivityAddressing.coord0(b64urlToBytes(g.topicId)), cohortWantK).includes(fromPeerId),
1557
- });
1558
- registerPushStateGossipHandler(node, reactivityProtocols.pushStateGossip, pushStateGossip);
1559
- pushStateGossip.start();
1560
-
1561
- // 5. Recover RPC — the pull companion to notify (docs/reactivity.md §Backfill RPC / §Resume). A
1562
- // subscriber that detected a gap, or woke from sleep past the live tail, asks a serving cohort member
1563
- // "what did I miss?" and is brought current over a real request-reply socket. The SERVE side is live
1564
- // here: this node answers RecoverRequestV1 frames against its live forwarder PushStates. The OUTBOUND
1565
- // transport + signers are constructed and exposed for the subscribe factory that CONSTRUCTS managers
1566
- // (the Quereus Database.watch app-bridge backlog optimystic-network-reactive-watch-integration-test);
1567
- // no node-internal manager calls them yet, exactly as the notify subscriber side is constructed against
1568
- // `reactivitySubscribers` rather than from a watch.
1569
- //
1570
- // Node-level sticky cohort-hint cache (keyed by collectionId), shared between the outbound transport's
1571
- // sticky-primary lookup and a future manager's rotation-invalidation so both see ONE cache. It starts
1572
- // empty ⇒ the transport falls through to the cohort-walk (any member holding the gossiped PushState
1573
- // answers); populating the sticky primary is a one-RT optimization, not a correctness need.
1574
- const reactivityCohortHintCache = createStickyCohortHintCache();
1575
- // topicId dialable cohort member peer-id strings: the SAME FRET coord_0 assembly the push-state-gossip
1576
- // authenticity gate uses (`reactivityAddressing.coord0` `fret.assembleCohort`), so a recover walk
1577
- // reaches exactly the cohort that holds the topic's gossiped PushState. `assembleCohort` returns peer-id
1578
- // strings (the recover dialer's `peerIdFromString` space), matching the notify dial-target space.
1579
- const resolveReactivityCohort = (topicId: Uint8Array): string[] =>
1580
- fret.assembleCohort(reactivityAddressing.coord0(topicId), cohortWantK);
1581
-
1582
- // Outbound transport: exposes the db-core BackfillTransport / ResumeTransport seams against this node.
1583
- // maxBytes is omitted so the dialer + handler default to DEFAULT_STREAM_MAX_BYTES, matching the notify
1584
- // transport's default (constructed above without an override) — one frame ceiling across the family.
1585
- const recover = new Libp2pReactivityRecoverTransport({
1586
- dialer: createLibp2pRecoverDialer(node, reactivityProtocols.recover),
1587
- selfPeerId,
1588
- cohortHintCache: reactivityCohortHintCache,
1589
- resolveCohort: resolveReactivityCohort,
1590
- });
1591
-
1592
- // Inbound serve handler: decode (bounded) → verify the dialing peer's signature → freshness/replay gate →
1593
- // resolve the live PushState off the forwarder host → serveBackfill/serveResume → reply (no reply on any
1594
- // failure; the stream aborts and the subscriber walks/chain-reads). One node-level replay guard is shared
1595
- // across all recover requests a plain pruned-on-access map, so no new timer to tear down.
1596
- registerRecoverHandler(node, reactivityProtocols.recover, {
1597
- pushStateFor: forwarderHost.pushStateFor.bind(forwarderHost),
1598
- pushStateForCollection: forwarderHost.pushStateForCollection.bind(forwarderHost),
1599
- replayGuard: createCorrelationReplayGuard(),
1600
- rotationFor: (req, now) => {
1601
- // Drain-window redirect: a recover reaching an OLD (rotated, still-draining) tail is bounced to
1602
- // the new tree (reactivity-rotation-recover-redirect-drain). A resume carries the stale topic
1603
- // (topicId = reactivityTopicId(latestKnownTailId)); a backfill carries no topic, so resolve the
1604
- // collection's current served topic. rotationRedirectFor returns the gate's redirect while
1605
- // draining and undefined once drained (then evicting the gate + the old tail's served PushState).
1606
- const oldTopicId = req.topicId ?? resolveCurrentServedTopic(forwarderHost, req.collectionId);
1607
- return oldTopicId === undefined ? undefined : forwarderHost.rotationRedirectFor(oldTopicId, now);
1608
- },
1609
- });
1610
-
1611
- // The subscriber's synchronous request signers over the node's Ed25519 key (resolves the recover wiring's
1612
- // lone design point — see recover-transport.ts §createRecoverRequestSigners). Fed to a manager by the
1613
- // subscribe factory alongside recover.backfillTransport(topicId, collectionId) /
1614
- // recover.resumeTransport(topicId, collectionId).
1615
- const recoverSigners = createRecoverRequestSigners(nodePrivateKey);
1616
-
1617
- // Expose the recover seams so the subscribe factory wires backfill/resume RPC + signers + the shared
1618
- // sticky cache (mirrors `reactivitySubscribers` above).
1619
- (node as any).reactivityRecover = recover;
1620
- (node as any).reactivityRecoverSigners = recoverSigners;
1621
- (node as any).reactivityCohortHintCache = reactivityCohortHintCache;
1622
-
1623
- // 6. Rotation re-registration scheduler — the host timer that moves a subscriber to the rotated tree
1624
- // when its manager surfaces a `RotationNotice` (`reactivity-rotation-rereg-scheduler`). Constructed with
1625
- // the default unref'd `setTimeout` timer so an idle re-registration never pins the process. The
1626
- // `reRegister(plan)` MOVE belongs to the subscribe factory that CONSTRUCTS managers (the deferred Quereus
1627
- // `Database.watch` bridge backlog optimystic-network-reactive-watch-integration-test): on fire it builds
1628
- // a fresh `ReactivitySubscriptionManager` under `plan.newTopicId` carrying `plan.lastRevision`, registers
1629
- // it, and swaps the `ReactivitySubscriberRegistry` entry registering the NEW-topic handler BEFORE
1630
- // unregistering the old, so a notification mid-swap is never dropped. Until that factory lands no
1631
- // node-internal manager drives `schedule()`, so this seam is a logged no-op — exactly as 12.33 exposed
1632
- // `reactivitySubscribers` / `reactivityRecover` without a live manager constructor.
1633
- reactivityRotation = new RotationReRegistrationScheduler({
1634
- reRegister: (plan): Promise<void> => {
1635
- reactivityWiringLog("reactivity rotation re-registration fired for successor topic=%s (lastRevision=%d) but no subscribe factory is wired yet — deferred to optimystic-network-reactive-watch-integration-test", bytesToB64url(plan.newTopicId), plan.lastRevision);
1636
- return Promise.resolve();
1637
- },
1638
- });
1639
- (node as any).reactivityRotation = reactivityRotation;
1640
-
1641
- // --- Matchmaking QueryV1 RPC — cohort serve side (docs/matchmaking.md §Seeker query) ---
1642
- // The server half of the seeker query transport: a remote seeker dials `/optimystic/matchmaking/1.0.0/query`
1643
- // and this node answers with its cohort's locally-held provider/seeker registrations, signed by the node
1644
- // peer key. Matchmaking is layered ABOVE the cohort-topic substrate, so it owns its own protocol family
1645
- // and is wired here (the composition root) over the host's PUBLIC surface only — mirroring the reactivity
1646
- // registration above; nothing reaches into host.ts internals. The OUTBOUND seeker walk client is the
1647
- // prereq follow-on `matchmaking-query-rpc-seeker-walk`; only the serve side is live here.
1648
- registerMatchmakingQueryHandler(node, matchmakingProtocols.query, {
1649
- registry: host.registry,
1650
- // Reuse the reactivity addressing: createTierAddressing(createRingHash()) is byte-identical to the
1651
- // host's internal addressing for the tier-0 coord (peer- and fanout-independent), and the handler
1652
- // only ever derives coord_0(topicId).
1653
- addressing: reactivityAddressing,
1654
- // Single-member reply signature over the node peer key (same pattern reactivity uses for its signers).
1655
- sign: async (payload: Uint8Array): Promise<string> => bytesToB64url(await signPeer(nodePrivateKey, payload)),
1656
- // Anti-DoS rate-limit seam (backlog matchmaking-query-rate-limit) intentionally left unwired here:
1657
- // default-allow. When that ticket lands it passes a `gate: (from, topicId) => boolean` that limits on
1658
- // the connection's verified `from` peer (NOT the self-asserted query.requesterId).
1659
- });
1660
- }
1661
-
1662
- return node as unknown as OptimysticNode;
1663
- } catch (err) {
1664
- // Post-start rollback. node.stop() runs whatever teardown wrappers were installed BEFORE the throw
1665
- // (each wrapper is registered next to the resource it releases, precisely so this unwinds as much as
1666
- // exists) and closes the transports. A rollback failure must never mask the real startup error, so it
1667
- // is logged and swallowed; `err` is what the caller sees.
1668
- try {
1669
- await node.stop();
1670
- } catch (stopErr) {
1671
- wiringLog('rollback stop failed after startup error: %o', stopErr);
1672
- }
1673
- throw err;
1674
- }
1675
- }
1
+ import { createLibp2p, type Libp2p } from 'libp2p';
2
+ import { noise } from '@chainsafe/libp2p-noise';
3
+ import { yamux } from '@chainsafe/libp2p-yamux';
4
+ import { identify, identifyPush } from '@libp2p/identify';
5
+ import { ping } from '@libp2p/ping';
6
+ import { dcutr } from '@libp2p/dcutr';
7
+ import { autoNAT } from '@libp2p/autonat';
8
+ import { gossipsub } from '@chainsafe/libp2p-gossipsub';
9
+ import { bootstrap } from '@libp2p/bootstrap';
10
+ import { circuitRelayServer, type CircuitRelayServerInit } from '@libp2p/circuit-relay-v2';
11
+ import { peerIdFromString } from '@libp2p/peer-id';
12
+ import { generateKeyPair } from '@libp2p/crypto/keys';
13
+ import type { ConnectionGater, PrivateKey } from '@libp2p/interface';
14
+ import { clusterService } from './cluster/service.js';
15
+ import { blockTransferService } from './cluster/block-transfer-service.js';
16
+ import { repoService } from './repo/service.js';
17
+ import { StorageRepo, withBlockCommitLatch } from './storage/storage-repo.js';
18
+ import { BlockStorage } from './storage/block-storage.js';
19
+ import { MemoryRawStorage } from './storage/memory-storage.js';
20
+ import type { IRawStorage } from './storage/i-raw-storage.js';
21
+ import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
22
+ import { clusterMember, type ReconcileBlockCallback, type CommitCertificateSink, type DeriveExpectedClusterCallback } from './cluster/cluster-repo.js';
23
+ import { createReconcileBlock } from './cluster/reconcile-block.js';
24
+ import { resolveClusterPolicy, type ClusterPolicyOptions } from './cluster/cluster-policy.js';
25
+ import { assertClusterSizeCoupling } from './cluster/cluster-size-coupling.js';
26
+ import { createCommitCertStore, makeClusterCommitCertExtractor, type CommitCertStore } from './cluster/commit-cert.js';
27
+ import { coordinatorRepo } from './repo/coordinator-repo.js';
28
+ import { Libp2pKeyPeerNetwork, type NetworkMode, type NetworkStatePersistence } from './libp2p-key-network.js';
29
+ import { mergePeerAddresses, publishableConnectionAddr, type AddressLog } from './peer-address-book.js';
30
+ import type { OptimysticNode, OptimysticNodeAttachments } from './optimystic-node.js';
31
+ import { ClusterClient } from './cluster/client.js';
32
+ import type { IRepo, ICluster, ITransactionValidator, BlockId, IBlockChangeNotifier } from '@optimystic/db-core';
33
+ import type { ITransactionStateStore } from './cluster/i-transaction-state-store.js';
34
+ import { networkManagerService, type NetworkManagerService } from './network/network-manager-service.js';
35
+ import type { SpreadOnChurnConfig, SpreadOnChurnMonitor } from './cluster/spread-on-churn.js';
36
+ import { BlockTransferCoordinator } from './cluster/block-transfer.js';
37
+ import type { RebalanceMonitorConfig } from './cluster/rebalance-monitor.js';
38
+ import { fretService, Libp2pFretService } from 'p2p-fret';
39
+ import { syncService } from './sync/service.js';
40
+ import { SyncClient } from './sync/client.js';
41
+ import type { SyncResponse } from './sync/protocol.js';
42
+ import type { ClusterLatestCallback } from './repo/coordinator-repo.js';
43
+ import { RestorationCoordinator } from './storage/restoration-coordinator.js';
44
+ import { RingSelector } from './storage/ring-selector.js';
45
+ import { RingShiftCoordinator } from './storage/ring-shift-coordinator.js';
46
+ import { StorageMonitor } from './storage/storage-monitor.js';
47
+ import type { StorageMonitorConfig } from './storage/storage-monitor.js';
48
+ import { ArachnodeFretAdapter } from './storage/arachnode-fret-adapter.js';
49
+ import type { RestoreCallback, BlockArchive } from './storage/struct.js';
50
+ import type { FretService } from 'p2p-fret';
51
+ import { createCohortTopicHost, type CohortTopicHostOptions } from './cohort-topic/host.js';
52
+ import { attachCohortChangeBridge } from './cohort-topic/change-bridge.js';
53
+ import { createReactivitySelfMembershipGate, reactivityTailBytes } from './cohort-topic/reactivity-membership-gate.js';
54
+ import { Libp2pReactivityNotifyTransport, registerNotifyHandler } from './reactivity/notify-transport.js';
55
+ import {
56
+ Libp2pReactivityRecoverTransport,
57
+ createLibp2pRecoverDialer,
58
+ registerRecoverHandler,
59
+ createRecoverRequestSigners,
60
+ } from './reactivity/recover-transport.js';
61
+ import { ReactivityForwarderHost, reactivityDirectSubscribers, reactivityNotificationTopicId } from './reactivity/forwarder-host.js';
62
+ import { ReactivityOriginationManager } from './reactivity/origination-manager.js';
63
+ import { ReactivityPushStateGossipDriver, registerPushStateGossipHandler, type ReactivityGossipCollection } from './reactivity/push-state-gossip.js';
64
+ import { RotationReRegistrationScheduler } from './reactivity/rotation-rereg-scheduler.js';
65
+ import { ReactivitySubscriberRegistry } from './reactivity/subscriber-registry.js';
66
+ import { DEFAULT_REACTIVITY_PROTOCOLS, reactivityProtocolList } from './reactivity/protocols.js';
67
+ import { registerMatchmakingQueryHandler } from './matchmaking/query-transport.js';
68
+ import { DEFAULT_MATCHMAKING_PROTOCOLS, matchmakingProtocolList } from './matchmaking/protocols.js';
69
+ import { signPeer } from './cohort-topic/peer-sig.js';
70
+ import {
71
+ createNotificationVerifier,
72
+ createCorrelationReplayGuard,
73
+ createStickyCohortHintCache,
74
+ reactivityNodePolicy,
75
+ createTierAddressing,
76
+ createRingHash,
77
+ Tier,
78
+ b64urlToBytes,
79
+ bytesToB64url,
80
+ type NotificationV1,
81
+ type CohortRef,
82
+ type PushStateGossipV1,
83
+ type PushStateInit,
84
+ type NotificationVerifier,
85
+ } from '@optimystic/db-core';
86
+ import { PartitionDetector } from './cluster/partition-detector.js';
87
+ import { assertSuperMajorityCoupling } from './cluster/supermajority-coupling.js';
88
+ import { createLogger } from './logger.js';
89
+ import { PeerReputationService } from './reputation/peer-reputation.js';
90
+ import type { IPeerReputation } from './reputation/types.js';
91
+ import type { AuthorizeInboundStream, InboundStreamAuthorizationInit } from './inbound-authorization.js';
92
+ import { DisputeService } from './dispute/dispute-service.js';
93
+ import { DisputeClient } from './dispute/client.js';
94
+ import { sampleArbitrators } from './dispute/arbitrator-selection.js';
95
+ import type { DisputeConfig } from './dispute/types.js';
96
+
97
+ type Libp2pInit = NonNullable<Parameters<typeof createLibp2p>[0]>;
98
+ export type Libp2pTransports = NonNullable<Libp2pInit['transports']>;
99
+
100
+ /** A service that accepts post-construction injection of the running libp2p node. */
101
+ interface SetLibp2pCapable {
102
+ setLibp2p(libp2p: Libp2p): void;
103
+ }
104
+
105
+ /** A service that accepts post-start injection of the peer-reputation view. */
106
+ interface SetReputationCapable {
107
+ setReputation(reputation: IPeerReputation): void;
108
+ }
109
+
110
+ /**
111
+ * The custom services that receive post-assembly dependency injection. Reaching them through this
112
+ * typed record (rather than `(node as any).services?.fret?.setLibp2p?.(...)`) removes the silent
113
+ * optional-chaining skips: a call like `wired.fret.setLibp2p(node)` is checked against these
114
+ * interfaces at build time (a caller-side typo or wrong-arity call fails tsc), and if the service is
115
+ * absent at runtime the property access throws (fail-fast) instead of being quietly no-op'd. Note the
116
+ * config-side `services` map is itself cast (see the comment at its declaration), so a service RENAME
117
+ * is caught here at runtime, not by tsc, and a signature change on the real service is caught only at
118
+ * that service's own definition. All three services are unconditionally present in that config, so a
119
+ * throw here is a genuine wiring bug, not a missing service.
120
+ */
121
+ type WiredServices = {
122
+ fret: SetLibp2pCapable;
123
+ networkManager: SetLibp2pCapable & SetReputationCapable;
124
+ repo: SetLibp2pCapable;
125
+ };
126
+
127
+ /** Logger for the reactivity node-wiring (origination/forwarder/recover/rotation composition). */
128
+ const reactivityWiringLog = createLogger('reactivity-node-wiring');
129
+
130
+ /**
131
+ * Logger for the best-effort in-factory service wiring. These injections run during `createLibp2p`
132
+ * internals against the unreliable `components.libp2p` proxy; the real node is re-injected
133
+ * post-construction (see the load-bearing block after `createLibp2p`), so a failure here is logged,
134
+ * not fatal.
135
+ */
136
+ const wiringLog = createLogger('node-wiring');
137
+
138
+ /** Factory function or instance for creating raw storage */
139
+ export type RawStorageProvider = IRawStorage | (() => IRawStorage);
140
+
141
+ /**
142
+ * `ClusterPolicyOptions` is intersected in, not restated: `resolveClusterPolicy` consumes those
143
+ * fields structurally, so a second copy of the shape here would let a newly added knob compile and
144
+ * be silently ignored. See `cluster/cluster-policy.ts` for what each one resolves to.
145
+ */
146
+ export type NodeOptions = ClusterPolicyOptions & {
147
+ /**
148
+ * Network port. Only used by the default `listenAddrs` fallback.
149
+ * For non-TCP transports (e.g. WebSockets), set `listenAddrs` explicitly.
150
+ */
151
+ port?: number;
152
+ /**
153
+ * WebSocket listen port. When set, the Node `createLibp2pNode` defaulting
154
+ * branch adds `webSockets()` to the transports and `/ip4/<wsHost>/tcp/<wsPort>/ws`
155
+ * to the listen addrs. Browsers and other WS-only peers (RN, web) can dial here.
156
+ * Ignored when `transports`/`listenAddrs` are explicitly provided.
157
+ */
158
+ wsPort?: number;
159
+ /** Interface to bind the WS listener to. Defaults to `0.0.0.0`. */
160
+ wsHost?: string;
161
+ /**
162
+ * Drop the default TCP transport and TCP listen addr. Useful for browser-only
163
+ * bootstraps that listen on `/ws` (typically fronted as `/wss`) only.
164
+ * Ignored when `transports`/`listenAddrs` are explicitly provided.
165
+ */
166
+ disableTcp?: boolean;
167
+ bootstrapNodes: string[];
168
+ networkName: string;
169
+ fretProfile?: 'edge' | 'core';
170
+ id?: string; // optional peer id
171
+ relay?: boolean; // enable relay service
172
+ /**
173
+ * Init passed to `circuitRelayServer(...)` when `relay` is enabled.
174
+ *
175
+ * `@libp2p/circuit-relay-v2` defaults to `applyDefaultLimit: true`, which
176
+ * stamps every reservation with `Limit { data: 128 KiB, duration: 2 min }`
177
+ * and resets the relayed stream once either cap is hit — silently killing
178
+ * long-lived service↔browser circuits. Trusted local clusters (e.g. the
179
+ * reference-peer service nodes) should pass
180
+ * `{ reservations: { applyDefaultLimit: false } }` to lift the cap.
181
+ */
182
+ relayServerInit?: CircuitRelayServerInit;
183
+ /** Storage provider - either an IRawStorage instance or a factory function. Defaults to MemoryRawStorage if not provided. */
184
+ storage?: RawStorageProvider;
185
+ /** Override libp2p listen multiaddrs. */
186
+ listenAddrs?: string[];
187
+ /**
188
+ * Multiaddrs to advertise INSTEAD OF the listen addrs. For a node behind a NAT / reverse proxy /
189
+ * DNS front that binds one address but is reachable at another. When non-empty these REPLACE the
190
+ * advertised set entirely — observed/relayed addresses and {@link NodeOptions.appendAnnounceAddrs}
191
+ * are all dropped from it. An empty array means "unset" (libp2p's own semantics).
192
+ */
193
+ announceAddrs?: string[];
194
+ /**
195
+ * Multiaddrs to advertise IN ADDITION TO the listen addrs. Ignored while
196
+ * {@link NodeOptions.announceAddrs} is non-empty.
197
+ */
198
+ appendAnnounceAddrs?: string[];
199
+ /** Override libp2p transports. */
200
+ transports?: Libp2pTransports;
201
+
202
+ /**
203
+ * Responsibility K - the replica set size for determining cluster membership.
204
+ * This is distinct from kBucketSize (DHT routing) and clusterSize (consensus quorum).
205
+ * On the repo path, a node checks whether it is in the top responsibilityK peers
206
+ * (by XOR distance) for the key and redirects to closer peers if not. On the cluster
207
+ * update path it is a small-mesh bypass threshold: when the record's peer set is
208
+ * smaller than this, the update is processed locally regardless of membership;
209
+ * otherwise a non-member redirects to the responsible peers.
210
+ * Default: 1 (only the closest/member peer is responsible)
211
+ */
212
+ responsibilityK?: number;
213
+
214
+ /** Arachnode storage configuration */
215
+ arachnode?: {
216
+ enableRingZulu?: boolean; // default: true
217
+ storage?: StorageMonitorConfig;
218
+ };
219
+
220
+ /**
221
+ * Churn-resilient spread protocol tuning. Absent -> enabled with defaults
222
+ * (see SpreadOnChurnConfig). Set { enabled: false } to disable spread on this node.
223
+ */
224
+ spreadOnChurn?: Partial<SpreadOnChurnConfig>;
225
+
226
+ /**
227
+ * Rebalance reaction tuning. Drives the RebalanceMonitor + BlockTransferCoordinator pull-gained/
228
+ * push-lost path when arachnode/FRET are available (the only place fretAdapter + restoration
229
+ * coordinator exist). Absent -> enabled with defaults (see RebalanceMonitorConfig). Set
230
+ * { enabled: false } to disable the rebalance reaction on this node. When arachnode is disabled
231
+ * or FRET is absent the rebalance path stays inert regardless of this flag (rebalance is a
232
+ * resilience optimization, not a correctness requirement).
233
+ */
234
+ rebalance?: Partial<RebalanceMonitorConfig> & { enabled?: boolean };
235
+
236
+ /** Transaction validator for cluster consensus */
237
+ validator?: ITransactionValidator;
238
+
239
+ /** Optional persistence for network state (HWM, FRET table) across restarts */
240
+ persistence?: NetworkStatePersistence;
241
+
242
+ /** Dispute protocol configuration */
243
+ dispute?: Partial<DisputeConfig>;
244
+
245
+ /** Optional persistent store for 2PC transaction state (enables crash recovery) */
246
+ transactionStateStore?: ITransactionStateStore;
247
+
248
+ /**
249
+ * Optional sink for the consensus commit certificate, fired per committed action just before the
250
+ * commit is applied to local storage (see {@link CommitCertificateSink}). This is the cluster-side
251
+ * half of the reactivity origination path: a caller wiring reactivity supplies a
252
+ * {@link CommitCertStore}'s `put` here, then resolves it via {@link makeClusterCommitCertExtractor}
253
+ * when it installs the change-notifier bridge ({@link attachCohortChangeBridge}) on the running
254
+ * node. Absent → zero cost (no cert is assembled).
255
+ */
256
+ onCommitCertificate?: CommitCertificateSink;
257
+
258
+ /**
259
+ * Opt-in cohort-topic substrate activation (reactivity / matchmaking origination). Default OFF →
260
+ * the node keeps today's bare `blockChangeNotifier = storageRepo` behavior at zero cohort cost (no
261
+ * host, no cert store; a caller-supplied {@link onCommitCertificate} is the only sink). When
262
+ * `enabled`, the node-base constructs the cohort-topic host post-assembly, builds a real FRET-backed
263
+ * `selfIsCohortMember` gate over `coord_0(H(tailId ‖ "reactivity"))`, and installs the change-notifier
264
+ * origination bridge — making reactivity origination live for ALL collections created on the node.
265
+ *
266
+ * A failure to construct the host (or a missing FRET service) **hard-fails** node startup: the
267
+ * operator opted in, so silently degrading to the bare notifier would hide misconfiguration.
268
+ */
269
+ cohortTopic?: {
270
+ /** Master switch. Absent/`false` → dormant, zero cost. */
271
+ enabled: boolean;
272
+ /**
273
+ * Requested cohort size; MUST match the host's `wantK` so the membership gate checks the same
274
+ * cohort the host serves. Default 16 (the host's default).
275
+ */
276
+ wantK?: number;
277
+ /** Optional pass-through host tuning (profile / minSigs / fanout / gossipIntervalMs / antiDos / promotion). */
278
+ host?: Omit<CohortTopicHostOptions, 'privateKey' | 'wantK'>;
279
+ };
280
+
281
+ /**
282
+ * Optional Ed25519 private key for this node. When provided, the libp2p
283
+ * node uses this identity instead of generating a fresh keypair. Use this
284
+ * to persist peer identity across process restarts.
285
+ *
286
+ * Accepts a libp2p `PrivateKey` (as returned by `generateKeyPair('Ed25519')`
287
+ * or `privateKeyFromProtobuf(...)` from `@libp2p/crypto/keys`).
288
+ */
289
+ privateKey?: PrivateKey;
290
+
291
+ /**
292
+ * Optional predicate deciding whether a remote peer may open one of the four Optimystic
293
+ * database protocols on this node (`repo`, `cluster`, `sync`, `block-transfer`). It is
294
+ * consulted once per inbound stream, before any frame is decoded or any operation executed.
295
+ *
296
+ * This is deliberately ONE node-level option threaded to all four services rather than four
297
+ * per-service options: "is this peer allowed to talk to my database?" is a property of the
298
+ * node, not of the protocol, and four independently-settable options make it easy to secure
299
+ * three surfaces and silently miss the fourth. (Each service still accepts the same option in
300
+ * its own init, so the services stay independently testable and usable outside this factory.)
301
+ *
302
+ * Absent → no check at all, and today's behavior exactly. Supplied → fail-closed: `false`, a
303
+ * throw, a rejection, or a timeout all deny and abort the stream. `remotePeerId` is the
304
+ * dialing peer's `PeerId.toString()`. See {@link AuthorizeInboundStream} and
305
+ * `docs/internals.md` § Inbound Stream Authorization.
306
+ *
307
+ * NOTE: this covers the four database protocols only. The dispute, reactivity, matchmaking,
308
+ * cohort-topic and libp2p built-in (identify/ping/…) protocols this node also registers are
309
+ * NOT gated by it. To refuse a peer at the connection level instead — every protocol at once,
310
+ * including identify — use {@link NodeOptions.connectionGater}.
311
+ */
312
+ authorizeInboundStream?: AuthorizeInboundStream;
313
+
314
+ /**
315
+ * Deadline for {@link NodeOptions.authorizeInboundStream}; expiry denies the stream (a hanging
316
+ * predicate would otherwise pin an inbound stream slot). Defaults to
317
+ * `DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS` (5s). Ignored when no predicate is supplied.
318
+ */
319
+ authorizeInboundStreamTimeoutMs?: number;
320
+
321
+ /**
322
+ * Optional libp2p connection gater. The libp2p browser default denies
323
+ * dialing insecure WebSockets and private/loopback addresses; callers
324
+ * that need to dial local or unsecured bootstraps (web reference dev,
325
+ * Playwright e2e, RN simulators) supply a permissive gater here.
326
+ */
327
+ connectionGater?: ConnectionGater;
328
+ };
329
+
330
+ function resolveStorage(provider: RawStorageProvider | undefined): IRawStorage {
331
+ if (!provider) {
332
+ return new MemoryRawStorage();
333
+ }
334
+ return typeof provider === 'function' ? provider() : provider;
335
+ }
336
+
337
+ /**
338
+ * Resolve the full FRET engine the cohort-topic host needs.
339
+ *
340
+ * `createCohortTopicHost` consumes the complete {@link FretService} engine surface — notably
341
+ * `setActivityHandler` (and `routeAct`, size estimation, …). The value at `node.services.fret` is the
342
+ * libp2p `Libp2pFretService` *wrapper*, which re-exports only a subset (`assembleCohort`, `routeAct`, …)
343
+ * and keeps the real engine private behind its lazy `ensure()` accessor. By the time activation runs the
344
+ * engine is already initialized — the wrapper's `Startable.start()` ran during `node.start()` — and the
345
+ * engine and wrapper share one underlying routing store, so the host and the membership gate observe the
346
+ * same cohort state. Returns the engine when reachable; otherwise the value as-is (a test may inject a
347
+ * raw engine that needs no unwrapping).
348
+ */
349
+ function resolveFretEngine(fret: FretService | undefined): FretService | undefined {
350
+ if (!fret) {
351
+ return undefined;
352
+ }
353
+ const candidate = fret as unknown as { ensure?: () => FretService };
354
+ return typeof candidate.ensure === 'function' ? candidate.ensure() : fret;
355
+ }
356
+
357
+ /**
358
+ * The raw topic id bytes of a collection's current served reactivity {@link PushState}, or `undefined` if the
359
+ * node serves none. The forwarder host keys its served map by topicId, but a **backfill** recover request
360
+ * carries only a collectionId — so the drain-redirect binding resolves the collection's current tail topic
361
+ * here (the highest-`lastRevision` served PushState) before consulting `rotationRedirectFor`. While the old
362
+ * tail is the only served state this resolves it (and its drain gate redirects); once the new tail is served
363
+ * this resolves the new tail (no gate → no redirect), exactly as the recover serve's backfill path intends.
364
+ */
365
+ function resolveCurrentServedTopic(forwarderHost: ReactivityForwarderHost, collectionId: string): Uint8Array | undefined {
366
+ const ps = forwarderHost.pushStateForCollection(collectionId);
367
+ return ps === undefined ? undefined : b64urlToBytes(ps.topicId);
368
+ }
369
+
370
+ export async function createLibp2pNodeBase(
371
+ options: NodeOptions,
372
+ defaults: {
373
+ listenAddrs: string[];
374
+ transports: Libp2pTransports;
375
+ }
376
+ ): Promise<OptimysticNode> {
377
+ const rawStorage = resolveStorage(options.storage);
378
+
379
+ // Create placeholder restore callback (will be replaced after node starts)
380
+ let restoreCallback: RestoreCallback = async (_blockId, _rev?) => {
381
+ return undefined;
382
+ };
383
+
384
+ // Create shared storage layers with restoration callback
385
+ const storageRepo = new StorageRepo((blockId) =>
386
+ new BlockStorage(blockId, rawStorage, restoreCallback)
387
+ );
388
+
389
+ // Per-block commit-latch runner, ready to thread into the invalidation-apply sink (`onInvalidate`)
390
+ // passed to `clusterMember(...)` and into each cascade `CollectionEnv`, the instant either is wired
391
+ // here. Sharing the `StorageRepo.commit:<blockId>` latch makes a compensating saveReplica/saveDeletion
392
+ // RMW of `meta.latest` mutually exclusive with a concurrent commit on the same block. It is unused
393
+ // today only because no `onInvalidate`/cascade driver is wired in the live node (see review handoff);
394
+ // it is bound here so that wiring is a one-liner and cannot reach for a divergent latch key.
395
+ const blockCommitLatch = withBlockCommitLatch;
396
+ void blockCommitLatch;
397
+
398
+ let clusterImpl: ICluster | undefined;
399
+ let coordinatedRepo: IRepo | undefined;
400
+ // The running node, bound immediately after `createLibp2p` below. Service factories that need
401
+ // the node at REQUEST time must close over this, never over `components.libp2p`: `components`
402
+ // is libp2p's Proxy, whose getter THROWS `MissingServiceError('libp2p not set')` for any key it
403
+ // does not hold — and `libp2p` is not a component. The throw happens on the property read, so
404
+ // neither `?.` nor a following `if (!libp2p) return` can catch it; it escapes as an application
405
+ // error on whatever request touched it. Same reason fret/networkManager/repo take the node via
406
+ // setLibp2p (see the injection block after `createLibp2p`).
407
+ let liveNode: Libp2p | undefined;
408
+
409
+ const clusterProxy: ICluster = {
410
+ async update(record) {
411
+ if (!clusterImpl) {
412
+ throw new Error('ClusterMember not initialized');
413
+ }
414
+ return await clusterImpl.update(record);
415
+ }
416
+ };
417
+
418
+ const repoProxy: IRepo = {
419
+ async get(blockGets, options) {
420
+ const target = coordinatedRepo ?? storageRepo;
421
+ return await target.get(blockGets, options);
422
+ },
423
+ async pend(request, options) {
424
+ const target = coordinatedRepo ?? storageRepo;
425
+ return await target.pend(request, options);
426
+ },
427
+ async cancel(trxRef, options) {
428
+ const target = coordinatedRepo ?? storageRepo;
429
+ return await target.cancel(trxRef, options);
430
+ },
431
+ async commit(request, options) {
432
+ const target = coordinatedRepo ?? storageRepo;
433
+ return await target.commit(request, options);
434
+ }
435
+ };
436
+
437
+ // The ONE authorization slice, spread verbatim into all four database-protocol service inits
438
+ // below. Building it once (rather than repeating two option reads per service) is what makes
439
+ // "secured three surfaces, missed the fourth" impossible: adding a fifth protocol service is a
440
+ // spread of this object, and dropping it from one is visible at the call site.
441
+ // Absent `authorizeInboundStream` → every service constructs its gate as `undefined` and the
442
+ // inbound path is byte-for-byte what it was before this option existed.
443
+ const inboundAuthorization: InboundStreamAuthorizationInit = {
444
+ ...(options.authorizeInboundStream ? { authorizeInboundStream: options.authorizeInboundStream } : {}),
445
+ ...(options.authorizeInboundStreamTimeoutMs !== undefined
446
+ ? { authorizeInboundStreamTimeoutMs: options.authorizeInboundStreamTimeoutMs }
447
+ : {})
448
+ };
449
+
450
+ const nodePrivateKey = options.privateKey ?? await generateKeyPair('Ed25519');
451
+
452
+ const listenAddrs = options.listenAddrs ?? defaults.listenAddrs;
453
+ const transports = options.transports ?? defaults.transports;
454
+
455
+ // --- cohort-topic substrate activation (opt-in; default off → today's bare behavior, zero cost) ---
456
+ const cohortEnabled = options.cohortTopic?.enabled === true;
457
+ // Resolve wantK ONCE so the post-assembly host serves and the membership gate checks the SAME cohort.
458
+ const cohortWantK = options.cohortTopic?.wantK ?? 16;
459
+ // When enabled, the cluster member records the consensus commit cert into this store synchronously,
460
+ // BEFORE `storageRepo.commit` emits the change event the bridge's extractor resolves it from (see
461
+ // cluster-repo.ts §applyConsensusOperation). Created early because the sink must be passed into
462
+ // `clusterMember(...)` below. Composed with any caller-supplied `onCommitCertificate` so both fire.
463
+ const certStore: CommitCertStore | undefined = cohortEnabled ? createCommitCertStore() : undefined;
464
+ const onCommitCertificate: CommitCertificateSink | undefined = certStore
465
+ // `certStore.put` runs FIRST so origination's cert capture cannot be defeated by a throwing caller
466
+ // sink: the whole composed call is isolated in `ClusterMember.captureCommitCert`, so a caller sink
467
+ // that threw before the store was written would make that commit silently never originate. Ordering
468
+ // the store first keeps origination correct regardless of the caller sink (`put` never throws).
469
+ ? (actionId, cert): void => { certStore.put(actionId, cert); options.onCommitCertificate?.(actionId, cert); }
470
+ : options.onCommitCertificate;
471
+
472
+ // Every cluster-policy default lives in `cluster/cluster-policy.ts` — including WHY the admission
473
+ // gate and the repair corroboration floor resolve the one operator field
474
+ // (`clusterPolicy.assumedClusterSize`) to different values when it is absent. Resolved ONCE, here,
475
+ // before anything that reads a cluster size is constructed: `networkManagerService` below,
476
+ // `Libp2pKeyPeerNetwork`, and the spread-on-churn monitor init must all read `consensusConfig.clusterSize`
477
+ // rather than `options.clusterSize` directly, or they can each apply their own fallback default and
478
+ // silently disagree (ticket bug-cluster-size-resolution-single-source). `assertClusterSizeCoupling`
479
+ // below is the fail-fast backstop if a future edit reintroduces that split.
480
+ const consensusConfig = resolveClusterPolicy(options);
481
+
482
+ const libp2pOptions: Libp2pInit = {
483
+ start: false,
484
+ privateKey: nodePrivateKey,
485
+ // NOTE: libp2p's `AddressManagerInit` also carries `noAnnounce` and `announceFilter`; neither is
486
+ // exposed on `NodeOptions`. Add them here the same way if a deployment ever needs to suppress a
487
+ // specific advertised address rather than replace the whole set.
488
+ addresses: {
489
+ listen: listenAddrs,
490
+ ...(options.announceAddrs ? { announce: options.announceAddrs } : {}),
491
+ ...(options.appendAnnounceAddrs ? { appendAnnounce: options.appendAnnounceAddrs } : {})
492
+ },
493
+ connectionManager: {
494
+ // `autoDial`, `minConnections`, and `dialQueue` were stale libp2p option keys silently
495
+ // ignored under the former `libp2pOptions as any` (removed with this change). This libp2p
496
+ // version has no such keys — auto-dial is now default connection-manager behavior with no
497
+ // direct replacement — so they are dropped rather than re-cast. See review handoff.
498
+ maxConnections: 16,
499
+ // Renamed from the stale `inboundConnectionUpgradeTimeout`. 10_000 equals this version's
500
+ // default, so surfacing (and correcting) the key is behavior-preserving; the old key was a no-op.
501
+ inboundUpgradeTimeout: 10_000
502
+ },
503
+ ...(options.connectionGater ? { connectionGater: options.connectionGater } : {}),
504
+ transports,
505
+ connectionEncrypters: [noise()],
506
+ streamMuxers: [yamux()],
507
+ // Narrow cast confined to the `services` field: the built-in factories (identify/dcutr/…) are
508
+ // typed against a SECOND copy of `@libp2p/interface` pulled in transitively (via `@libp2p/crypto`),
509
+ // whose `Uint8Array<ArrayBuffer>` vs `<ArrayBufferLike>` PeerId/key shapes are structurally
510
+ // incompatible with the top-level copy — a dependency-dedup artifact, not a real mismatch. The cast
511
+ // stays on this field alone so the rest of `libp2pOptions` remains fully typed as `Libp2pInit`.
512
+ // NOTE: this cast exists ONLY because of the duplicate @libp2p/interface install; if that dedups
513
+ // (or on a libp2p bump) drop `as unknown as NonNullable<Libp2pInit['services']>` and type the map directly.
514
+ services: ({
515
+ // `@libp2p/identify` is the ONE service here whose protocol id it builds itself:
516
+ // `Identify`/`IdentifyPush` both emit `/${protocolPrefix}/id[/push]/1.0.0`, always
517
+ // prepending the leading slash (its own default is the BARE `'ipfs'`). So this
518
+ // prefix must stay slash-LESS — passing `/optimystic/...` yields the malformed
519
+ // double-slash `//optimystic/<net>/id/1.0.0`. Every other service below
520
+ // (cluster/repo/sync/blockTransfer) concatenates its own template literal and so
521
+ // takes the slash-PREFIXED `protocolPrefix` form; do not unify the two.
522
+ // Locked by `identify-protocol-id.spec.ts`.
523
+ identify: identify({
524
+ protocolPrefix: `optimystic/${options.networkName}`
525
+ }),
526
+ // identify/push propagates *later* address/protocol changes (relay reservation,
527
+ // AutoNAT-learned observed addr, a service registered post-start) to already-connected
528
+ // peers. Without it those peers keep the stale snapshot from the initial identify.
529
+ // Two consequences, both now covered by tests rather than asserted here:
530
+ // - Addresses: a relay-only peer's reservation completes AFTER its first connection to
531
+ // the relay, so the circuit address is exactly the one identify cannot have carried.
532
+ // The relay's peerStore entry stays empty and a later dial by peer id alone fails
533
+ // with NoValidAddressesError against a reachable peer — `relay-address-propagation.spec.ts`
534
+ // (its gated control reproduces that failure with push removed).
535
+ // - Protocols: `membershipOf` in `libp2p-key-network.ts` classifies a peer serves/
536
+ // foreign/unknown purely from the peerStore protocol list, so a cluster/repo handler
537
+ // registered post-start never flips an already-connected peer to `serves` —
538
+ // `identify-push-propagation.spec.ts`.
539
+ identifyPush: identifyPush({
540
+ protocolPrefix: `optimystic/${options.networkName}`
541
+ }),
542
+ ping: ping(),
543
+ // DCUtR (hole-punch) upgrades relayed node↔node connections to direct
544
+ // ones; AutoNAT learns this node's public reachability via peer dial-back.
545
+ // Both are always-on and depend on `identify` above. They are inert where
546
+ // the transport can't hole-punch or dial back (e.g. browser/WS-only), which
547
+ // is acceptable — they neither throw nor break the build in that case.
548
+ dcutr: dcutr(),
549
+ autoNAT: autoNAT(),
550
+ pubsub: gossipsub({
551
+ allowPublishToZeroTopicPeers: true,
552
+ heartbeatInterval: 7000
553
+ }),
554
+ // Circuit relay server - enables this node to relay connections for other peers
555
+ ...(options.relay ? { relay: circuitRelayServer(options.relayServerInit) } : {}),
556
+
557
+ // Custom services - create wrapper factories that inject dependencies
558
+ cluster: (components: any) => {
559
+ const addressLog: AddressLog = createLogger('peer-address-book', components.peerId?.toString());
560
+ const serviceFactory = clusterService({
561
+ protocolPrefix: `/optimystic/${options.networkName}`,
562
+ responsibilityK: options.responsibilityK ?? 1,
563
+ ...inboundAuthorization
564
+ });
565
+ return serviceFactory({
566
+ logger: components.logger,
567
+ registrar: components.registrar,
568
+ cluster: clusterProxy,
569
+ // Identity for membership scoping on the update path. peerId is a core
570
+ // libp2p component, available at service-construction time.
571
+ peerId: components.peerId,
572
+ // Fallback addr resolver for redirect targets whose multiaddrs are not
573
+ // already embedded in record.peers. A redirect payload is handed to a THIRD
574
+ // party, so it obeys the same rule the cluster record does — see
575
+ // `publishableConnectionAddr`: only an outbound connection's remoteAddr is an
576
+ // address anyone else can reach.
577
+ getConnectionAddrs: (peerId: any) => {
578
+ const conns = liveNode?.getConnections?.(peerId) ?? [];
579
+ const addrs: string[] = [];
580
+ for (const c of conns) {
581
+ const addr = publishableConnectionAddr(c, addressLog);
582
+ if (addr !== undefined) addrs.push(addr);
583
+ }
584
+ return addrs;
585
+ },
586
+ // Inbound cluster records carry each cohort member's multiaddrs. libp2p only
587
+ // propagates addresses between directly-connected peers, so for a cohort chosen
588
+ // by key position this is often the ONLY way this node learns how to reach a
589
+ // relay-only sibling. Same late-binding shape as getConnectionAddrs above:
590
+ // `liveNode` resolves at request time, not at service construction.
591
+ recordPeerAddresses: (peerId: any, multiaddrs: string[]) => {
592
+ if (!liveNode) return;
593
+ mergePeerAddresses(liveNode, peerId, multiaddrs, addressLog);
594
+ }
595
+ });
596
+ },
597
+
598
+ repo: (components: any) => {
599
+ const serviceFactory = repoService({
600
+ protocolPrefix: `/optimystic/${options.networkName}`,
601
+ responsibilityK: options.responsibilityK ?? 1,
602
+ ...inboundAuthorization
603
+ });
604
+ // RepoService.checkRedirect needs the running node (network manager for the
605
+ // responsible-set computation, self id for the membership check, connection
606
+ // addrs for redirect targets). The libp2p components.libp2p proxy does NOT
607
+ // reliably resolve from inside a service at request time, so the node is
608
+ // injected explicitly post-construction via setLibp2p(node) below the same
609
+ // mechanism networkManager/fret use rather than forwarded here. checkRedirect
610
+ // keys the responsible set on the RAW encoded block id
611
+ // (getCluster(encode(blockKey)) → hashKey(encode(...))), matching the
612
+ // coordinator's findCluster(encode(blockId)) — same cohort, no spurious redirect.
613
+ return serviceFactory({
614
+ logger: components.logger,
615
+ registrar: components.registrar,
616
+ repo: repoProxy
617
+ });
618
+ },
619
+
620
+ sync: (components: any) => {
621
+ const serviceFactory = syncService({
622
+ protocolPrefix: `/optimystic/${options.networkName}`,
623
+ ...inboundAuthorization
624
+ });
625
+ return serviceFactory({
626
+ logger: components.logger,
627
+ registrar: components.registrar,
628
+ repo: repoProxy
629
+ });
630
+ },
631
+
632
+ // Block-transfer protocol handler for churn re-replication. Wired to the
633
+ // *local* storageRepo (not repoProxy): a pushed replica must land in this
634
+ // node's own storage, not be re-routed through the cluster-coordinated repo.
635
+ blockTransfer: (components: any) => {
636
+ const serviceFactory = blockTransferService({
637
+ protocolPrefix: `/optimystic/${options.networkName}`,
638
+ ...inboundAuthorization
639
+ });
640
+ return serviceFactory({
641
+ registrar: components.registrar,
642
+ repo: storageRepo,
643
+ // So this service's authorization denials reach the same error sink as the other three.
644
+ logger: components.logger
645
+ });
646
+ },
647
+
648
+ networkManager: (components: any) => {
649
+ const svcFactory = networkManagerService({
650
+ clusterSize: consensusConfig.clusterSize,
651
+ expectedRemotes: (options.bootstrapNodes?.length ?? 0) > 0,
652
+ allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
653
+ clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5
654
+ });
655
+ const svc = svcFactory(components);
656
+ // Best-effort proxy-time injection; the real node is re-injected post-construction below.
657
+ try { (svc as SetLibp2pCapable).setLibp2p(components.libp2p); }
658
+ catch (err) { wiringLog('networkManager in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err); }
659
+ return svc;
660
+ },
661
+ fret: (components: any) => {
662
+ const svcFactory = fretService({
663
+ k: 15,
664
+ m: 8,
665
+ capacity: 2048,
666
+ profile: options.fretProfile ?? ((options.bootstrapNodes?.length ?? 0) > 0 ? 'core' : 'edge'),
667
+ networkName: options.networkName,
668
+ bootstraps: options.bootstrapNodes ?? []
669
+ });
670
+ const svc = svcFactory(components) as Libp2pFretService;
671
+ // Best-effort proxy-time injection; the real node is re-injected post-construction below.
672
+ try { (svc as SetLibp2pCapable).setLibp2p(components.libp2p); }
673
+ catch (err) { wiringLog('fret in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err); }
674
+ return svc;
675
+ }
676
+
677
+ // [dispute-subsystem-dormant] The /optimystic/<network>/dispute/1.0.0 handler
678
+ // (disputeProtocolService / DisputeProtocolService) is intentionally NOT registered here.
679
+ // The subsystem is staged dormant pending arbitrator-set anchoring — without it, a peer
680
+ // minting throwaway keypairs can forge a synthetic super-majority and pass resolution.
681
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
682
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
683
+ }) as unknown as NonNullable<Libp2pInit['services']>,
684
+ // Add bootstrap nodes as needed
685
+ peerDiscovery: [
686
+ ...(options.bootstrapNodes?.length ? [bootstrap({ list: options.bootstrapNodes })] : [])
687
+ ],
688
+ };
689
+
690
+ const node = await createLibp2p(libp2pOptions);
691
+
692
+ // Bind the closure-captured node BEFORE start(): the cluster service's address-learning and
693
+ // redirect-addr resolvers read it on every inbound request, and the first one can arrive as
694
+ // soon as the protocol handler goes live in start().
695
+ liveNode = node;
696
+
697
+ // Inject the REAL libp2p node into the services that need it, before start(). These are
698
+ // load-bearing and the node has NOT started yet, so any throw fails fast and rejects node
699
+ // creation (nothing started leaks) — far better than the service silently falling back to the
700
+ // unreliable `components.libp2p` proxy and surfacing later as routing/consensus failures.
701
+ const wired = node.services as unknown as WiredServices;
702
+ wired.fret.setLibp2p(node);
703
+ wired.networkManager.setLibp2p(node);
704
+ // RepoService.checkRedirect resolves the network manager / self id / connection
705
+ // addrs through this injected node (the components.libp2p proxy is unreliable
706
+ // from inside a service at request time). Done before start() so the protocol
707
+ // handler is live with a resolvable node from its first request.
708
+ wired.repo.setLibp2p(node);
709
+
710
+ await node.start();
711
+
712
+ // Everything from here to the `return` runs against an ALREADY STARTED node (open transports,
713
+ // listening addresses, running services). A rejection out of that span used to hand the caller an
714
+ // error and no handle, leaving the node running with its listener port still bound — unrecoverable
715
+ // for the caller and enough to block the port for the next start attempt. So the whole post-start
716
+ // body rolls back: see the `catch` at the bottom of this function.
717
+ try {
718
+
719
+ // Initialize peer reputation service
720
+ const reputation = new PeerReputationService();
721
+
722
+ // Initialize cluster coordination components
723
+ const networkMode: NetworkMode = (options.bootstrapNodes?.length ?? 0) > 0 ? 'joining' : 'forming';
724
+ // Network-namespaced protocol prefix, threaded into the key network so coordinator/
725
+ // cohort selection is scoped to peers that serve THIS network's cluster/repo protocol.
726
+ // A peer that only belongs to another network sharing the same physical nodes/
727
+ // bootstraps registers a different (network-namespaced) identify protocol, so it is
728
+ // never selected and can't drag this network's super-majority below quorum.
729
+ const protocolPrefix = `/optimystic/${options.networkName}`;
730
+ const keyNetwork = new Libp2pKeyPeerNetwork(node, consensusConfig.clusterSize, undefined, networkMode, options.persistence, reputation, protocolPrefix);
731
+ await keyNetwork.initFromPersistedState();
732
+ const createClusterClient = (peerId: any) => ClusterClient.create(peerId, keyNetwork, protocolPrefix);
733
+
734
+ // Inject reputation into NetworkManagerService. Load-bearing and non-optional: the service is
735
+ // unconditionally present, so a throw is a real wiring bug. The node has already started here, but
736
+ // no ad-hoc stop is needed: the post-start rollback `catch` at the bottom of this function stops it.
737
+ wired.networkManager.setReputation(reputation);
738
+
739
+ // Create partition detector and get FRET service
740
+ const partitionDetector = new PartitionDetector();
741
+ const fretSvc = (node as any).services?.fret as FretService | undefined;
742
+
743
+ // Fetch a block archive from one cohort peer over the sync protocol, bounded by a
744
+ // per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
745
+ // SyncClient query in `clusterLatestCallback`, but returns the full archive (which
746
+ // carries the materialized block) rather than only the latest ActionRev.
747
+ const fetchArchiveFromPeer = async (peerIdStr: string, blockId: BlockId): Promise<BlockArchive | undefined> => {
748
+ let peerId: ReturnType<typeof peerIdFromString>;
749
+ try {
750
+ peerId = peerIdFromString(peerIdStr);
751
+ } catch {
752
+ return undefined;
753
+ }
754
+ if (peerId.equals(node.peerId)) return undefined;
755
+ const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
756
+ try {
757
+ const response = await Promise.race<SyncResponse>([
758
+ syncClient.requestBlock({ blockId, rev: undefined }),
759
+ new Promise<SyncResponse>(resolve => { setTimeout(() => resolve({ success: false }), 1000).unref(); })
760
+ ]);
761
+ return response.success ? response.archive : undefined;
762
+ } catch {
763
+ // Peer unreachable / no data — caller falls back to the next cohort peer.
764
+ return undefined;
765
+ }
766
+ };
767
+
768
+ // Active reconciliation for a block this member committed without a materializable base
769
+ // (cohort drift, or a refused `missing-base-revision` commit). See `reconcile-block.ts` for
770
+ // the corroboration rules in particular why both quorums are capped by how many peers
771
+ // could answer at all, which is what lets a genuinely two-node cohort heal.
772
+ // NOTE: this and the CoordinatorRepo below must cap against the SAME
773
+ // repairCorroborationClusterSize, or the two restoration paths disagree about how much trust a
774
+ // lone peer gets. Safe today because both read the one `resolveClusterPolicy` result above; if
775
+ // either ever resolves its own value, add a fail-fast coupling check like
776
+ // `assertSuperMajorityCoupling` rather than relying on proximity.
777
+ const reconcileBlock: ReconcileBlockCallback = createReconcileBlock({
778
+ selfPeerId: node.peerId.toString(),
779
+ fetchArchive: fetchArchiveFromPeer,
780
+ saveReplicatedBlock: (blockId, block, source) => storageRepo.saveReplicatedBlock(blockId, block, source),
781
+ simpleMajorityThreshold: consensusConfig.simpleMajorityThreshold,
782
+ repairCorroborationClusterSize: consensusConfig.repairCorroborationClusterSize,
783
+ reputation
784
+ });
785
+
786
+ // Member-side membership derivation for the admission gate: independently re-derive this block's
787
+ // responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
788
+ // network-size confidence. A member gates a coordinator-declared peer set against this view before
789
+ // voting, so a self-shrunk minority-partition set cannot be voted into super-majority (see cluster-repo
790
+ // admitMembership). No FRET ⇒ confidence 0 ⇒ the gate fails closed for any downsize.
791
+ const deriveExpectedCluster: DeriveExpectedClusterCallback = async (blockId) => {
792
+ const peers = await keyNetwork.findCluster(new TextEncoder().encode(blockId));
793
+ let confidence = 0;
794
+ if (fretSvc) {
795
+ try {
796
+ confidence = fretSvc.getNetworkSizeEstimate().confidence;
797
+ } catch {
798
+ // Leave confidence 0 fail closed for downsizing.
799
+ }
800
+ }
801
+ return { peers: peers ?? {}, confidence };
802
+ };
803
+
804
+ clusterImpl = clusterMember({
805
+ storageRepo,
806
+ peerNetwork: keyNetwork,
807
+ peerId: node.peerId,
808
+ privateKey: nodePrivateKey,
809
+ protocolPrefix,
810
+ partitionDetector,
811
+ fretService: fretSvc,
812
+ validator: options.validator,
813
+ reputation,
814
+ consensusConfig,
815
+ stateStore: options.transactionStateStore,
816
+ reconcileBlock,
817
+ onCommitCertificate,
818
+ deriveExpectedCluster
819
+ // `recomputeArbitratorSet` (invalidation layer-2) is intentionally NOT wired here yet: a live FRET
820
+ // recompute needs a churn-tolerance window so it does not false-reject legitimate certificates from
821
+ // late-joiners (a liveness regression). Until that is tuned against live topology — and the
822
+ // cohort-topic membership-cert trust anchor (layer 3) lands — invalidation verification runs on the
823
+ // challenger-bound set + membership + dedup (layer 1) and LOGS the residual anchoring gap. See
824
+ // `verifyInvalidationCertificate` and `tickets/plan/cohort-topic-membership-cert-trust-anchoring.md`.
825
+ });
826
+
827
+ // Cleanup cluster member intervals on node stop. Installed HERE, immediately after clusterImpl
828
+ // exists, rather than further down: the post-start rollback only unwinds resources whose stop
829
+ // wrapper is already installed at the moment of the throw, so a wrapper trailing its resource by
830
+ // hundreds of lines leaves those intervals running on a failed startup. Same reasoning as the
831
+ // owned-block-feed wrapper below.
832
+ {
833
+ const previousStop = node.stop.bind(node);
834
+ node.stop = async () => {
835
+ try {
836
+ (clusterImpl as import('./cluster/cluster-repo.js').ClusterMember).dispose();
837
+ } finally {
838
+ // Never let a dispose failure strand the transports — same try/finally shape every
839
+ // other wrapper in this chain uses.
840
+ await previousStop();
841
+ }
842
+ };
843
+ }
844
+
845
+ const coordinatorRepoFactory = coordinatorRepo(
846
+ keyNetwork,
847
+ createClusterClient,
848
+ {
849
+ // clusterSize is now part of consensusConfig (member + coordinator share one reference).
850
+ ...consensusConfig
851
+ },
852
+ fretSvc,
853
+ reputation,
854
+ options.transactionStateStore
855
+ );
856
+
857
+ // Create callback for querying cluster peers for their latest block revision. Three-way
858
+ // contract (see ClusterLatestCallback): an ActionRev is the peer's claim, a resolved
859
+ // `undefined` is the peer answering "I hold nothing", and a REJECTION is silence the
860
+ // coordinator counts it as "did not answer" and refuses to report an authoritative absent
861
+ // over it. Transport errors must therefore propagate, not collapse into `undefined` (that
862
+ // collapse let a slow two-node cohort report a missing block as authoritatively absent —
863
+ // ticket cluster-read-consult-cannot-report-unreachable).
864
+ const clusterLatestCallback: ClusterLatestCallback = async (peerId, blockId, context?) => {
865
+ // Self-read short-circuit: dialling self via SyncClient is a round trip
866
+ // with no remote on the other end, and on nodes without listen addresses
867
+ // (solo WebSocket-only, bare-RN, etc.) the self-dial can hang the dial
868
+ // queue. Read directly from the local storage repo instead. The catch stays:
869
+ // a local storage error is not a cohort peer being unreachable, and the
870
+ // coordinator ignores a self rejection anyway.
871
+ if (peerId.equals(node.peerId)) {
872
+ try {
873
+ const result = await storageRepo.get({ blockIds: [blockId], context });
874
+ return result[blockId]?.state?.latest;
875
+ } catch {
876
+ return undefined;
877
+ }
878
+ }
879
+ const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
880
+ // No try/catch: a dial or protocol failure rejects through to the coordinator, whose
881
+ // per-peer deadline also bounds a hung request — slowness needs no race here.
882
+ const response = await syncClient.requestBlock({ blockId, rev: undefined });
883
+ if (response.success && response.archive) {
884
+ const revisions = Object.keys(response.archive.revisions).map(Number);
885
+ if (revisions.length > 0) {
886
+ const maxRev = Math.max(...revisions);
887
+ const revisionData = response.archive.revisions[maxRev];
888
+ if (revisionData?.action) {
889
+ return { actionId: revisionData.action.actionId, rev: maxRev };
890
+ }
891
+ }
892
+ }
893
+ // The peer DID answer, without data: `success:false` is the sync service's "Block not
894
+ // found in local storage", and an archive with no usable revisions holds nothing either
895
+ // way. Both are absent claims, not silence.
896
+ return undefined;
897
+ };
898
+
899
+ coordinatedRepo = coordinatorRepoFactory({
900
+ storageRepo,
901
+ localCluster: clusterImpl,
902
+ localPeerId: node.peerId,
903
+ clusterLatestCallback,
904
+ // Read-driven acquisition shares the commit path's reconcile callback verbatim: same bounded
905
+ // archive fetch, same (rev, actionId) and content quorums, same monotonic saveReplicatedBlock
906
+ // funnel. `clusterLatestCallback` alone can only tell the reader WHICH revision the cohort
907
+ // holds; this is what moves the bytes. Only reached once a corroborated revision exists, so a
908
+ // genuinely absent block still costs no archive fetch.
909
+ acquireBlockFromCohort: reconcileBlock
910
+ });
911
+
912
+ // Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
913
+ // coordinator (what declares a transaction committed on that super-majority) MUST run the same
914
+ // threshold, or the node would come up able to disagree with itself mid-consensus. Both are fed from
915
+ // the single `consensusConfig` above; this asserts on their RESOLVED values so any future drift throws
916
+ // HERE at construction. See `assertSuperMajorityCoupling`.
917
+ assertSuperMajorityCoupling(
918
+ clusterImpl as import('./cluster/cluster-repo.js').ClusterMember,
919
+ coordinatedRepo as import('./repo/coordinator-repo.js').CoordinatorRepo
920
+ );
921
+
922
+ // Recover persisted transaction state before accepting new requests
923
+ if (options.transactionStateStore) {
924
+ await (clusterImpl as import('./cluster/cluster-repo.js').ClusterMember).recoverTransactions();
925
+ await (coordinatedRepo as import('./repo/coordinator-repo.js').CoordinatorRepo).recoverTransactions();
926
+ }
927
+
928
+ // --- Shared owned-block set for the resilience monitors ---
929
+ // SpreadOnChurnMonitor (sender) and RebalanceMonitor (responsibility tracker) both act on "the
930
+ // blocks this node physically holds". They share ONE Set so the two can never drift: a single
931
+ // owned-block feed populates it, and the rebalance responsibility-loss signal evicts from it
932
+ // (in the rebalance block below). Both monitors take this exact instance via deps.trackedBlocks.
933
+ const networkManager = (node as any).services?.networkManager as NetworkManagerService | undefined;
934
+
935
+ // See the comment above `consensusConfig` for why every cluster-size consumer must read the SAME
936
+ // resolved value. This throws at construction (rather than letting a node come up mismatched) if a
937
+ // future edit gives `keyNetwork` or `networkManager` their own fallback again.
938
+ assertClusterSizeCoupling(consensusConfig.clusterSize, { keyNetwork, networkManager });
939
+
940
+ const ownedBlocks = new Set<string>();
941
+ // Single owned-block feed: every block this node commits OR receives as a replica fires
942
+ // storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
943
+ // node.blockChangeNotifier): the cohort-topic activation block below may replace
944
+ // blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its own
945
+ // surface regardless of that opt-in. NOTE: this feed does NOT re-emit blocks already durable
946
+ // from a previous run; those are seeded once at startup by the storage-enumeration scan wired
947
+ // below (seedOwnedBlocksFromStorage), so a restarted node protects on-disk data without waiting
948
+ // for each block to be touched again. Registered lazily the first time a
949
+ // monitor that reads ownedBlocks is wired, so when BOTH monitors are disabled no subscription
950
+ // leaks; torn down exactly once in the stop wrapper below.
951
+ let offOwnedBlockFeed: (() => void) | undefined;
952
+ const ensureOwnedBlockFeed = (): void => {
953
+ if (offOwnedBlockFeed) return;
954
+ offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
955
+ for (const blockId of e.blockIds) ownedBlocks.add(blockId);
956
+ });
957
+ };
958
+ // Single owned-block-feed teardown. Registered up front (before either monitor's own stop
959
+ // wrapper) so it runs regardless of WHICH monitor subscribed the feed - including the
960
+ // spread-disabled / rebalance-only case. Idempotent: offOwnedBlockFeed is undefined-guarded.
961
+ {
962
+ const previousStop = node.stop.bind(node);
963
+ node.stop = async () => {
964
+ try {
965
+ offOwnedBlockFeed?.();
966
+ } finally {
967
+ await previousStop();
968
+ }
969
+ };
970
+ }
971
+
972
+ // --- Churn-resilient spread: drive SpreadOnChurnMonitor on a live node ---
973
+ // Nothing previously activated the SENDING side of the churn-resilient spread protocol on a
974
+ // real node. Here we init + start the monitor (sharing ownedBlocks) and ensure the single
975
+ // owned-block feed is live, so a debounced connection:close re-pushes the node's blocks to
976
+ // expansion-cohort peers (the receiver durably persists each push via saveReplicatedBlock).
977
+ let spreadMonitor: SpreadOnChurnMonitor | undefined;
978
+ if (networkManager && (options.spreadOnChurn?.enabled ?? true) !== false) {
979
+ try {
980
+ spreadMonitor = networkManager.initSpreadOnChurnMonitor(
981
+ partitionDetector,
982
+ storageRepo,
983
+ keyNetwork,
984
+ consensusConfig.clusterSize,
985
+ protocolPrefix,
986
+ ownedBlocks,
987
+ options.spreadOnChurn,
988
+ );
989
+ await spreadMonitor.start();
990
+ ensureOwnedBlockFeed();
991
+ } catch (err) {
992
+ // Spread is a resilience optimization, not a correctness requirement - a wiring
993
+ // failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup, unlike the
994
+ // operator-opted-in cohortTopic block. Log and continue with spread inert.
995
+ ((node as any).logger?.forComponent?.('db-p2p:spread-on-churn'))?.('init failed: %o', err);
996
+ }
997
+ }
998
+
999
+ // Expose for tests/diagnostics (mirrors node.keyNetwork / node.reputation).
1000
+ (node as any).spreadOnChurnMonitor = spreadMonitor;
1001
+
1002
+ // Disposal: stop the spread monitor deterministically before the transports close. Composes
1003
+ // with the arachnode / clusterMember / cohort-topic stop wrappers (each calls its captured
1004
+ // previousStop last). Idempotent (SpreadOnChurnMonitor.stop early-returns when not running), so
1005
+ // a double node.stop() does not throw. The owned-block feed teardown is the separate up-front
1006
+ // wrapper above (shared across both monitors).
1007
+ {
1008
+ const previousStop = node.stop.bind(node);
1009
+ node.stop = async () => {
1010
+ try {
1011
+ if (spreadMonitor) await spreadMonitor.stop();
1012
+ } finally {
1013
+ await previousStop();
1014
+ }
1015
+ };
1016
+ }
1017
+
1018
+ // Initialize Arachnode ring membership and restoration
1019
+ const enableArachnode = options.arachnode?.enableRingZulu ?? true;
1020
+ if (enableArachnode) {
1021
+ const log = (node as any).logger?.forComponent?.('db-p2p:arachnode');
1022
+ const fret = (node as any).services?.fret as any;
1023
+
1024
+ if (fret) {
1025
+ const fretAdapter = new ArachnodeFretAdapter(fret, node.peerId.toString());
1026
+
1027
+ // Blocks whose shed range has been RELEASED (Phase C of a ring shift, or a confirmed
1028
+ // rebalance release). This is the GC-eligibility signal the future storage sweep
1029
+ // (`st-storage-sweep-archival-and-capacity-estimate`) must consult: a block's local bytes may
1030
+ // be reclaimed ONLY once it appears here, so an unconfirmed / still-served range is never
1031
+ // swept. Populated strictly after replication is confirmed. See
1032
+ // docs/arachnode-ring-handoff.md § Part 2 (Local bytes vs. tracking).
1033
+ // NOTE: no sweep consumes this set yet; it is the coordinated eligibility handoff the sweep
1034
+ // ticket will read. Until then it grows unbounded — bound it when the sweep lands.
1035
+ const gcEligible = new Set<string>();
1036
+ (node as any).gcEligibleBlocks = gcEligible;
1037
+
1038
+ // The ring-shift state machine (advertise→confirm→release). Wired inside the rebalance block
1039
+ // below (it needs the BlockTransferCoordinator confirmer + the cohort-size floor); left
1040
+ // undefined when the rebalance reaction is not wired, in which case ring shifts stay inert —
1041
+ // a move-out is unsafe without the confirm/release path.
1042
+ let ringShift: RingShiftCoordinator | undefined;
1043
+
1044
+ const storageMonitor = new StorageMonitor(rawStorage, options.arachnode?.storage ?? {});
1045
+ const ringSelector = new RingSelector(fretAdapter, storageMonitor, {
1046
+ minCapacity: 100 * 1024 * 1024,
1047
+ thresholds: {
1048
+ moveOut: 0.85,
1049
+ moveIn: 0.40
1050
+ },
1051
+ // Damping so the ring decision cannot thrash near a boundary
1052
+ // (docs/arachnode-ring-handoff.md § Part 1).
1053
+ smoothingAlpha: 0.2,
1054
+ deadband: 0.5,
1055
+ minDwellMs: 10 * 60 * 1000
1056
+ });
1057
+
1058
+ // Determine and announce ring membership
1059
+ const peerId = node.peerId.toString();
1060
+ const arachnodeInfo = await ringSelector.createArachnodeInfo(peerId);
1061
+ fretAdapter.setArachnodeInfo(arachnodeInfo);
1062
+
1063
+ log?.('Announced Arachnode membership: Ring %d', arachnodeInfo.ringDepth);
1064
+
1065
+ // Setup restoration coordinator with FRET adapter
1066
+ const restorationCoordinatorV2 = new RestorationCoordinator(
1067
+ fretAdapter,
1068
+ { connect: (pid, protocol) => node.dialProtocol(pid as Parameters<typeof node.dialProtocol>[0], [protocol]) },
1069
+ `/optimystic/${options.networkName}`,
1070
+ node.peerId.toString()
1071
+ );
1072
+
1073
+ // Update restore callback to use new coordinator
1074
+ const newRestoreCallback: RestoreCallback = async (blockId, rev?) => {
1075
+ return await restorationCoordinatorV2.restore(blockId, rev);
1076
+ };
1077
+
1078
+ // Replace the restore callback (this is a bit hacky, but works for now)
1079
+ (storageRepo as any).createBlockStorage = (blockId: string) =>
1080
+ new BlockStorage(blockId, rawStorage, newRestoreCallback);
1081
+
1082
+ // --- Rebalance reaction: drive RebalanceMonitor + react via BlockTransferCoordinator ---
1083
+ // Nothing previously activated the rebalance path on a real node: initRebalanceMonitor was
1084
+ // never called, the monitor was never start()ed, and BlockTransferCoordinator (the
1085
+ // pull-gained / push-lost reaction primitive) was never constructed in src. This block lives
1086
+ // inside the arachnode `if (fret)` gate because both dependencies only exist here — the
1087
+ // fretAdapter and the RestorationCoordinator. When arachnode is disabled or FRET is absent the
1088
+ // rebalance path stays inert (acceptable: rebalance is a resilience optimization). A wiring
1089
+ // failure here is non-fatal (log + continue), unlike the operator-opted-in cohortTopic block.
1090
+ if (networkManager && (options.rebalance?.enabled ?? true) !== false) {
1091
+ try {
1092
+ // repo the LOCAL storageRepo (not repoProxy/coordinatedRepo): a pulled/pushed replica
1093
+ // must land in / be read from this node's own storage, same reasoning as the
1094
+ // blockTransfer service handler registration. protocolPrefix (/optimystic/<networkName>)
1095
+ // MUST match the prefix the node registers its block-transfer handler under, or every
1096
+ // lost-block push dials the wrong protocol and fails to connect.
1097
+ const coordinator = new BlockTransferCoordinator(
1098
+ storageRepo,
1099
+ keyNetwork,
1100
+ restorationCoordinatorV2,
1101
+ partitionDetector,
1102
+ protocolPrefix,
1103
+ );
1104
+
1105
+ const rebalanceMonitor = networkManager.initRebalanceMonitor(
1106
+ partitionDetector,
1107
+ fretAdapter,
1108
+ ownedBlocks,
1109
+ options.rebalance,
1110
+ );
1111
+ await rebalanceMonitor.start();
1112
+
1113
+ // onRebalance fires synchronously from the monitor's debounced check; the coordinator's
1114
+ // reaction (pull gained / push lost, each partition-guarded) is async, so hop it off the
1115
+ // handler rather than blocking the monitor's emit loop. handleRebalanceEvent can REJECT
1116
+ // (e.g. RestorationCoordinator.restore() throws while pulling a gained block) and a bare
1117
+ // `void` would surface that as an unhandled rejection (process-fatal on Node >=15); the
1118
+ // reaction is a resilience optimization, so swallow + log instead.
1119
+ //
1120
+ // ALONGSIDE dispatching to the coordinator, drive the shared owned-block set off this
1121
+ // authoritative responsibility signal. A GAINED block is added immediately so it is
1122
+ // tracked even before its next commit/replica touches the feed.
1123
+ //
1124
+ // A LOST block is NO LONGER released synchronously: doing so stopped spreading a block
1125
+ // whose push to the new owners might fail, drop it below the replication floor, and let a
1126
+ // later sweep reclaim it (docs/arachnode-ring-handoff.md § Why the current code violates
1127
+ // it #2). Instead the release is GATED on confirmation the coordinator returns the lost
1128
+ // blocks it confirmed replicated to ≥ floor new owners, and ONLY those are untracked
1129
+ // (authoritative eviction from the shared set — complements spread's lazy self-prune) and
1130
+ // marked GC-eligible. A lost block whose push failed / was partition-skipped stays
1131
+ // tracked and served, and is retried on the next rebalance.
1132
+ //
1133
+ // Best-effort iteration safety: this eviction can mutate ownedBlocks while
1134
+ // SpreadOnChurnMonitor (or this monitor) is mid for...of over the same Set inside an
1135
+ // async loop. Adding/deleting a Set entry during iteration does not throw in JS — entries
1136
+ // are visited best-effort — which is acceptable for a resilience mechanism, so we
1137
+ // document it here rather than add locking.
1138
+ rebalanceMonitor.onRebalance((event) => {
1139
+ for (const blockId of event.gained) ownedBlocks.add(blockId);
1140
+ coordinator.handleRebalanceEvent(event).then((result) => {
1141
+ for (const blockId of result.released) {
1142
+ rebalanceMonitor.untrackBlock(blockId); // also evicts from the shared ownedBlocks set
1143
+ gcEligible.add(blockId); // confirmed replicated safe to sweep
1144
+ }
1145
+ }).catch((err) => {
1146
+ log?.('rebalance reaction failed: %o', err);
1147
+ });
1148
+ });
1149
+
1150
+ // Ring-shift handoff (advertise→confirm→release). It needs the confirmer (this
1151
+ // coordinator) and the cohort-size floor (this monitor), so it is wired here. The
1152
+ // `onRelease` callback runs Phase C's local effect: stop serving/spreading the shed
1153
+ // range and mark it GC-eligible — the same authoritative eviction the confirmed-rebalance
1154
+ // release performs.
1155
+ ringShift = new RingShiftCoordinator({
1156
+ fretAdapter,
1157
+ ringSelector,
1158
+ fret,
1159
+ partitionDetector,
1160
+ confirmer: coordinator,
1161
+ ownedBlocks,
1162
+ selfPeerId: peerId,
1163
+ getFloor: () => rebalanceMonitor.getCohortSize(),
1164
+ onRelease: (blockIds) => {
1165
+ for (const blockId of blockIds) {
1166
+ rebalanceMonitor.untrackBlock(blockId);
1167
+ gcEligible.add(blockId);
1168
+ }
1169
+ }
1170
+ });
1171
+ // Reconcile any stale `moving` advertisement left by a crash mid-handoff (no-op unless
1172
+ // arachnode metadata survived a restart still marked `moving`).
1173
+ ringShift.reconcileOnStart();
1174
+
1175
+ // Feed owned blocks via the SINGLE shared feed (idempotent — already live if the spread
1176
+ // block above wired it). Both monitors read the same ownedBlocks set this populates.
1177
+ ensureOwnedBlockFeed();
1178
+
1179
+ // Expose for tests/diagnostics (mirrors node.spreadOnChurnMonitor).
1180
+ (node as any).rebalanceMonitor = rebalanceMonitor;
1181
+ (node as any).blockTransferCoordinator = coordinator;
1182
+ (node as any).ringShiftCoordinator = ringShift;
1183
+
1184
+ // Disposal: stop the monitor before transports close. Composes with the other stop
1185
+ // wrappers (each calls its captured previousStop last). Idempotent — RebalanceMonitor.stop()
1186
+ // early-returns when not running (NetworkManagerService.stop() also stops it). The shared
1187
+ // owned-block feed teardown is the separate up-front wrapper (not duplicated here).
1188
+ const previousStop = node.stop.bind(node);
1189
+ node.stop = async () => {
1190
+ try {
1191
+ await rebalanceMonitor.stop();
1192
+ } finally {
1193
+ await previousStop();
1194
+ }
1195
+ };
1196
+ } catch (err) {
1197
+ // Rebalance is a resilience optimization, not a correctness requirement - a wiring
1198
+ // failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup.
1199
+ log?.('rebalance wiring init failed: %o', err);
1200
+ }
1201
+ }
1202
+
1203
+ // Monitor capacity and adjust ring periodically. The damped `shouldTransition()` decides
1204
+ // WHETHER/where to move (docs/arachnode-ring-handoff.md § Part 1); the RingShiftCoordinator
1205
+ // carries the move out through the advertise→confirm→release handoff (§ Part 2) so a shift
1206
+ // never drops a key below its replication floor. The old unilateral `setArachnodeInfo` flip
1207
+ // which changed advertised responsibility instantly with no data handoff is gone.
1208
+ //
1209
+ // Ring shifts run ONLY when `ringShift` is wired (i.e. the rebalance reaction is enabled): a
1210
+ // move-out is unsafe without the confirm/release path, so a node with the rebalance reaction
1211
+ // disabled stays at its bootstrap ring rather than flipping unsafely.
1212
+ const monitorInterval = setInterval(async () => {
1213
+ if (!ringShift) return;
1214
+ const transition = await ringSelector.shouldTransition();
1215
+ if (transition.shouldMove && transition.direction && transition.newRingDepth !== undefined) {
1216
+ log?.('Ring transition needed: moving %s to Ring %d', transition.direction, transition.newRingDepth);
1217
+ try {
1218
+ const outcome = await ringShift.executeShift({
1219
+ direction: transition.direction,
1220
+ newRingDepth: transition.newRingDepth
1221
+ });
1222
+ log?.('Ring shift outcome: %o', outcome);
1223
+ } catch (err) {
1224
+ log?.('Ring shift failed: %o', err);
1225
+ } finally {
1226
+ // Measure the minimum dwell from the SETTLED shift (completed or rolled back), not
1227
+ // just the trigger stamped inside shouldTransition (docs/arachnode-ring-handoff.md §1.3).
1228
+ ringSelector.recordShiftSettled();
1229
+ }
1230
+ }
1231
+ }, 60_000);
1232
+
1233
+ // Cleanup on node stop
1234
+ const originalStop = node.stop.bind(node);
1235
+ node.stop = async () => {
1236
+ clearInterval(monitorInterval);
1237
+ await originalStop();
1238
+ };
1239
+ } else {
1240
+ log?.('FRET service not available, Arachnode disabled');
1241
+ }
1242
+ }
1243
+
1244
+ // --- Seed the shared owned-block set from already-durable storage ---
1245
+ // Blocks durable from a previous run are otherwise untracked until next touched (see the
1246
+ // onAnyCollectionChange comment above where ownedBlocks is declared). Placed here, AFTER both
1247
+ // monitor-wiring blocks (spread ~line 862, rebalance ~line 974) have had their chance to call
1248
+ // ensureOwnedBlockFeed():
1249
+ // - Gate on offOwnedBlockFeed: only seed when a monitor actually consumes ownedBlocks; if both
1250
+ // are disabled the set is unused and the scan (plus the background task) is wasted work.
1251
+ // - Feed-before-scan ordering is load-bearing: because the feed is already live, a block
1252
+ // committed/replicated DURING the scan is caught by the feed; Set.add is idempotent so the
1253
+ // overlap is harmless. Scanning before subscribing would drop a block committed in the gap.
1254
+ // - Fire-and-forget so a large store never blocks startup; the .catch keeps a scan rejection
1255
+ // from becoming an unhandled rejection.
1256
+ // - Cancellable: a stop wrapper flips seedStopping so the scan loop breaks against a
1257
+ // stopping/closing backend rather than running the enumeration to completion.
1258
+ // NOTE: a concurrent rebalance release can untrackBlock (delete from ownedBlocks) a confirmed-
1259
+ // released block while this scan is still running, and the scan could then re-add that id. Benign
1260
+ // transient: the block is still in the metadata store (no sweep reclaims metadata yet), so a
1261
+ // re-added released block is simply re-evaluated and re-released on the next rebalance tick. Right
1262
+ // after a restart, responsibility-loss detection lags this fast metadata scan, so the window is
1263
+ // small. Accepted rather than synchronized.
1264
+ if (offOwnedBlockFeed && typeof rawStorage.listBlockIds === 'function') {
1265
+ let seedStopping = false;
1266
+ const previousStop = node.stop.bind(node);
1267
+ node.stop = async () => {
1268
+ seedStopping = true;
1269
+ await previousStop();
1270
+ };
1271
+ void seedOwnedBlocksFromStorage(rawStorage, ownedBlocks, () => seedStopping)
1272
+ .catch((err) => ((node as any).logger?.forComponent?.('db-p2p:owned-block-seed'))?.('seed failed: %o', err));
1273
+ }
1274
+
1275
+ // [dispute-subsystem-dormant] The DisputeService object is constructed below so tests and
1276
+ // getDisputeStatus() work, but it is unreachable from the live network path:
1277
+ // - No inbound handler: disputeProtocolService is NOT in the services map above.
1278
+ // - onInvalidation is deliberately unset: maybeInvalidate() is a no-op on live nodes.
1279
+ // - revalidate is deliberately unset: handleChallenge always votes inconclusive on live nodes.
1280
+ // Full activation requires arbitrator-set anchoring before a forged synthetic cohort can pass resolution.
1281
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
1282
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
1283
+ // Initialize dispute service if enabled
1284
+ let disputeServiceInstance: DisputeService | undefined;
1285
+ if (options.dispute?.disputeEnabled) {
1286
+ const createDisputeClient = (peerId: any) => DisputeClient.create(peerId, keyNetwork, protocolPrefix);
1287
+ disputeServiceInstance = new DisputeService({
1288
+ peerId: node.peerId,
1289
+ privateKey: nodePrivateKey,
1290
+ peerNetwork: keyNetwork,
1291
+ createDisputeClient,
1292
+ reputation,
1293
+ validator: options.validator,
1294
+ config: options.dispute,
1295
+ selectArbitrators: async (blockId: string, excludePeers: string[], count: number, round: number, epoch: Uint8Array) => {
1296
+ const { hashKey: fretHashKey } = await import('p2p-fret');
1297
+ const fret = (node as any).services?.fret as FretService | undefined;
1298
+ if (!fret) return [];
1299
+ // Dispersed sampling: draw `count` peers from coordinates spread across the whole keyspace
1300
+ // (hash(blockId round ‖ epoch ‖ i)) rather than the block's XOR neighborhood, so an attacker
1301
+ // who owns the block's locale does not thereby own the arbitrators. `assembleCohort` already
1302
+ // filters to known members; excluding the original cluster + self keeps arbitrators independent.
1303
+ const excludeSet = new Set(excludePeers);
1304
+ // NOTE: adding the local node's own id to `exclude` makes the draw node-relative. Cross-node
1305
+ // determinism (the verifiable-recompute property) holds today only because the dissent
1306
+ // coordinator running this is itself a member of the original cluster, so `self` is already in
1307
+ // `excludePeers` — the add is a no-op and every honest node excludes the identical set. When a
1308
+ // verify-path recompute lands, it MUST reconstruct `exclude` from the challenger's identity
1309
+ // (`proof.challengerPeerId`) + original cluster, never the verifier's own id, or re-derivation diverges.
1310
+ excludeSet.add(node.peerId.toString());
1311
+ const picks = await sampleArbitrators(
1312
+ { blockId: new TextEncoder().encode(blockId), round, epoch, count, exclude: excludeSet },
1313
+ (coord, wants) => fret.assembleCohort(coord, wants) as string[],
1314
+ fretHashKey,
1315
+ );
1316
+ return picks.map(pid => peerIdFromString(pid));
1317
+ },
1318
+ });
1319
+ }
1320
+
1321
+ // The host-facing attachment surface, declared once in `optimystic-node.ts` and written here
1322
+ // through ONE object literal so every field is type-checked AND a field added to
1323
+ // `OptimysticNodeAttachments` but never assigned here is a compile error rather than an
1324
+ // `undefined` a host reads as present. Keeping it typed is load-bearing: when
1325
+ // `node.keyNetwork` was reachable only through a cast, three hosts found it easier to build a
1326
+ // SECOND Libp2pKeyPeerNetwork from constructor defaults — a different cohort width and no
1327
+ // network-membership filter than this node's own consensus path uses for the same key
1328
+ // (ticket bug-second-key-network-built-with-defaults).
1329
+ const attachments: OptimysticNodeAttachments = {
1330
+ coordinatedRepo,
1331
+ storageRepo,
1332
+ // The StorageRepo is the single commit funnel for both the coordinated and
1333
+ // direct paths, so it is the node's per-collection change-notifier origin. This is the
1334
+ // default; the cohort-topic activation block below REPLACES it with the origination-decorating
1335
+ // bridge notifier when the substrate is enabled.
1336
+ blockChangeNotifier: storageRepo,
1337
+ keyNetwork,
1338
+ reputation,
1339
+ disputeService: disputeServiceInstance,
1340
+ // The node's libp2p Ed25519 identity key. Exposed on the same attachment surface as
1341
+ // coordinatedRepo/keyNetwork so a host can bind a client-transaction signer to it (the Quereus
1342
+ // collection-factory's getSigner reuses this via signPeer). libp2p does not surface the private
1343
+ // key on its public `Libp2p` interface, so this attachment is the sanctioned in-process handle.
1344
+ // Ed25519 by construction (options.privateKey defaults to generateKeyPair('Ed25519')).
1345
+ peerPrivateKey: nodePrivateKey,
1346
+ };
1347
+ Object.assign(node, attachments);
1348
+
1349
+ // --- Cohort-topic origination activation (post-node: consumes the fully-assembled node + FRET) ---
1350
+ // This is the only place that is after the node + FRET are assembled (node.start() done, fretSvc
1351
+ // available) yet before any caller can capture `blockChangeNotifier` the Quereus collection-factory
1352
+ // captures it once, immediately after createLibp2pNode returns, and reuses that reference as
1353
+ // `localChangeNotifier` for every NetworkTransactor it builds. Installing the bridge here makes the
1354
+ // origination path live for ALL collections created on the node.
1355
+ if (cohortEnabled) {
1356
+ // The host needs the full FRET engine surface; node.services.fret is the wrapper (see resolveFretEngine).
1357
+ const fret = resolveFretEngine(fretSvc);
1358
+ if (!fret) {
1359
+ // Operator opted in; degrading silently to the bare notifier would hide misconfiguration.
1360
+ // (The started node is torn down by the post-start rollback `catch` at the bottom of this function.)
1361
+ throw new Error('cohortTopic enabled but the FRET service is unavailable on the node');
1362
+ }
1363
+
1364
+ const host = await createCohortTopicHost(node, fret, {
1365
+ ...(options.cohortTopic!.host ?? {}),
1366
+ // Wire the node's reputation service in as the production backing for the bootstrap-evidence
1367
+ // referee verifier (the `{ isBanned, getScore }` view `PeerReputationService` satisfies), so a
1368
+ // configured cohort genuinely gates cold-root `bootstrap: true` (PoW always; reputation when a
1369
+ // referee endorsement is offered; a signed reference to an existing parent topic on any tier).
1370
+ // The node service is the *default* backing — a caller that supplies its own `antiDos.reputation`
1371
+ // (or any other `antiDos` override) still wins, since the caller spread comes last.
1372
+ antiDos: { reputation, ...(options.cohortTopic!.host?.antiDos) },
1373
+ // committedParentTopicReader (the T0/T1 committed-tier parent-reference existence backing) is
1374
+ // intentionally left unwired: no coord-keyed committed-membership index exists yet (the
1375
+ // transaction-log commit certificate is keyed by action, not by coord_0). So the host default
1376
+ // fails T0/T1 parent-ref existence closed a FRET-cached cert must not vouch for committed-tier
1377
+ // existence (committed-tier integrity) while T2/T3 parent-ref consults the FRET membership cache
1378
+ // for real. The dedicated committed backing is the follow-on `cohort-topic-parent-ref-tx-log-content`;
1379
+ // an operator may still pass one via cohortTopic.host.committedParentTopicReader.
1380
+ privateKey: nodePrivateKey, // real k − x threshold signing
1381
+ wantK: cohortWantK,
1382
+ });
1383
+
1384
+ // --- Cohort-topic + reactivity + matchmaking teardown ---
1385
+ // Installed HERE, immediately after `host` exists and BEFORE the ~230 lines of reactivity /
1386
+ // matchmaking wiring below, because the post-start rollback only unwinds resources whose stop
1387
+ // wrapper is already installed at the moment of the throw. With the wrapper at the END of the
1388
+ // block (where it used to live) a throw mid-wiring left the host's gossip timer and cohort-topic
1389
+ // protocol handlers running. The bindings it releases are therefore declared up front and
1390
+ // undefined-guarded same idiom as `offOwnedBlockFeed` above so this tears down exactly what
1391
+ // has been created so far, whether that is the host alone or the whole wiring.
1392
+ //
1393
+ // Ordering (load-bearing): release reactivity timers + protocol handlers BEFORE host.stop()
1394
+ // (which clears the cohort gossip timer + unhandles the cohort-topic protocols) BEFORE the node's
1395
+ // transports close (previousStop). Composes with the existing arachnode + clusterMember stop
1396
+ // wrappers (each calls its captured previousStop last). `node.unhandle` on a protocol that was
1397
+ // never registered does not throw — libp2p's registrar deletes each id from its handler map
1398
+ // (a miss is silently ignored) and then re-patches the peer store's advertised protocol list —
1399
+ // so the handler releases need no separate registration flags.
1400
+ const reactivityProtocols = DEFAULT_REACTIVITY_PROTOCOLS;
1401
+ const matchmakingProtocols = DEFAULT_MATCHMAKING_PROTOCOLS;
1402
+ let unsubscribeCohortBridge: (() => void) | undefined;
1403
+ let offInboundNotify: (() => void) | undefined;
1404
+ let pushStateGossip: ReactivityPushStateGossipDriver | undefined;
1405
+ let reactivityRotation: RotationReRegistrationScheduler | undefined;
1406
+ {
1407
+ const previousStop = node.stop.bind(node);
1408
+ node.stop = async (): Promise<void> => {
1409
+ try {
1410
+ reactivityRotation?.stop();
1411
+ pushStateGossip?.stop();
1412
+ offInboundNotify?.();
1413
+ await node.unhandle(reactivityProtocolList(reactivityProtocols));
1414
+ await node.unhandle(matchmakingProtocolList(matchmakingProtocols));
1415
+ unsubscribeCohortBridge?.();
1416
+ await host.stop();
1417
+ } finally {
1418
+ await previousStop();
1419
+ }
1420
+ };
1421
+ }
1422
+
1423
+ // selfIsCohortMember: this node owns the collection's reactivity-topic fan-out iff it is in the
1424
+ // FRET cohort around coord_0(H(currentTailId "reactivity")). Uses db-core's default hashes
1425
+ // (createReactivityTopicAnchor / createTierAddressing / createRingHash), byte-identical to the
1426
+ // host's internal `new RingHash()` and the subscriber-side anchor, and the SAME cohortWantK as
1427
+ // the host — so the coord + cohort line up across origination and subscription.
1428
+ const selfIsCohortMember = createReactivitySelfMembershipGate({
1429
+ fret,
1430
+ selfPeerId: node.peerId.toString(),
1431
+ wantK: cohortWantK,
1432
+ });
1433
+
1434
+ unsubscribeCohortBridge = attachCohortChangeBridge(
1435
+ node as unknown as { blockChangeNotifier?: IBlockChangeNotifier },
1436
+ {
1437
+ source: storageRepo,
1438
+ service: host.service,
1439
+ selfIsCohortMember,
1440
+ extractCommitCert: makeClusterCommitCertExtractor(certStore!),
1441
+ },
1442
+ ).unsubscribe;
1443
+
1444
+ // Expose the host so the reactivity origination wiring (and the activation test) can install
1445
+ // `CohortTopicService.onLocalCommit`.
1446
+ (node as any).cohortTopicHost = host;
1447
+
1448
+ // --- Reactivity notification transport (origination fan-out inbound delivery push-state gossip) ---
1449
+ // Compose notify + forwarder-host + push-state-gossip onto the cohort-topic host so a committed change
1450
+ // on a tail-cohort member actually reaches subscribers on OTHER nodes over real sockets. The change
1451
+ // bridge above fires `onLocalCommit`; this is what the emitted notifications travel over.
1452
+ // (docs/reactivity.md §Notification origination / §Propagation.) Reactivity reuses the canonical,
1453
+ // network-agnostic protocol IDs, matching the cohort-topic family's production default.
1454
+ const selfPeerId = node.peerId.toString();
1455
+ const reactivityProfile = host.profile; // Edge ⇒ subscriber-only via the policy gate; Core forwards.
1456
+ const reactivityPolicy = reactivityNodePolicy(reactivityProfile);
1457
+ // db-core default anchor + tier addressing, byte-identical to the host's `new RingHash()`, the
1458
+ // origination gate, and the subscriber-side anchor so coord_0 derivation lines up everywhere.
1459
+ const reactivityAddressing = createTierAddressing(createRingHash());
1460
+ // Reactivity's forwarder cohort sits at coord_0 — TREE tier 0 (peer-independent), distinct from the
1461
+ // CAPACITY tier T3 the verifier/willingness use. `registry.findServing` keys on the engine's tree
1462
+ // depth, so the served reactivity engine is found at tree tier 0, never at 3.
1463
+ const REACTIVITY_FORWARDER_TREE_TIER = 0;
1464
+
1465
+ // Node-level subscriber registry: a constructed ReactivitySubscriptionManager registers here so a
1466
+ // socket-delivered NotificationV1 reaches it. (The Quereus Database.watch manager bridge that
1467
+ // CONSTRUCTS managers stays the backlog item optimystic-network-reactive-watch-integration-test.)
1468
+ const reactivitySubscribers = new ReactivitySubscriberRegistry();
1469
+ (node as any).reactivitySubscribers = reactivitySubscribers;
1470
+
1471
+ // 1. Notify transportunicast NotificationV1 send + inbound subscribe. selfPeerId guards self-dials.
1472
+ const notify = new Libp2pReactivityNotifyTransport(node, { selfPeerId });
1473
+
1474
+ // 2. Forwarder host — turns the forward decision into live fan-out over the notify transport.
1475
+ const forwarderHost = new ReactivityForwarderHost({
1476
+ transport: notify,
1477
+ selfPeerId,
1478
+ profile: reactivityProfile,
1479
+ pushStateInit: (topicId: Uint8Array, n: NotificationV1): PushStateInit => ({
1480
+ collectionId: n.collectionId,
1481
+ topicId: bytesToB64url(topicId),
1482
+ tailIdAtJoin: n.tailId,
1483
+ deltaMaxBytes: reactivityPolicy.deltaMaxBytes,
1484
+ }),
1485
+ verifierFor: (): NotificationVerifier => createNotificationVerifier({ verifier: host.service.verifier(), tier: Tier.T3 }),
1486
+ directSubscribers: (topicId: Uint8Array): string[] => {
1487
+ // Find the served reactivity engine at TREE tier 0 (see REACTIVITY_FORWARDER_TREE_TIER) and read
1488
+ // its direct-subscriber records. The adapter filters to reactivity appState and maps participantId
1489
+ // bytes dialable peer-id strings (the transport's `peerIdFromString` space) — NOT base64url,
1490
+ // which would silently fail to dial. `undefined` (no subscriber has registered here yet) ⇒ [].
1491
+ const engine = host.registry.findServing(topicId, REACTIVITY_FORWARDER_TREE_TIER);
1492
+ return engine === undefined ? [] : reactivityDirectSubscribers(engine, topicId);
1493
+ },
1494
+ // No childCohorts until cohort-topic-parent-child-link populates PushState.childCohorts (single
1495
+ // tier-0 reach today); wire the resolver anyway. A child cohort's primary is the FRET-nearest member
1496
+ // of its coord, returned as a peer-id string (the dial space).
1497
+ resolveChildPrimary: (ref: CohortRef): string | undefined => {
1498
+ const peers = fret.assembleCohort(b64urlToBytes(ref.coord), cohortWantK);
1499
+ return peers.length > 0 ? peers[0] : undefined;
1500
+ },
1501
+ deliverLocal: (topicId: Uint8Array, n: NotificationV1): void => reactivitySubscribers.deliver(topicId, n),
1502
+ });
1503
+
1504
+ // Inbound notify frames forwarder host (subscriber role delivers in-process; forwarder role fans out).
1505
+ // NOTE: the four `register*Handler` helpers below (notify / pushStateGossip / recover /
1506
+ // matchmaking query) all call `node.handle(...)` fire-and-forget (`void`), so a rejected
1507
+ // registration escapes the post-start rollback `catch` as an UNHANDLED rejection instead of
1508
+ // failing node creation. Harmless today every protocol id here is a fixed constant registered
1509
+ // exactly once, so the only realistic rejection is a duplicate, and that needs a caller to pass
1510
+ // overlapping custom `cohortTopic.host.protocols`. If any of these ids ever becomes
1511
+ // caller-configurable, or a helper grows a registration that can genuinely fail, make them await
1512
+ // their `node.handle` so the failure reaches the rollback.
1513
+ registerNotifyHandler(node, reactivityProtocols.notify, notify);
1514
+ offInboundNotify = notify.onNotification((from, n): void => { void forwarderHost.onInbound(from, n); });
1515
+
1516
+ // 3. Origination emit — install onLocalCommit: a member commit builds a NotificationV1 and ingests it.
1517
+ const origination = new ReactivityOriginationManager({
1518
+ service: host.service,
1519
+ resolveContext: (event) => {
1520
+ if (event.tailId === undefined) {
1521
+ return undefined; // tail-less (read-driven promotion) never originates (the gate also returns first)
1522
+ }
1523
+ return {
1524
+ // MUST reuse the gate's `reactivityTailBytes` (utf8), NOT db-core's double-hashing
1525
+ // blockIdToBytes else origination derives a different coord than subscribers resolve.
1526
+ tailId: reactivityTailBytes(event.tailId),
1527
+ deltaMaxBytes: reactivityPolicy.deltaMaxBytes,
1528
+ // rotationHint stays undefined on a live node: the successor tail id is not knowable at the
1529
+ // filling commit (random block ids; gated on 6.5-block-id-derivation). The authoritative,
1530
+ // observable rotation signal is `event.tailId` CHANGING, which the manager observes via the
1531
+ // `markRotated` binding below. (The pre-announce remains exercised in the mock-tier harness +
1532
+ // the design simulator, both of which can synthesize the successor id.)
1533
+ };
1534
+ },
1535
+ // reactivityNotificationTopicId(n) = reactivityTopicId(b64urlToBytes(n.tailId)); since
1536
+ // n.tailId = b64url(reactivityTailBytes(tail)), this is the SAME topicId the gate assembled coord_0
1537
+ // around and the subscriber/forwarder verifier derives closing the encoding loop.
1538
+ emit: (n): void => { void forwarderHost.ingest(reactivityNotificationTopicId(n), n); },
1539
+ // Observe-rotation: when a collection's tail id changes between commits the OLD tail's reactivity
1540
+ // topic has rotated. Start its drain so the recover serve begins redirecting to the new tree (the
1541
+ // `reactivity-rotation-recover-redirect-drain` markRotated seam). `oldTopicId` is byte-identical to
1542
+ // the topic a subscriber subscribed under (both `reactivityTopicId(reactivityTailBytes(tail))`).
1543
+ markRotated: (oldTopicId, redirect, now): void => forwarderHost.markRotated(oldTopicId, redirect, now),
1544
+ });
1545
+ origination.install();
1546
+
1547
+ // 4. PushState gossip — periodic intra-cohort convergence so any member (not just the primary) can
1548
+ // serve a replay/backfill. Rides the host's cohort gossip transport (no second transport).
1549
+ pushStateGossip = new ReactivityPushStateGossipDriver({
1550
+ gossipTransport: host.gossipTransport,
1551
+ liveCollections: (): ReactivityGossipCollection[] => forwarderHost.livePushStates().map((pushState) => ({
1552
+ pushState,
1553
+ cohortCoord: reactivityAddressing.coord0(b64urlToBytes(pushState.topicId)),
1554
+ })),
1555
+ pushStateForGossip: (g: PushStateGossipV1) => forwarderHost.pushStateFor(b64urlToBytes(g.topicId)),
1556
+ // Authenticity gate: accept gossip only from a member of the cohort around the frame's reactivity
1557
+ // coord (per-frame peer-sig envelope signing is deferred — reactivity-pushstate-gossip's hardening backlog).
1558
+ isCohortMember: (fromPeerId: string, g: PushStateGossipV1): boolean =>
1559
+ fret.assembleCohort(reactivityAddressing.coord0(b64urlToBytes(g.topicId)), cohortWantK).includes(fromPeerId),
1560
+ });
1561
+ registerPushStateGossipHandler(node, reactivityProtocols.pushStateGossip, pushStateGossip);
1562
+ pushStateGossip.start();
1563
+
1564
+ // 5. Recover RPC the pull companion to notify (docs/reactivity.md §Backfill RPC / §Resume). A
1565
+ // subscriber that detected a gap, or woke from sleep past the live tail, asks a serving cohort member
1566
+ // "what did I miss?" and is brought current over a real request-reply socket. The SERVE side is live
1567
+ // here: this node answers RecoverRequestV1 frames against its live forwarder PushStates. The OUTBOUND
1568
+ // transport + signers are constructed and exposed for the subscribe factory that CONSTRUCTS managers
1569
+ // (the Quereus Database.watch app-bridge — backlog optimystic-network-reactive-watch-integration-test);
1570
+ // no node-internal manager calls them yet, exactly as the notify subscriber side is constructed against
1571
+ // `reactivitySubscribers` rather than from a watch.
1572
+ //
1573
+ // Node-level sticky cohort-hint cache (keyed by collectionId), shared between the outbound transport's
1574
+ // sticky-primary lookup and a future manager's rotation-invalidation so both see ONE cache. It starts
1575
+ // empty the transport falls through to the cohort-walk (any member holding the gossiped PushState
1576
+ // answers); populating the sticky primary is a one-RT optimization, not a correctness need.
1577
+ const reactivityCohortHintCache = createStickyCohortHintCache();
1578
+ // topicId → dialable cohort member peer-id strings: the SAME FRET coord_0 assembly the push-state-gossip
1579
+ // authenticity gate uses (`reactivityAddressing.coord0` → `fret.assembleCohort`), so a recover walk
1580
+ // reaches exactly the cohort that holds the topic's gossiped PushState. `assembleCohort` returns peer-id
1581
+ // strings (the recover dialer's `peerIdFromString` space), matching the notify dial-target space.
1582
+ const resolveReactivityCohort = (topicId: Uint8Array): string[] =>
1583
+ fret.assembleCohort(reactivityAddressing.coord0(topicId), cohortWantK);
1584
+
1585
+ // Outbound transport: exposes the db-core BackfillTransport / ResumeTransport seams against this node.
1586
+ // maxBytes is omitted so the dialer + handler default to DEFAULT_STREAM_MAX_BYTES, matching the notify
1587
+ // transport's default (constructed above without an override) — one frame ceiling across the family.
1588
+ const recover = new Libp2pReactivityRecoverTransport({
1589
+ dialer: createLibp2pRecoverDialer(node, reactivityProtocols.recover),
1590
+ selfPeerId,
1591
+ cohortHintCache: reactivityCohortHintCache,
1592
+ resolveCohort: resolveReactivityCohort,
1593
+ });
1594
+
1595
+ // Inbound serve handler: decode (bounded) verify the dialing peer's signature freshness/replay gate
1596
+ // resolve the live PushState off the forwarder host → serveBackfill/serveResume → reply (no reply on any
1597
+ // failure; the stream aborts and the subscriber walks/chain-reads). One node-level replay guard is shared
1598
+ // across all recover requests — a plain pruned-on-access map, so no new timer to tear down.
1599
+ registerRecoverHandler(node, reactivityProtocols.recover, {
1600
+ pushStateFor: forwarderHost.pushStateFor.bind(forwarderHost),
1601
+ pushStateForCollection: forwarderHost.pushStateForCollection.bind(forwarderHost),
1602
+ replayGuard: createCorrelationReplayGuard(),
1603
+ rotationFor: (req, now) => {
1604
+ // Drain-window redirect: a recover reaching an OLD (rotated, still-draining) tail is bounced to
1605
+ // the new tree (reactivity-rotation-recover-redirect-drain). A resume carries the stale topic
1606
+ // (topicId = reactivityTopicId(latestKnownTailId)); a backfill carries no topic, so resolve the
1607
+ // collection's current served topic. rotationRedirectFor returns the gate's redirect while
1608
+ // draining and undefined once drained (then evicting the gate + the old tail's served PushState).
1609
+ const oldTopicId = req.topicId ?? resolveCurrentServedTopic(forwarderHost, req.collectionId);
1610
+ return oldTopicId === undefined ? undefined : forwarderHost.rotationRedirectFor(oldTopicId, now);
1611
+ },
1612
+ });
1613
+
1614
+ // The subscriber's synchronous request signers over the node's Ed25519 key (resolves the recover wiring's
1615
+ // lone design point — see recover-transport.ts §createRecoverRequestSigners). Fed to a manager by the
1616
+ // subscribe factory alongside recover.backfillTransport(topicId, collectionId) /
1617
+ // recover.resumeTransport(topicId, collectionId).
1618
+ const recoverSigners = createRecoverRequestSigners(nodePrivateKey);
1619
+
1620
+ // Expose the recover seams so the subscribe factory wires backfill/resume RPC + signers + the shared
1621
+ // sticky cache (mirrors `reactivitySubscribers` above).
1622
+ (node as any).reactivityRecover = recover;
1623
+ (node as any).reactivityRecoverSigners = recoverSigners;
1624
+ (node as any).reactivityCohortHintCache = reactivityCohortHintCache;
1625
+
1626
+ // 6. Rotation re-registration scheduler the host timer that moves a subscriber to the rotated tree
1627
+ // when its manager surfaces a `RotationNotice` (`reactivity-rotation-rereg-scheduler`). Constructed with
1628
+ // the default unref'd `setTimeout` timer so an idle re-registration never pins the process. The
1629
+ // `reRegister(plan)` MOVE belongs to the subscribe factory that CONSTRUCTS managers (the deferred Quereus
1630
+ // `Database.watch` bridge backlog optimystic-network-reactive-watch-integration-test): on fire it builds
1631
+ // a fresh `ReactivitySubscriptionManager` under `plan.newTopicId` carrying `plan.lastRevision`, registers
1632
+ // it, and swaps the `ReactivitySubscriberRegistry` entry registering the NEW-topic handler BEFORE
1633
+ // unregistering the old, so a notification mid-swap is never dropped. Until that factory lands no
1634
+ // node-internal manager drives `schedule()`, so this seam is a logged no-op — exactly as 12.33 exposed
1635
+ // `reactivitySubscribers` / `reactivityRecover` without a live manager constructor.
1636
+ reactivityRotation = new RotationReRegistrationScheduler({
1637
+ reRegister: (plan): Promise<void> => {
1638
+ reactivityWiringLog("reactivity rotation re-registration fired for successor topic=%s (lastRevision=%d) but no subscribe factory is wired yet — deferred to optimystic-network-reactive-watch-integration-test", bytesToB64url(plan.newTopicId), plan.lastRevision);
1639
+ return Promise.resolve();
1640
+ },
1641
+ });
1642
+ (node as any).reactivityRotation = reactivityRotation;
1643
+
1644
+ // --- Matchmaking QueryV1 RPC cohort serve side (docs/matchmaking.md §Seeker query) ---
1645
+ // The server half of the seeker query transport: a remote seeker dials `/optimystic/matchmaking/1.0.0/query`
1646
+ // and this node answers with its cohort's locally-held provider/seeker registrations, signed by the node
1647
+ // peer key. Matchmaking is layered ABOVE the cohort-topic substrate, so it owns its own protocol family
1648
+ // and is wired here (the composition root) over the host's PUBLIC surface only — mirroring the reactivity
1649
+ // registration above; nothing reaches into host.ts internals. The OUTBOUND seeker walk client is the
1650
+ // prereq follow-on `matchmaking-query-rpc-seeker-walk`; only the serve side is live here.
1651
+ registerMatchmakingQueryHandler(node, matchmakingProtocols.query, {
1652
+ registry: host.registry,
1653
+ // Reuse the reactivity addressing: createTierAddressing(createRingHash()) is byte-identical to the
1654
+ // host's internal addressing for the tier-0 coord (peer- and fanout-independent), and the handler
1655
+ // only ever derives coord_0(topicId).
1656
+ addressing: reactivityAddressing,
1657
+ // Single-member reply signature over the node peer key (same pattern reactivity uses for its signers).
1658
+ sign: async (payload: Uint8Array): Promise<string> => bytesToB64url(await signPeer(nodePrivateKey, payload)),
1659
+ // Anti-DoS rate-limit seam (backlog matchmaking-query-rate-limit) intentionally left unwired here:
1660
+ // default-allow. When that ticket lands it passes a `gate: (from, topicId) => boolean` that limits on
1661
+ // the connection's verified `from` peer (NOT the self-asserted query.requesterId).
1662
+ });
1663
+ }
1664
+
1665
+ return node as unknown as OptimysticNode;
1666
+ } catch (err) {
1667
+ // Post-start rollback. node.stop() runs whatever teardown wrappers were installed BEFORE the throw
1668
+ // (each wrapper is registered next to the resource it releases, precisely so this unwinds as much as
1669
+ // exists) and closes the transports. A rollback failure must never mask the real startup error, so it
1670
+ // is logged and swallowed; `err` is what the caller sees.
1671
+ try {
1672
+ await node.stop();
1673
+ } catch (stopErr) {
1674
+ wiringLog('rollback stop failed after startup error: %o', stopErr);
1675
+ }
1676
+ throw err;
1677
+ }
1678
+ }