@serfab/cadre-core 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +3 -2
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +607 -53
  6. package/dist/cadre-node.js +1268 -157
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-database.d.ts +174 -23
  9. package/dist/control-database.js +317 -107
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-read-retry.d.ts +103 -0
  12. package/dist/control-read-retry.js +145 -0
  13. package/dist/control-read-retry.js.map +1 -0
  14. package/dist/control-retry.d.ts +101 -0
  15. package/dist/control-retry.js +108 -0
  16. package/dist/control-retry.js.map +1 -0
  17. package/dist/control-schema.d.ts +1 -1
  18. package/dist/control-schema.js +50 -6
  19. package/dist/control-schema.js.map +1 -1
  20. package/dist/control-write-retry.d.ts +59 -35
  21. package/dist/control-write-retry.js +21 -92
  22. package/dist/control-write-retry.js.map +1 -1
  23. package/dist/delegate-admission.d.ts +17 -15
  24. package/dist/delegate-admission.js +17 -15
  25. package/dist/delegate-admission.js.map +1 -1
  26. package/dist/enrolled-machine-store-file.d.ts +13 -0
  27. package/dist/enrolled-machine-store-file.js +41 -0
  28. package/dist/enrolled-machine-store-file.js.map +1 -0
  29. package/dist/enrolled-machine-store.d.ts +81 -0
  30. package/dist/enrolled-machine-store.js +225 -0
  31. package/dist/enrolled-machine-store.js.map +1 -0
  32. package/dist/file-durable-slot.js +5 -3
  33. package/dist/file-durable-slot.js.map +1 -1
  34. package/dist/index.d.ts +10 -6
  35. package/dist/index.js +39 -12
  36. package/dist/index.js.map +1 -1
  37. package/dist/membership-connection-gater.d.ts +226 -26
  38. package/dist/membership-connection-gater.js +316 -30
  39. package/dist/membership-connection-gater.js.map +1 -1
  40. package/dist/node-local-snapshot.d.ts +6 -0
  41. package/dist/node-local-snapshot.js +7 -1
  42. package/dist/node-local-snapshot.js.map +1 -1
  43. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  44. package/dist/{strand-backfill.js → peer-join-backfill.js} +146 -56
  45. package/dist/peer-join-backfill.js.map +1 -0
  46. package/dist/peer-record.d.ts +26 -0
  47. package/dist/peer-record.js +32 -0
  48. package/dist/peer-record.js.map +1 -1
  49. package/dist/relay-addrs.d.ts +145 -22
  50. package/dist/relay-addrs.js +314 -31
  51. package/dist/relay-addrs.js.map +1 -1
  52. package/dist/relay-reservation.d.ts +20 -15
  53. package/dist/relay-reservation.js +18 -14
  54. package/dist/relay-reservation.js.map +1 -1
  55. package/dist/strand-addr-protocol.js +16 -0
  56. package/dist/strand-addr-protocol.js.map +1 -1
  57. package/dist/strand-database.d.ts +12 -0
  58. package/dist/strand-database.js +16 -0
  59. package/dist/strand-database.js.map +1 -1
  60. package/dist/strand-formation-manager.d.ts +22 -9
  61. package/dist/strand-formation-manager.js +20 -12
  62. package/dist/strand-formation-manager.js.map +1 -1
  63. package/dist/strand-formation-protocol.d.ts +83 -9
  64. package/dist/strand-formation-protocol.js +86 -10
  65. package/dist/strand-formation-protocol.js.map +1 -1
  66. package/dist/strand-instance-manager.d.ts +151 -17
  67. package/dist/strand-instance-manager.js +237 -32
  68. package/dist/strand-instance-manager.js.map +1 -1
  69. package/dist/strand-membership-writer.d.ts +118 -14
  70. package/dist/strand-membership-writer.js +203 -15
  71. package/dist/strand-membership-writer.js.map +1 -1
  72. package/dist/strand-network-config.d.ts +84 -0
  73. package/dist/strand-network-config.js +136 -0
  74. package/dist/strand-network-config.js.map +1 -0
  75. package/dist/strand-revocation-enforcer.d.ts +392 -0
  76. package/dist/strand-revocation-enforcer.js +533 -0
  77. package/dist/strand-revocation-enforcer.js.map +1 -0
  78. package/dist/strand-solicitation.d.ts +10 -3
  79. package/dist/strand-solicitation.js +12 -7
  80. package/dist/strand-solicitation.js.map +1 -1
  81. package/dist/strand-wake-protocol.d.ts +47 -2
  82. package/dist/strand-wake-protocol.js +76 -6
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/strand-watcher.js +6 -1
  85. package/dist/strand-watcher.js.map +1 -1
  86. package/dist/types.d.ts +282 -43
  87. package/dist/types.js +7 -1
  88. package/dist/types.js.map +1 -1
  89. package/package.json +16 -8
  90. package/src/announce-addrs.ts +93 -0
  91. package/src/cadre-node.ts +1366 -171
  92. package/src/control-database.ts +2628 -2406
  93. package/src/control-read-retry.ts +167 -0
  94. package/src/control-retry.ts +190 -0
  95. package/src/control-schema.ts +50 -6
  96. package/src/control-write-retry.ts +30 -130
  97. package/src/delegate-admission.ts +17 -15
  98. package/src/enrolled-machine-store-file.ts +43 -0
  99. package/src/enrolled-machine-store.ts +280 -0
  100. package/src/file-durable-slot.ts +5 -3
  101. package/src/index.ts +477 -407
  102. package/src/membership-connection-gater.ts +539 -204
  103. package/src/node-local-snapshot.ts +13 -1
  104. package/src/peer-addr-book.ts +251 -250
  105. package/src/{strand-backfill.ts → peer-join-backfill.ts} +187 -75
  106. package/src/peer-record.ts +33 -0
  107. package/src/relay-addrs.ts +436 -111
  108. package/src/relay-reservation.ts +828 -823
  109. package/src/strand-addr-protocol.ts +17 -0
  110. package/src/strand-database.ts +17 -0
  111. package/src/strand-formation-manager.ts +511 -491
  112. package/src/strand-formation-protocol.ts +808 -692
  113. package/src/strand-instance-manager.ts +899 -611
  114. package/src/strand-membership-writer.ts +232 -15
  115. package/src/strand-network-config.ts +172 -0
  116. package/src/strand-revocation-enforcer.ts +653 -0
  117. package/src/strand-solicitation.ts +484 -472
  118. package/src/strand-wake-protocol.ts +94 -8
  119. package/src/strand-watcher.ts +319 -314
  120. package/src/types.ts +1554 -1309
  121. package/dist/strand-backfill.js.map +0 -1
@@ -1,611 +1,899 @@
1
- import debug from 'debug';
2
- import type { PrivateKey } from '@libp2p/interface';
3
- import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
4
- import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
5
- import { StrandDatabase } from './strand-database.js';
6
- import { StrandBackfill, type StrandBackfillConfig } from './strand-backfill.js';
7
- import { assertSchemaSignature } from './schema-verification.js';
8
- import type {
9
- StrandInstance,
10
- StrandRow,
11
- StorageConfig,
12
- NetworkConfig,
13
- LatencyHint,
14
- NodeProfile,
15
- SAppConfig,
16
- SAppInfo,
17
- RawStorageProvider,
18
- Libp2pNodeWithRepo
19
- } from './types.js';
20
- import { resolveStrandClusterSize, STRAND_CLUSTER_POLICY } from './types.js';
21
- import { resolveListenAddrs } from './relay-addrs.js';
22
-
23
- const log = debug('sereus:cadre:strand-manager');
24
- const timing = debug('sereus:cadre:timing');
25
-
26
- /**
27
- * Configuration for starting a strand instance
28
- */
29
- export interface StartStrandConfig {
30
- strandRow: StrandRow;
31
- /** sApp configuration provided by the hosting application */
32
- sAppConfig: SAppConfig;
33
- storage?: StorageConfig;
34
- network?: NetworkConfig;
35
- profile: NodeProfile;
36
- defaultLatencyHint: LatencyHint;
37
- privateKey?: PrivateKey;
38
- /** Cohort-derived discovery seed (multiaddr strings). Defaults to [] when omitted. */
39
- bootstrapNodes?: string[];
40
- /**
41
- * Require a valid author signature on the sApp schema before bring-up.
42
- * Defaults to true (fail closed) when omitted; set false only for dev/test
43
- * with unsigned demo schemas. Mirrors {@link CadreNodeConfig.requireSignedSchemas}.
44
- */
45
- requireSignedSchemas?: boolean;
46
- /**
47
- * Whether this node founds the strand (vs. joins it). Forwarded to the
48
- * StrandDatabase so the founder bootstrap (Header, founding Member/Manager)
49
- * runs once at bring-up. Joiners leave this unset and write nothing. See
50
- * {@link StrandConfig.founder}.
51
- */
52
- founder?: boolean;
53
- /**
54
- * Number of nodes Optimystic is told this strand's replication cluster should
55
- * have. Same rule as {@link CadreNodeConfig.strandClusterSize}, which CadreNode
56
- * forwards here: every node on the strand should use the same value, and it is
57
- * frozen when the strand's libp2p node is created. Defaults to
58
- * `DEFAULT_STRAND_CLUSTER_SIZE` (4); values below `MIN_CLUSTER_SIZE` (2) are
59
- * rejected by `resolveStrandClusterSize`. Strand-only — the
60
- * control network's breadth is the fixed `CONTROL_REPLICATION_BREADTH`.
61
- */
62
- clusterSize?: number;
63
- /**
64
- * Tuning for the strand peer-join block catch-up ({@link StrandBackfill}),
65
- * forwarded from {@link CadreNodeConfig.strandBackfill}. When the strand's
66
- * libp2p node connects to a peer this runtime has not yet caught up, every
67
- * block in the strand's own raw store is pushed to it. Runs only on strands
68
- * with per-strand storage; `{ enabled: false }` restores the pre-existing
69
- * no-backfill behaviour.
70
- */
71
- backfill?: StrandBackfillConfig;
72
- }
73
-
74
- /**
75
- * Volatile inputs re-resolved when resuming a quiesced strand. The cohort
76
- * discovery seed is the only one — it grows as peers are learned since the
77
- * strand first launched.
78
- */
79
- export interface ResumeStrandOverrides {
80
- /** Freshly-resolved cohort discovery seed (multiaddr strings). */
81
- bootstrapNodes?: string[];
82
- }
83
-
84
- /**
85
- * Get the isolated storage path for a specific strand.
86
- *
87
- * @deprecated This helper is Node-only and throws in React Native (it assumes a
88
- * filesystem layout). Use a storage provider factory function instead, which
89
- * receives the strandId and can create strand-specific storage paths using
90
- * platform-appropriate methods.
91
- *
92
- * @example
93
- * // Instead of using getStrandStoragePath, use a storage provider factory:
94
- * const storage = {
95
- * provider: (strandId: string) => new FileRawStorage(`./data/strands/${strandId}`)
96
- * };
97
- */
98
- export function getStrandStoragePath(basePath: string, strandId: string): string {
99
- // Check if we're in a Node.js environment
100
- if (typeof process === 'undefined' || !process.versions?.node) {
101
- throw new Error(
102
- 'getStrandStoragePath is not available in React Native. ' +
103
- 'Use a storage provider factory function instead.'
104
- );
105
- }
106
-
107
- // Sanitize strandId for filesystem safety (UUIDs should be safe, but just in case)
108
- const safeId = strandId.replace(/[^a-zA-Z0-9-]/g, '_');
109
-
110
- // Build the path with plain string joins rather than the Node `path` module.
111
- // A static `require('path')` forces RN bundlers (e.g. Metro) to *resolve* the
112
- // module at bundle time even though this Node-only helper throws above before
113
- // ever reaching here — joining by hand keeps the module free of any Node
114
- // built-in reference, so RN bundles need no `path` shim.
115
- const trimmedBase = basePath.replace(/[\\/]+$/, '');
116
- return `${trimmedBase}/strands/${safeId}`;
117
- }
118
-
119
- /**
120
- * Resolve a storage provider for a specific strand.
121
- * If the provider is a factory function, call it with the strandId.
122
- *
123
- * Called only from {@link StrandInstanceManager.startStrand}, which owns the result
124
- * for the instance's lifetime — see `strandStorages`.
125
- *
126
- * @param provider - Storage provider (instance or factory)
127
- * @param strandId - The strand ID to create storage for
128
- * @returns The resolved IRawStorage instance, or undefined if no provider
129
- */
130
- function resolveStrandStorage(
131
- provider: RawStorageProvider | undefined,
132
- strandId: string
133
- ): IRawStorage | undefined {
134
- if (!provider) {
135
- return undefined;
136
- }
137
- const storage = typeof provider === 'function' ? provider(strandId) : provider;
138
- // Wrapped in the write-through raw-storage cache (quereus-plugin-sereus's cached-storage.ts).
139
- // Called ONCE per strand launch — `startStrand` keeps the result for the instance's
140
- // lifetime — so the wrap survives every runtime rebuild the instance goes through.
141
- return wrapStorageWithCache(storage, strandId);
142
- }
143
-
144
- /**
145
- * Manages individual strand instances - creates and destroys isolated libp2p nodes
146
- * for each strand the cadre participates in.
147
- */
148
- export class StrandInstanceManager {
149
- private instances: Map<string, StrandInstance> = new Map();
150
- /**
151
- * Retained launch config per strand, captured in `startStrand` and cleared in
152
- * `stopStrand`. `resumeStrand` reuses it to rebuild a quiesced strand's runtime
153
- * without the caller re-threading storage/network/profile/key/sApp config.
154
- */
155
- private launchConfigs: Map<string, StartStrandConfig> = new Map();
156
- /**
157
- * The per-strand peer-join block catch-up, keyed by strand id. Private (not on
158
- * the public {@link StrandInstance}) because it is runtime plumbing with the
159
- * same lifetime as the strand's libp2p node: created in `buildStrandRuntime`,
160
- * stopped and dropped in `releaseRuntime` — so quiesce → resume rebuilds it
161
- * with a fresh caught-up-peer memo, which is intended (a resumed node may have
162
- * missed writes).
163
- */
164
- private backfills: Map<string, StrandBackfill> = new Map();
165
- /**
166
- * The resolved (cache-wrapped) raw storage per strand id — the instance's OWN
167
- * store, resolved once in `startStrand` and held until `stopStrand` disposes it.
168
- * Private for the same reason `backfills` is: runtime plumbing, not part of the
169
- * public {@link StrandInstance}.
170
- *
171
- * Deliberately NOT touched by `releaseRuntime`: that is what lets a quiesce →
172
- * resume cycle rebuild the libp2p node over the SAME store, keeping its
173
- * write-through cache warm (and, on an in-memory backend, keeping the strand's
174
- * blocks at all). An entry exists iff `instances` does AND the launch config
175
- * supplied a storage provider.
176
- *
177
- * NOTE: a hibernating strand therefore keeps its store — and its share of the
178
- * process-wide cache pool — resident for as long as the instance is tracked. That
179
- * is the point (a warm wake), and the pool evicts under pressure, so the cost is
180
- * one map entry per hibernating strand today. If a device ever hibernates strands
181
- * by the hundred, revisit: dropping the store at quiesce and paying for a cold
182
- * wake becomes the better trade.
183
- */
184
- private strandStorages: Map<string, IRawStorage> = new Map();
185
- private stopping = false;
186
-
187
- constructor() {
188
- log('StrandInstanceManager created');
189
- }
190
-
191
- /**
192
- * Get all current strand instances
193
- */
194
- getInstances(): Map<string, StrandInstance> {
195
- return new Map(this.instances);
196
- }
197
-
198
- /**
199
- * Get a specific strand instance
200
- */
201
- getInstance(strandId: string): StrandInstance | undefined {
202
- return this.instances.get(strandId);
203
- }
204
-
205
- /**
206
- * Check if a strand is currently running
207
- */
208
- hasStrand(strandId: string): boolean {
209
- return this.instances.has(strandId);
210
- }
211
-
212
- /**
213
- * Start a new strand instance.
214
- *
215
- * A failed launch leaves NOTHING tracked: the instance and its retained launch
216
- * config are both dropped before the error is rethrown, so the strand id is
217
- * free for a genuine retry. This matches the pre-registration failure path
218
- * (a rejected schema signature, which throws before anything is recorded) —
219
- * both failure modes of this call leave the same residue: none. Callers learn
220
- * of the failure from the rejected promise (and, on the control-discovered
221
- * path, from CadreNode's `strand:error` event), not from an error record left
222
- * behind in `instances`.
223
- *
224
- * The strand's raw storage is resolved HERE, once, and held for the instance's
225
- * lifetime (see `strandStorages`) — `buildStrandRuntime` only reads it, so a
226
- * hibernation wake never re-enters the embedder's provider.
227
- */
228
- async startStrand(config: StartStrandConfig): Promise<StrandInstance> {
229
- const { strandRow, sAppConfig } = config;
230
- const strandId = strandRow.Id;
231
-
232
- if (this.stopping) {
233
- throw new Error('StrandInstanceManager is stopping');
234
- }
235
-
236
- if (this.instances.has(strandId)) {
237
- log('Strand %s already running', strandId);
238
- return this.instances.get(strandId)!;
239
- }
240
-
241
- log('Starting strand instance: %s (sApp: %s v%s)', strandId, sAppConfig.id, sAppConfig.version);
242
- const tTotal = performance.now();
243
-
244
- // Verify schema signature before proceeding (fail-closed by default)
245
- const requireSignature = config.requireSignedSchemas ?? true;
246
- assertSchemaSignature(sAppConfig, { requireSignature });
247
- log('Strand %s sApp schema signature verified (author: %s)', strandId, sAppConfig.id);
248
-
249
- // Convert SAppConfig to SAppInfo for the instance
250
- const sAppInfo: SAppInfo = {
251
- id: sAppConfig.id,
252
- version: sAppConfig.version,
253
- schema: sAppConfig.schema,
254
- signature: sAppConfig.signature
255
- };
256
-
257
- // Resolve this strand's storage ONCE, before anything is recorded. If a factory
258
- // function is provided, it is called with the strandId to create strand-specific
259
- // storage (e.g. strand-isolated directories). A provider that throws therefore
260
- // fails the launch alongside the schema-signature check, leaving nothing tracked.
261
- const strandStorage = resolveStrandStorage(config.storage?.provider, strandId);
262
- if (strandStorage) {
263
- log('Strand %s using provided storage provider', strandId);
264
- }
265
-
266
- // Determine latency hint: sApp config > default
267
- const latencyHint = sAppConfig.latencyHint ?? config.defaultLatencyHint;
268
-
269
- const instance: StrandInstance = {
270
- strandId,
271
- status: 'starting',
272
- sAppInfo,
273
- memberPrivateKey: strandRow.MemberPrivateKey ?? undefined,
274
- connectedPeers: 0,
275
- lastActivity: new Date(),
276
- latencyHint
277
- };
278
-
279
- this.instances.set(strandId, instance);
280
- this.launchConfigs.set(strandId, config);
281
- if (strandStorage) {
282
- this.strandStorages.set(strandId, strandStorage);
283
- }
284
-
285
- try {
286
- await this.buildStrandRuntime(instance, config);
287
- timing('[startStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
288
- log('Strand %s started successfully with sApp %s', strandId, sAppConfig.id);
289
- return instance;
290
- } catch (error) {
291
- // Status/error first — the (now discarded) record is still what `log` reports on.
292
- instance.status = 'error';
293
- instance.error = error instanceof Error ? error.message : String(error);
294
- log('Failed to start strand %s: %s', strandId, instance.error);
295
- // Drop the dead record so this strand id can be launched again. Keep the
296
- // `launchConfigs` has an entry iff `instances` does invariant — resumeStrand
297
- // reads both, and a config without an instance would strand the config.
298
- this.instances.delete(strandId);
299
- this.launchConfigs.delete(strandId);
300
- // The store this launch resolved goes with it: nothing owns it any more, and a
301
- // retained cache wrapper would be handed back (already retired) on a retry.
302
- await this.disposeStrandStorage(strandId);
303
- throw error;
304
- }
305
- }
306
-
307
- /**
308
- * Build (or rebuild) the libp2p node + StrandDatabase for an instance and
309
- * attach them, transitioning it to `active`. Shared by `startStrand` (fresh
310
- * launch) and `resumeStrand` (rehydrating a quiesced instance). Reads all
311
- * volatile inputs (bootstrapNodes, network, profile, privateKey, sApp config)
312
- * from `config`, so the caller controls the cohort-derived values. Storage is the
313
- * one input it does NOT re-read from `config`: that belongs to the instance and
314
- * comes from `strandStorages`.
315
- */
316
- private async buildStrandRuntime(instance: StrandInstance, config: StartStrandConfig): Promise<void> {
317
- const strandId = instance.strandId;
318
- const { sAppConfig } = config;
319
-
320
- // The store the instance OWNS (resolved once in `startStrand`), not a fresh
321
- // resolution: a rebuild must reach the same backend through the same warm cache.
322
- const strandStorage = this.strandStorages.get(strandId);
323
-
324
- // db-p2p namespaces every one of the node's protocol ids by network name
325
- // (`/optimystic/<networkName>/...`), so anything dialing this node's own
326
- // services must derive its prefix from the SAME string the node was built
327
- // with — hence one binding for both, not two literals that can drift.
328
- const networkName = `strand-${strandId}`;
329
- const protocolPrefix = `/optimystic/${networkName}`;
330
-
331
- // Determine relay mode: if explicitly set in config, use that;
332
- // otherwise default to true for storage profile nodes.
333
- const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
334
- const listenAddrs = resolveListenAddrs(config.network);
335
-
336
- try {
337
- let t0 = performance.now();
338
- const node = await createLibp2pNode({
339
- port: 0, // Random port
340
- bootstrapNodes: config.bootstrapNodes ?? [],
341
- networkName,
342
- storage: strandStorage,
343
- fretProfile: config.profile === 'storage' ? 'core' : 'edge',
344
- relay: enableRelay,
345
- clusterSize: resolveStrandClusterSize(config.clusterSize),
346
- // Deliberately NOT CONTROL_CLUSTER_POLICY: a strand is application data with its own
347
- // breadth reasoning, and the shape match with the control policy is a coincidence.
348
- clusterPolicy: STRAND_CLUSTER_POLICY,
349
- arachnode: {
350
- enableRingZulu: config.profile === 'storage'
351
- },
352
- ...(config.privateKey && { privateKey: config.privateKey }),
353
- ...(config.network?.transports && { transports: config.network.transports }),
354
- // NOTE: strand nodes receive the same RESOLVED listen addrs as the
355
- // control node. A fixed-port listen addr (e.g. cadre-cli's example
356
- // `/ip4/0.0.0.0/tcp/4001`) would have control + strand nodes racing to
357
- // bind one port — EADDRINUSE. Unverified; if a deployment configures a
358
- // fixed port and strands fail to start, rewrite the port per node here.
359
- // An inherited `/p2p-circuit` addr, by contrast, is deliberate — it is
360
- // what gives a NAT'd strand node a reachable relay slot — and works
361
- // because the launch path announces this strand's derived peerId to
362
- // the relay first (delegate admission; see cadre-node.ts). Those circuit
363
- // entries come either from a hand-written `network.listenAddrs` or from
364
- // `network.relayAddrs`, which `resolveListenAddrs` folds into the same list.
365
- ...(listenAddrs && { listenAddrs }),
366
- ...(config.network?.connectionGater && { connectionGater: config.network.connectionGater })
367
- }) as Libp2pNodeWithRepo;
368
- timing('[buildStrandRuntime:%s] createLibp2pNode: %dms', strandId, Math.round(performance.now() - t0));
369
-
370
- instance.libp2pNode = node;
371
-
372
- // Create and initialize the StrandDatabase.
373
- //
374
- // Attach before initialize so a failed init is cleaned up by
375
- // releaseRuntime below (close() is safe on a partially-initialized db).
376
- t0 = performance.now();
377
- const strandDb = new StrandDatabase({
378
- strandId,
379
- sAppConfig,
380
- libp2pNode: node,
381
- coordinatedRepo: node.coordinatedRepo,
382
- // Founder bootstrap inputs: the strand's type drives which membership rows
383
- // are written, and the closed-strand MemberPrivateKey derives the founding
384
- // Member/Manager key. Both come off the control-network strand row.
385
- strandType: config.strandRow.Type,
386
- memberPrivateKey: config.strandRow.MemberPrivateKey ?? undefined,
387
- founder: config.founder
388
- });
389
- instance.database = strandDb;
390
- await strandDb.initialize();
391
- timing('[buildStrandRuntime:%s] strandDatabase.initialize: %dms', strandId, Math.round(performance.now() - t0));
392
-
393
- // Peer-join block catch-up: push this strand's own blocks to each newly
394
- // connected peer, so a machine that joined after blocks were committed
395
- // still ends up physically holding them (without per-strand storage there
396
- // is nothing to copy). Armed for EVERY stored strand: `StrandBackfill`
397
- // only does work when the strand's libp2p node reports a peer connection,
398
- // so on a device that is genuinely alone it is inert, and arming it at
399
- // launch is what closes the "founded alone, never replicates" hole — a
400
- // peer that joins later gets the founder's blocks without any relaunch.
401
- //
402
- // NOTE: cost is one StrandBackfill object + one `connection:open` listener
403
- // per running strand — linear in strand count, negligible at the handful a
404
- // device or host runs today. If a node ever hosts strands by the hundred,
405
- // move to one shared listener that dispatches by strand id.
406
- if (strandStorage && config.backfill?.enabled !== false) {
407
- if (node.keyNetwork) {
408
- const backfill = new StrandBackfill({
409
- strandId,
410
- libp2p: node,
411
- peerNetwork: node.keyNetwork,
412
- storage: strandStorage,
413
- // The same prefix the receiver registered its block-transfer handler
414
- // under — derived from networkName above, never re-spelled here.
415
- protocolPrefix
416
- }, config.backfill);
417
- backfill.start();
418
- this.backfills.set(strandId, backfill);
419
- } else {
420
- log('Strand %s: libp2p node exposes no keyNetwork; peer-join block catch-up is inert', strandId);
421
- }
422
- }
423
-
424
- instance.status = 'active';
425
- instance.lastActivity = new Date();
426
- } catch (error) {
427
- // Roll back any partially-attached runtime so the instance is left with
428
- // NEITHER handle. Otherwise the `libp2pNode || database` "already live"
429
- // guard in resumeStrand/handleStrandWake would treat a half-built strand
430
- // as healthy — leaking the libp2p node and never retrying the rebuild.
431
- await this.releaseRuntime(instance).catch((cleanupErr) => {
432
- log('buildStrandRuntime cleanup for strand %s also failed: %o', strandId, cleanupErr);
433
- });
434
- throw error;
435
- }
436
- }
437
-
438
- /**
439
- * Release an instance's strand-network runtime: close the StrandDatabase, then
440
- * stop the libp2p node (construction order in reverse), clearing both fields
441
- * and zeroing connectedPeers. Tolerant of partially-built state — either handle
442
- * may be absent — so it doubles as rollback for a failed `buildStrandRuntime`.
443
- * Shared by `quiesceStrand`, `stopStrand`, and that rollback path.
444
- *
445
- * Leaves `strandStorages` untouched by design — the store outlives the runtime it
446
- * was built into, which is what makes a resume warm. Only `stopStrand` disposes it.
447
- */
448
- private async releaseRuntime(instance: StrandInstance): Promise<void> {
449
- // Backfill first — before the database closes and the libp2p node stops — so
450
- // no NEW catch-up push is issued against a torn-down transport. A push already
451
- // in flight is not awaited; it fails into the module's own per-chunk catch.
452
- const backfill = this.backfills.get(instance.strandId);
453
- if (backfill) {
454
- backfill.stop();
455
- this.backfills.delete(instance.strandId);
456
- }
457
- if (instance.database) {
458
- await instance.database.close();
459
- instance.database = undefined;
460
- }
461
- if (instance.libp2pNode) {
462
- await instance.libp2pNode.stop();
463
- instance.libp2pNode = undefined;
464
- }
465
- instance.connectedPeers = 0;
466
- }
467
-
468
- /**
469
- * Drop the strand's owned store and release its cache registration with the shared
470
- * pool. Called only where the strand's whole lifetime ends (`stopStrand`, and the
471
- * failed-launch rollback) — never on a quiesce.
472
- *
473
- * A dispose failure is logged, not thrown: the store is already unreferenced here,
474
- * and failing the stop over a cache-bookkeeping error would leave the caller unable
475
- * to tear the strand down. `disposeStorageCache` no-ops for an unwrapped store
476
- * (e.g. `MemoryRawStorage`), so no instanceof test is needed.
477
- */
478
- private async disposeStrandStorage(strandId: string): Promise<void> {
479
- const storage = this.strandStorages.get(strandId);
480
- if (!storage) {
481
- return;
482
- }
483
- this.strandStorages.delete(strandId);
484
- try {
485
- await disposeStorageCache(storage);
486
- } catch (error) {
487
- log('Failed to dispose storage cache for strand %s: %o', strandId, error);
488
- }
489
- }
490
-
491
- /**
492
- * Quiesce a strand: release its strand-network resources (stop the libp2p node,
493
- * close the StrandDatabase) while RETAINING the instance record — identity,
494
- * sAppInfo, keys, latency hint, metadata — and its launch config so it can be
495
- * resumed later. Mechanically this is `stopStrand` minus the instance/config
496
- * deletion. The caller sets the post-quiesce status (e.g. `hibernating`).
497
- * No-ops when the strand is missing or already quiesced.
498
- */
499
- async quiesceStrand(strandId: string): Promise<void> {
500
- const instance = this.instances.get(strandId);
501
- if (!instance) {
502
- log('quiesceStrand: strand %s not found', strandId);
503
- return;
504
- }
505
- if (!instance.libp2pNode && !instance.database) {
506
- log('quiesceStrand: strand %s already quiesced', strandId);
507
- return;
508
- }
509
-
510
- log('Quiescing strand instance: %s', strandId);
511
- await this.releaseRuntime(instance);
512
- log('Strand %s quiesced (resources released, instance retained)', strandId);
513
- }
514
-
515
- /**
516
- * Resume a previously-quiesced strand: rebuild its libp2p node + StrandDatabase
517
- * from the retained launch config and re-attach them, transitioning it back to
518
- * `active`. `overrides` re-applies volatile inputs that may have changed since
519
- * launch (the cohort `bootstrapNodes` seed) and updates the retained config so
520
- * a later resume reuses the latest values. Returns the live instance unchanged
521
- * if it is already running.
522
- */
523
- async resumeStrand(strandId: string, overrides?: ResumeStrandOverrides): Promise<StrandInstance> {
524
- if (this.stopping) {
525
- throw new Error('StrandInstanceManager is stopping');
526
- }
527
- const instance = this.instances.get(strandId);
528
- if (!instance) {
529
- throw new Error(`Cannot resume strand ${strandId}: not tracked`);
530
- }
531
- const launchConfig = this.launchConfigs.get(strandId);
532
- if (!launchConfig) {
533
- throw new Error(`Cannot resume strand ${strandId}: no retained launch config`);
534
- }
535
- if (instance.libp2pNode || instance.database) {
536
- log('resumeStrand: strand %s already live', strandId);
537
- return instance;
538
- }
539
-
540
- log('Resuming strand instance: %s', strandId);
541
- const tTotal = performance.now();
542
-
543
- // Re-apply volatile inputs and persist them so a subsequent resume reuses them.
544
- const resumeConfig: StartStrandConfig = {
545
- ...launchConfig,
546
- bootstrapNodes: overrides?.bootstrapNodes ?? launchConfig.bootstrapNodes
547
- };
548
- this.launchConfigs.set(strandId, resumeConfig);
549
-
550
- instance.status = 'starting';
551
- try {
552
- await this.buildStrandRuntime(instance, resumeConfig);
553
- timing('[resumeStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
554
- log('Strand %s resumed successfully', strandId);
555
- return instance;
556
- } catch (error) {
557
- instance.status = 'error';
558
- instance.error = error instanceof Error ? error.message : String(error);
559
- log('Failed to resume strand %s: %s', strandId, instance.error);
560
- throw error;
561
- }
562
- }
563
-
564
- /**
565
- * Stop a strand instance
566
- */
567
- async stopStrand(strandId: string): Promise<void> {
568
- const instance = this.instances.get(strandId);
569
- if (!instance) {
570
- log('Strand %s not found', strandId);
571
- return;
572
- }
573
-
574
- log('Stopping strand instance: %s', strandId);
575
- instance.status = 'stopping';
576
-
577
- try {
578
- await this.releaseRuntime(instance);
579
- instance.status = 'stopped';
580
- this.instances.delete(strandId);
581
- this.launchConfigs.delete(strandId);
582
- // Storage is released only here — NOT in releaseRuntime, which a quiesce shares.
583
- await this.disposeStrandStorage(strandId);
584
- log('Strand %s stopped successfully', strandId);
585
- } catch (error) {
586
- instance.status = 'error';
587
- instance.error = error instanceof Error ? error.message : String(error);
588
- log('Error stopping strand %s: %s', strandId, instance.error);
589
- throw error;
590
- }
591
- }
592
-
593
- /**
594
- * Stop all strand instances
595
- */
596
- async stopAll(): Promise<void> {
597
- this.stopping = true;
598
- log('Stopping all strand instances (%d)', this.instances.size);
599
-
600
- const stopPromises = Array.from(this.instances.keys()).map(id =>
601
- this.stopStrand(id).catch(err => {
602
- log('Error stopping strand %s during shutdown: %s', id, err);
603
- })
604
- );
605
-
606
- await Promise.all(stopPromises);
607
- this.stopping = false;
608
- log('All strand instances stopped');
609
- }
610
- }
611
-
1
+ import debug from 'debug';
2
+ import type { PrivateKey } from '@libp2p/interface';
3
+ import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
4
+ import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
5
+ import { StrandDatabase } from './strand-database.js';
6
+ import { PeerJoinBackfill, type PeerJoinBackfillConfig } from './peer-join-backfill.js';
7
+ import {
8
+ StrandRevocationEnforcer,
9
+ createRevocationConnectionGater,
10
+ readStrandRevocationRows,
11
+ type StrandRevocationEnforcementConfig
12
+ } from './strand-revocation-enforcer.js';
13
+ import { assertSchemaSignature } from './schema-verification.js';
14
+ import type {
15
+ StrandInstance,
16
+ StrandRow,
17
+ StorageConfig,
18
+ NetworkConfig,
19
+ LatencyHint,
20
+ NodeProfile,
21
+ SAppConfig,
22
+ SAppInfo,
23
+ RawStorageProvider,
24
+ Libp2pNodeWithRepo
25
+ } from './types.js';
26
+ import { resolveStrandClusterSize, strandClusterPolicy } from './types.js';
27
+ import { strandNodeAddrs } from './strand-network-config.js';
28
+
29
+ const log = debug('sereus:cadre:strand-manager');
30
+ const timing = debug('sereus:cadre:timing');
31
+
32
+ /**
33
+ * Configuration for starting a strand instance
34
+ */
35
+ export interface StartStrandConfig {
36
+ strandRow: StrandRow;
37
+ /** sApp configuration provided by the hosting application */
38
+ sAppConfig: SAppConfig;
39
+ storage?: StorageConfig;
40
+ network?: NetworkConfig;
41
+ profile: NodeProfile;
42
+ defaultLatencyHint: LatencyHint;
43
+ privateKey?: PrivateKey;
44
+ /** Cohort-derived discovery seed (multiaddr strings). Defaults to [] when omitted. */
45
+ bootstrapNodes?: string[];
46
+ /**
47
+ * Require a valid author signature on the sApp schema before bring-up.
48
+ * Defaults to true (fail closed) when omitted; set false only for dev/test
49
+ * with unsigned demo schemas. Mirrors {@link CadreNodeConfig.requireSignedSchemas}.
50
+ */
51
+ requireSignedSchemas?: boolean;
52
+ /**
53
+ * Whether this node founds the strand (vs. joins it). Forwarded to the
54
+ * StrandDatabase so the founder bootstrap (Header, founding Member/Manager)
55
+ * runs once at bring-up. Joiners leave this unset and write nothing. Callers
56
+ * resolve it BEFORE calling `startStrand` (`CadreNode.launchStrand` derives it
57
+ * from the row's `FounderOwnerKey` when no explicit flag is given — see
58
+ * {@link StrandConfig.founder}); a founder request that arrives while the
59
+ * instance is already tracked goes through {@link StrandInstanceManager.foundExistingStrand},
60
+ * never through a repeat `startStrand`.
61
+ */
62
+ founder?: boolean;
63
+ /**
64
+ * Number of nodes Optimystic is told this strand's replication cluster should
65
+ * have. Same rule as {@link CadreNodeConfig.strandClusterSize}, which CadreNode
66
+ * forwards here: every node on the strand should use the same value, and it is
67
+ * frozen when the strand's libp2p node is created. Defaults to
68
+ * `DEFAULT_STRAND_CLUSTER_SIZE` (4); values below `MIN_CLUSTER_SIZE` (2) are
69
+ * rejected by `resolveStrandClusterSize`. Strand-only — the
70
+ * control network's breadth is the fixed `CONTROL_REPLICATION_BREADTH`.
71
+ */
72
+ clusterSize?: number;
73
+ /**
74
+ * Machines that SERVE THIS STRAND (this node included), for the strand node's
75
+ * block-repair corroboration yardstick — see `resolveRepairYardstick`. It must be
76
+ * an authenticated per-strand count: the machines that actually run a node for this
77
+ * strand, not the machines that exist.
78
+ *
79
+ * **The party's enrolled-machine count is NOT this number and must never be passed
80
+ * here.** A strand launches only on machines whose embedding app registered its sApp
81
+ * config (`CadreNode.addStrand`), so a closed strand shared by two machines of a
82
+ * three-machine party is served by two. Passing three over-declares, and
83
+ * over-declaring is the unsafe direction: at a declaration of 3 or more Optimystic
84
+ * pins the repair corroboration floor at two corroborating peers, which a cohort
85
+ * that can only ever field one peer can never reach — so the strand can never repair
86
+ * a block (`cluster-fetch:no-quorum`, surfacing as reads failing with `Missing
87
+ * block`). That regression is why this field was renamed off "enrolledMachines":
88
+ * `bug-strand-yardstick-counts-party-machines`.
89
+ *
90
+ * **Nothing feeds it in production today.** No authenticated per-strand serving count
91
+ * exists yet, so `CadreNode` passes nothing and the strand node runs the frozen
92
+ * `STRAND_CLUSTER_POLICY` — declaring no yardstick, which leaves the known,
93
+ * upstream-tracked single-voter exposure
94
+ * (`backlog/debt-read-repair-single-voter-corroboration`). Building the count is
95
+ * `backlog/feat-strand-yardstick-from-serving-machines`; this field and its
96
+ * threading are the seam it plugs into.
97
+ *
98
+ * Volatile when a source does exist: re-resolve it on every resume beside the cohort
99
+ * seed, since machines join and leave a strand while it hibernates. Omitting it means
100
+ * "this node does not know", which declares nothing.
101
+ */
102
+ servingMachines?: number;
103
+ /**
104
+ * Tuning for the strand peer-join block catch-up ({@link PeerJoinBackfill}),
105
+ * forwarded from {@link CadreNodeConfig.strandBackfill}. When the strand's
106
+ * libp2p node connects to a peer this runtime has not yet caught up, every
107
+ * block in the strand's own raw store is pushed to it. Runs only on strands
108
+ * with per-strand storage; `{ enabled: false }` restores the pre-existing
109
+ * no-backfill behaviour.
110
+ */
111
+ backfill?: PeerJoinBackfillConfig;
112
+ /**
113
+ * Tuning for the CLOSED-strand revoked-peer gate
114
+ * ({@link StrandRevocationEnforcer}), forwarded from
115
+ * {@link CadreNodeConfig.strandRevocationEnforcement}. Armed only for
116
+ * `strandRow.Type === 'c'` (an open strand has no membership rows to derive a
117
+ * deny set from); `{ enabled: false }` restores the pre-existing behaviour
118
+ * (a removed party's peers keep being served).
119
+ */
120
+ revocationEnforcement?: StrandRevocationEnforcementConfig;
121
+
122
+ /**
123
+ * Called when THIS node's own peer id turns up in the strand's revoked set —
124
+ * this node's party was removed from the closed strand, or left it. Fires once
125
+ * per removal (re-armed if the party is re-admitted; a resume also rebuilds the
126
+ * enforcer and may legitimately re-fire), and only for closed strands with the
127
+ * gate armed. `CadreNode`
128
+ * wires it to its `strand:revoked` event; nothing is stopped or torn down on
129
+ * this node's behalf — what to do about it is the app's call.
130
+ */
131
+ onSelfRevoked?: (strandId: string) => void;
132
+ }
133
+
134
+ /**
135
+ * Volatile inputs re-resolved when resuming a quiesced strand — the ones that can
136
+ * have moved since it last ran, and that the rebuilt libp2p node freezes again. Each
137
+ * one omitted keeps the value the retained launch config already holds, so a resume
138
+ * that passes nothing rebuilds the strand exactly as it last ran.
139
+ */
140
+ export interface ResumeStrandOverrides {
141
+ /**
142
+ * Freshly-resolved cohort discovery seed (multiaddr strings). Grows as peers are
143
+ * learned since the strand first launched.
144
+ */
145
+ bootstrapNodes?: string[];
146
+ /**
147
+ * Freshly-read count of the machines serving this strand, for the repair yardstick
148
+ * (see {@link StartStrandConfig.servingMachines} — including why the party's
149
+ * enrolled-machine count is not it). Moves whenever a machine starts or stops
150
+ * serving the strand, which a hibernating strand does not otherwise notice. Nothing
151
+ * passes it today. Omitting it here does NOT mean "unknown" the way omitting it from
152
+ * {@link StartStrandConfig} does — it retains the last value; see `resumeStrand`.
153
+ */
154
+ servingMachines?: number;
155
+ }
156
+
157
+ /**
158
+ * Get the isolated storage path for a specific strand.
159
+ *
160
+ * @deprecated This helper is Node-only and throws in React Native (it assumes a
161
+ * filesystem layout). Use a storage provider factory function instead, which
162
+ * receives the strandId and can create strand-specific storage paths using
163
+ * platform-appropriate methods.
164
+ *
165
+ * @example
166
+ * // Instead of using getStrandStoragePath, use a storage provider factory:
167
+ * const storage = {
168
+ * provider: (strandId: string) => new FileRawStorage(`./data/strands/${strandId}`)
169
+ * };
170
+ */
171
+ export function getStrandStoragePath(basePath: string, strandId: string): string {
172
+ // Check if we're in a Node.js environment
173
+ if (typeof process === 'undefined' || !process.versions?.node) {
174
+ throw new Error(
175
+ 'getStrandStoragePath is not available in React Native. ' +
176
+ 'Use a storage provider factory function instead.'
177
+ );
178
+ }
179
+
180
+ // Sanitize strandId for filesystem safety (UUIDs should be safe, but just in case)
181
+ const safeId = strandId.replace(/[^a-zA-Z0-9-]/g, '_');
182
+
183
+ // Build the path with plain string joins rather than the Node `path` module.
184
+ // A static `require('path')` forces RN bundlers (e.g. Metro) to *resolve* the
185
+ // module at bundle time even though this Node-only helper throws above before
186
+ // ever reaching here — joining by hand keeps the module free of any Node
187
+ // built-in reference, so RN bundles need no `path` shim.
188
+ const trimmedBase = basePath.replace(/[\\/]+$/, '');
189
+ return `${trimmedBase}/strands/${safeId}`;
190
+ }
191
+
192
+ /**
193
+ * Resolve a storage provider for a specific strand.
194
+ * If the provider is a factory function, call it with the strandId.
195
+ *
196
+ * Called only from {@link StrandInstanceManager.startStrand}, which owns the result
197
+ * for the instance's lifetime — see `strandStorages`.
198
+ *
199
+ * @param provider - Storage provider (instance or factory)
200
+ * @param strandId - The strand ID to create storage for
201
+ * @returns The resolved IRawStorage instance, or undefined if no provider
202
+ */
203
+ function resolveStrandStorage(
204
+ provider: RawStorageProvider | undefined,
205
+ strandId: string
206
+ ): IRawStorage | undefined {
207
+ if (!provider) {
208
+ return undefined;
209
+ }
210
+ const storage = typeof provider === 'function' ? provider(strandId) : provider;
211
+ // Wrapped in the write-through raw-storage cache (quereus-plugin-sereus's cached-storage.ts).
212
+ // Called ONCE per strand launch — `startStrand` keeps the result for the instance's
213
+ // lifetime — so the wrap survives every runtime rebuild the instance goes through.
214
+ return wrapStorageWithCache(storage, strandId);
215
+ }
216
+
217
+ /**
218
+ * Manages individual strand instances - creates and destroys isolated libp2p nodes
219
+ * for each strand the cadre participates in.
220
+ */
221
+ export class StrandInstanceManager {
222
+ private instances: Map<string, StrandInstance> = new Map();
223
+ /**
224
+ * Retained launch config per strand, captured in `startStrand` and cleared in
225
+ * `stopStrand`. `resumeStrand` reuses it to rebuild a quiesced strand's runtime
226
+ * without the caller re-threading storage/network/profile/key/sApp config.
227
+ */
228
+ private launchConfigs: Map<string, StartStrandConfig> = new Map();
229
+ /**
230
+ * The per-strand peer-join block catch-up, keyed by strand id. Private (not on
231
+ * the public {@link StrandInstance}) because it is runtime plumbing with the
232
+ * same lifetime as the strand's libp2p node: created in `buildStrandRuntime`,
233
+ * stopped and dropped in `releaseRuntime` — so quiesce → resume rebuilds it
234
+ * with a fresh caught-up-peer memo, which is intended (a resumed node may have
235
+ * missed writes).
236
+ */
237
+ private backfills: Map<string, PeerJoinBackfill> = new Map();
238
+ /**
239
+ * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
240
+ * id. Same lifecycle rationale as {@link backfills}: created in
241
+ * `buildStrandRuntime`, stopped and dropped in `releaseRuntime`, so quiesce →
242
+ * resume rebuilds it with a fresh (initially empty, fail-open) snapshot.
243
+ */
244
+ private revocationEnforcers: Map<string, StrandRevocationEnforcer> = new Map();
245
+ /**
246
+ * The resolved (cache-wrapped) raw storage per strand id — the instance's OWN
247
+ * store, resolved once in `startStrand` and held until `stopStrand` disposes it.
248
+ * Private for the same reason `backfills` is: runtime plumbing, not part of the
249
+ * public {@link StrandInstance}.
250
+ *
251
+ * Deliberately NOT touched by `releaseRuntime`: that is what lets a quiesce →
252
+ * resume cycle rebuild the libp2p node over the SAME store, keeping its
253
+ * write-through cache warm (and, on an in-memory backend, keeping the strand's
254
+ * blocks at all). An entry exists iff `instances` does AND the launch config
255
+ * supplied a storage provider.
256
+ *
257
+ * NOTE: a hibernating strand therefore keeps its store — and its share of the
258
+ * process-wide cache pool — resident for as long as the instance is tracked. That
259
+ * is the point (a warm wake), and the pool evicts under pressure, so the cost is
260
+ * one map entry per hibernating strand today. If a device ever hibernates strands
261
+ * by the hundred, revisit: dropping the store at quiesce and paying for a cold
262
+ * wake becomes the better trade.
263
+ */
264
+ private strandStorages: Map<string, IRawStorage> = new Map();
265
+ private stopping = false;
266
+
267
+ constructor() {
268
+ log('StrandInstanceManager created');
269
+ }
270
+
271
+ /**
272
+ * Get all current strand instances
273
+ */
274
+ getInstances(): Map<string, StrandInstance> {
275
+ return new Map(this.instances);
276
+ }
277
+
278
+ /**
279
+ * Get a specific strand instance
280
+ */
281
+ getInstance(strandId: string): StrandInstance | undefined {
282
+ return this.instances.get(strandId);
283
+ }
284
+
285
+ /**
286
+ * Check if a strand is currently running
287
+ */
288
+ hasStrand(strandId: string): boolean {
289
+ return this.instances.has(strandId);
290
+ }
291
+
292
+ /**
293
+ * Start a new strand instance.
294
+ *
295
+ * A failed launch leaves NOTHING tracked: the instance and its retained launch
296
+ * config are both dropped before the error is rethrown, so the strand id is
297
+ * free for a genuine retry. This matches the pre-registration failure path
298
+ * (a rejected schema signature, which throws before anything is recorded) —
299
+ * both failure modes of this call leave the same residue: none. Callers learn
300
+ * of the failure from the rejected promise (and, on the control-discovered
301
+ * path, from CadreNode's `strand:error` event), not from an error record left
302
+ * behind in `instances`.
303
+ *
304
+ * The strand's raw storage is resolved HERE, once, and held for the instance's
305
+ * lifetime (see `strandStorages`) — `buildStrandRuntime` only reads it, so a
306
+ * hibernation wake never re-enters the embedder's provider.
307
+ */
308
+ async startStrand(config: StartStrandConfig): Promise<StrandInstance> {
309
+ const { strandRow, sAppConfig } = config;
310
+ const strandId = strandRow.Id;
311
+
312
+ if (this.stopping) {
313
+ throw new Error('StrandInstanceManager is stopping');
314
+ }
315
+
316
+ if (this.instances.has(strandId)) {
317
+ // Callers resolve founder-ness BEFORE reaching here (CadreNode.launchStrand) and
318
+ // honor a founder request on a tracked instance via foundExistingStrand — so a
319
+ // founder flag arriving at this early return against a non-founder retained
320
+ // config is a dropped bootstrap, and must never again be silent.
321
+ if (config.founder === true && this.launchConfigs.get(strandId)?.founder !== true) {
322
+ log('startStrand: strand %s is already running but was NOT launched as founder — ' +
323
+ 'this early return DROPS the founder request; use foundExistingStrand', strandId);
324
+ }
325
+ log('Strand %s already running', strandId);
326
+ return this.instances.get(strandId)!;
327
+ }
328
+
329
+ log('Starting strand instance: %s (sApp: %s v%s)', strandId, sAppConfig.id, sAppConfig.version);
330
+ const tTotal = performance.now();
331
+
332
+ // Verify schema signature before proceeding (fail-closed by default)
333
+ const requireSignature = config.requireSignedSchemas ?? true;
334
+ assertSchemaSignature(sAppConfig, { requireSignature });
335
+ log('Strand %s sApp schema signature verified (author: %s)', strandId, sAppConfig.id);
336
+
337
+ // Convert SAppConfig to SAppInfo for the instance
338
+ const sAppInfo: SAppInfo = {
339
+ id: sAppConfig.id,
340
+ version: sAppConfig.version,
341
+ schema: sAppConfig.schema,
342
+ signature: sAppConfig.signature
343
+ };
344
+
345
+ // Resolve this strand's storage ONCE, before anything is recorded. If a factory
346
+ // function is provided, it is called with the strandId to create strand-specific
347
+ // storage (e.g. strand-isolated directories). A provider that throws therefore
348
+ // fails the launch alongside the schema-signature check, leaving nothing tracked.
349
+ const strandStorage = resolveStrandStorage(config.storage?.provider, strandId);
350
+ if (strandStorage) {
351
+ log('Strand %s using provided storage provider', strandId);
352
+ }
353
+
354
+ // Determine latency hint: sApp config > default
355
+ const latencyHint = sAppConfig.latencyHint ?? config.defaultLatencyHint;
356
+
357
+ const instance: StrandInstance = {
358
+ strandId,
359
+ status: 'starting',
360
+ sAppInfo,
361
+ memberPrivateKey: strandRow.MemberPrivateKey ?? undefined,
362
+ connectedPeers: 0,
363
+ lastActivity: new Date(),
364
+ latencyHint
365
+ };
366
+
367
+ this.instances.set(strandId, instance);
368
+ this.launchConfigs.set(strandId, config);
369
+ if (strandStorage) {
370
+ this.strandStorages.set(strandId, strandStorage);
371
+ }
372
+
373
+ try {
374
+ await this.buildStrandRuntime(instance, config);
375
+ timing('[startStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
376
+ log('Strand %s started successfully with sApp %s', strandId, sAppConfig.id);
377
+ return instance;
378
+ } catch (error) {
379
+ // Status/error first — the (now discarded) record is still what `log` reports on.
380
+ instance.status = 'error';
381
+ instance.error = error instanceof Error ? error.message : String(error);
382
+ log('Failed to start strand %s: %s', strandId, instance.error);
383
+ // Drop the dead record so this strand id can be launched again. Keep the
384
+ // `launchConfigs` has an entry iff `instances` does invariant — resumeStrand
385
+ // reads both, and a config without an instance would strand the config.
386
+ this.instances.delete(strandId);
387
+ this.launchConfigs.delete(strandId);
388
+ // The store this launch resolved goes with it: nothing owns it any more, and a
389
+ // retained cache wrapper would be handed back (already retired) on a retry.
390
+ await this.disposeStrandStorage(strandId);
391
+ throw error;
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Build (or rebuild) the libp2p node + StrandDatabase for an instance and
397
+ * attach them, transitioning it to `active`. Shared by `startStrand` (fresh
398
+ * launch) and `resumeStrand` (rehydrating a quiesced instance). Reads all
399
+ * volatile inputs (bootstrapNodes, servingMachines, network, profile,
400
+ * privateKey, sApp config) from `config`, so the caller controls the
401
+ * cohort-derived values. Storage is the
402
+ * one input it does NOT re-read from `config`: that belongs to the instance and
403
+ * comes from `strandStorages`.
404
+ */
405
+ private async buildStrandRuntime(instance: StrandInstance, config: StartStrandConfig): Promise<void> {
406
+ const strandId = instance.strandId;
407
+ const { sAppConfig } = config;
408
+
409
+ // The store the instance OWNS (resolved once in `startStrand`), not a fresh
410
+ // resolution: a rebuild must reach the same backend through the same warm cache.
411
+ const strandStorage = this.strandStorages.get(strandId);
412
+
413
+ // db-p2p namespaces every one of the node's protocol ids by network name
414
+ // (`/optimystic/<networkName>/...`), so anything dialing this node's own
415
+ // services must derive its prefix from the SAME string the node was built
416
+ // with — hence one binding for both, not two literals that can drift.
417
+ const networkName = `strand-${strandId}`;
418
+ const protocolPrefix = `/optimystic/${networkName}`;
419
+
420
+ // Determine relay mode: if explicitly set in config, use that;
421
+ // otherwise default to true for storage profile nodes.
422
+ const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
423
+ // The CONFIGURED relay route (the default), deliberately NOT the control node's
424
+ // `'search'` route: nothing drives an explicit reservation for a strand node, so
425
+ // a bare `/p2p-circuit` search entry would register a pending reservation that
426
+ // never gets filled and leave the strand node undialable. The ordering hazard
427
+ // that pushed the control node onto the search route does not exist here — a
428
+ // strand node's protocol ids are namespaced `/optimystic/strand-<id>/…`, so a
429
+ // relay dialed from inside `libp2p.start()` is never in the strand's cohort and
430
+ // cannot refuse its database bring-up. See `relay-addrs.ts`.
431
+ //
432
+ // The strand-node VIEW of the machine's one `NetworkConfig`, not the control
433
+ // node's resolution: fixed direct listen ports become ephemeral (two nodes cannot
434
+ // bind one port) and the announce config is dropped (it names the control node's
435
+ // address). See `strand-network-config.ts` for both, and for the tradeoff.
436
+ const addrOptions = strandNodeAddrs(config.network);
437
+
438
+ // The CLOSED-strand revoked-peer gate (see strand-revocation-enforcer.ts).
439
+ // Constructed BEFORE the libp2p node because the node's options embed its
440
+ // predicates; its deny set starts empty (admit everything — fail-open, same
441
+ // posture as bring-up everywhere here) and is first populated after the
442
+ // strand database initializes below. Registered in the map immediately so
443
+ // the failure rollback (releaseRuntime) tears it down like every other
444
+ // runtime component. Open strands skip it entirely: their Member/MemberPeer
445
+ // tables are empty by schema (`OnlyClosed`), so there is nothing to derive
446
+ // a deny set from — arming would be pointless polling.
447
+ const revocationEnforcer =
448
+ config.strandRow.Type === 'c' && config.revocationEnforcement?.enabled !== false
449
+ ? new StrandRevocationEnforcer({
450
+ label: strandId,
451
+ readRows: () => {
452
+ const database = instance.database;
453
+ if (!database) {
454
+ throw new Error(`strand ${strandId} has no live database`);
455
+ }
456
+ return readStrandRevocationRows(database.getDatabase());
457
+ },
458
+ // Read per sweep, never captured: this closure is built BEFORE the
459
+ // libp2p node exists (its options embed the enforcer's predicates),
460
+ // and `releaseRuntime` clears the field again on quiesce. Undefined
461
+ // therefore means "no transport right now", which the enforcer
462
+ // treats as "nothing to tear down".
463
+ getNetwork: () => instance.libp2pNode,
464
+ onSelfRevoked: () => config.onSelfRevoked?.(strandId)
465
+ }, config.revocationEnforcement)
466
+ : undefined;
467
+ if (revocationEnforcer) {
468
+ this.revocationEnforcers.set(strandId, revocationEnforcer);
469
+ }
470
+
471
+ // Closed strands compose revoked-peer denial onto the caller's gater and arm
472
+ // the fail-closed per-stream gate; open strands keep the raw configured gater
473
+ // (their peers are legitimately cross-party and nothing is ever revoked).
474
+ const revocationGateOptions = revocationEnforcer
475
+ ? {
476
+ connectionGater: createRevocationConnectionGater(revocationEnforcer, config.network?.connectionGater),
477
+ authorizeInboundStream: (remotePeerId: string, protocol: string) =>
478
+ revocationEnforcer.authorizeStream(remotePeerId, protocol)
479
+ }
480
+ : config.network?.connectionGater
481
+ ? { connectionGater: config.network.connectionGater }
482
+ : {};
483
+
484
+ try {
485
+ // Bound once: the breadth is also the ceiling on the repair yardstick below, and the
486
+ // two must be derived from the same resolution. Inside the `try` deliberately — a
487
+ // rejected clusterSize is a build failure that runs the same cleanup as any other.
488
+ const strandClusterSize = resolveStrandClusterSize(config.clusterSize);
489
+
490
+ let t0 = performance.now();
491
+ const node = await createLibp2pNode({
492
+ port: 0, // Random port
493
+ bootstrapNodes: config.bootstrapNodes ?? [],
494
+ networkName,
495
+ storage: strandStorage,
496
+ fretProfile: config.profile === 'storage' ? 'core' : 'edge',
497
+ relay: enableRelay,
498
+ clusterSize: strandClusterSize,
499
+ // Deliberately NOT CONTROL_CLUSTER_POLICY: a strand is application data with its own
500
+ // breadth reasoning, and the shape match with the control policy is a coincidence.
501
+ //
502
+ // The builder declares this node's block-repair corroboration yardstick from the
503
+ // count of machines SERVING this strand, capped at the breadth above (a block
504
+ // never lives on more machines than the cohort is wide). Given no count — the
505
+ // production path today, since no per-strand serving count exists yet — it
506
+ // returns the frozen STRAND_CLUSTER_POLICY itself, declaring nothing. Resolved
507
+ // HERE rather than at `startStrand`, so a wake from hibernation would pick up a
508
+ // serving set that changed while the strand slept.
509
+ clusterPolicy: strandClusterPolicy(strandClusterSize, config.servingMachines),
510
+ arachnode: {
511
+ enableRingZulu: config.profile === 'storage'
512
+ },
513
+ ...(config.privateKey && { privateKey: config.privateKey }),
514
+ ...(config.network?.transports && { transports: config.network.transports }),
515
+ // Listen entries plus the WebSocket transport switch they imply — a strand node
516
+ // announces nothing the operator configured (`strand-network-config.ts`), and
517
+ // spreads AFTER `transports` above because the switch is a no-op whenever the
518
+ // embedder supplied its own factories. An inherited configured `/p2p-circuit` entry is
519
+ // deliberate and survives the derivation untouched: it is what gives a NAT'd
520
+ // strand node a reachable relay slot, and it works because the launch path
521
+ // announces this strand's derived peerId to the relay first (delegate admission;
522
+ // see cadre-node.ts). Those circuit entries come either from a hand-written
523
+ // `network.listenAddrs` or from `network.relayAddrs`, which the resolution folds
524
+ // into the same list on the configured route this call takes (see above).
525
+ ...addrOptions,
526
+ // The raw configured gater (open strands), or the revocation-composed
527
+ // gater plus the fail-closed per-stream revoked-peer gate (closed
528
+ // strands) — resolved above, before the try.
529
+ ...revocationGateOptions
530
+ }) as Libp2pNodeWithRepo;
531
+ timing('[buildStrandRuntime:%s] createLibp2pNode: %dms', strandId, Math.round(performance.now() - t0));
532
+
533
+ instance.libp2pNode = node;
534
+
535
+ // Create and initialize the StrandDatabase.
536
+ //
537
+ // Attach before initialize so a failed init is cleaned up by
538
+ // releaseRuntime below (close() is safe on a partially-initialized db).
539
+ t0 = performance.now();
540
+ const strandDb = new StrandDatabase({
541
+ strandId,
542
+ sAppConfig,
543
+ libp2pNode: node,
544
+ coordinatedRepo: node.coordinatedRepo,
545
+ // Founder bootstrap inputs: the strand's type drives which membership rows
546
+ // are written, and the closed-strand MemberPrivateKey derives the founding
547
+ // Member/Manager key. Both come off the control-network strand row.
548
+ strandType: config.strandRow.Type,
549
+ memberPrivateKey: config.strandRow.MemberPrivateKey ?? undefined,
550
+ founder: config.founder
551
+ });
552
+ instance.database = strandDb;
553
+ await strandDb.initialize();
554
+ timing('[buildStrandRuntime:%s] strandDatabase.initialize: %dms', strandId, Math.round(performance.now() - t0));
555
+
556
+ // Arm the revoked-peer deny-set poll now that the database it reads
557
+ // exists. start() kicks an immediate refresh WITHOUT awaiting it —
558
+ // bring-up and resume are never blocked on a membership read; until the
559
+ // first read lands the empty snapshot admits everyone (fail-open).
560
+ revocationEnforcer?.start();
561
+
562
+ // Peer-join block catch-up: push this strand's own blocks to each newly
563
+ // connected peer, so a machine that joined after blocks were committed
564
+ // still ends up physically holding them (without per-strand storage there
565
+ // is nothing to copy). Armed for EVERY stored strand: `PeerJoinBackfill`
566
+ // only does work when the strand's libp2p node reports a peer connection,
567
+ // so on a device that is genuinely alone it is inert, and arming it at
568
+ // launch is what closes the "founded alone, never replicates" hole — a
569
+ // peer that joins later gets the founder's blocks without any relaunch.
570
+ // No `authorizePeer` gate, deliberately — see the module comment in
571
+ // peer-join-backfill.ts for the strand-side argument (and why the control
572
+ // network, which DOES gate, is different).
573
+ //
574
+ // NOTE: cost is one PeerJoinBackfill object + one `connection:open` listener
575
+ // per running strand — linear in strand count, negligible at the handful a
576
+ // device or host runs today. If a node ever hosts strands by the hundred,
577
+ // move to one shared listener that dispatches by strand id.
578
+ if (strandStorage && config.backfill?.enabled !== false) {
579
+ if (node.keyNetwork) {
580
+ const backfill = new PeerJoinBackfill({
581
+ label: strandId,
582
+ libp2p: node,
583
+ peerNetwork: node.keyNetwork,
584
+ storage: strandStorage,
585
+ // The same prefix the receiver registered its block-transfer handler
586
+ // under — derived from networkName above, never re-spelled here.
587
+ protocolPrefix
588
+ }, config.backfill);
589
+ backfill.start();
590
+ this.backfills.set(strandId, backfill);
591
+ } else {
592
+ log('Strand %s: libp2p node exposes no keyNetwork; peer-join block catch-up is inert', strandId);
593
+ }
594
+ }
595
+
596
+ instance.status = 'active';
597
+ instance.lastActivity = new Date();
598
+ } catch (error) {
599
+ // Roll back any partially-attached runtime so the instance is left with
600
+ // NEITHER handle. Otherwise the `libp2pNode || database` "already live"
601
+ // guard in resumeStrand/handleStrandWake would treat a half-built strand
602
+ // as healthy — leaking the libp2p node and never retrying the rebuild.
603
+ await this.releaseRuntime(instance).catch((cleanupErr) => {
604
+ log('buildStrandRuntime cleanup for strand %s also failed: %o', strandId, cleanupErr);
605
+ });
606
+ throw error;
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Release an instance's strand-network runtime: close the StrandDatabase, then
612
+ * stop the libp2p node (construction order in reverse), clearing both fields
613
+ * and zeroing connectedPeers. Tolerant of partially-built state — either handle
614
+ * may be absent — so it doubles as rollback for a failed `buildStrandRuntime`.
615
+ * Shared by `quiesceStrand`, `stopStrand`, and that rollback path.
616
+ *
617
+ * Leaves `strandStorages` untouched by design — the store outlives the runtime it
618
+ * was built into, which is what makes a resume warm. Only `stopStrand` disposes it.
619
+ */
620
+ private async releaseRuntime(instance: StrandInstance): Promise<void> {
621
+ // Backfill first — before the database closes and the libp2p node stops — so
622
+ // no NEW catch-up push is issued against a torn-down transport. A push already
623
+ // in flight is not awaited; it fails into the module's own per-chunk catch.
624
+ const backfill = this.backfills.get(instance.strandId);
625
+ if (backfill) {
626
+ backfill.stop();
627
+ this.backfills.delete(instance.strandId);
628
+ }
629
+ // The revoked-peer enforcer goes with the runtime it gated: a resume
630
+ // rebuilds it with a fresh (initially empty, fail-open) snapshot.
631
+ const revocationEnforcer = this.revocationEnforcers.get(instance.strandId);
632
+ if (revocationEnforcer) {
633
+ revocationEnforcer.stop();
634
+ this.revocationEnforcers.delete(instance.strandId);
635
+ }
636
+ if (instance.database) {
637
+ await instance.database.close();
638
+ instance.database = undefined;
639
+ }
640
+ if (instance.libp2pNode) {
641
+ await instance.libp2pNode.stop();
642
+ instance.libp2pNode = undefined;
643
+ }
644
+ instance.connectedPeers = 0;
645
+ }
646
+
647
+ /**
648
+ * Drop the strand's owned store and release this strand's claim on its cache. The
649
+ * wrapper counts holders, so the cache is emptied and unregistered from the shared
650
+ * pool only if no other scope still holds it. Called only where the strand's whole
651
+ * lifetime ends (`stopStrand`, and the failed-launch rollback) — never on a quiesce.
652
+ *
653
+ * A dispose failure is logged, not thrown: the store is already unreferenced here,
654
+ * and failing the stop over a cache-bookkeeping error would leave the caller unable
655
+ * to tear the strand down. `disposeStorageCache` no-ops for an unwrapped store
656
+ * (e.g. `MemoryRawStorage`), so no instanceof test is needed.
657
+ */
658
+ private async disposeStrandStorage(strandId: string): Promise<void> {
659
+ const storage = this.strandStorages.get(strandId);
660
+ if (!storage) {
661
+ return;
662
+ }
663
+ this.strandStorages.delete(strandId);
664
+ try {
665
+ await disposeStorageCache(storage);
666
+ } catch (error) {
667
+ log('Failed to dispose storage cache for strand %s: %o', strandId, error);
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Refresh a strand's revoked-peer deny set NOW — and, with it, run the
673
+ * teardown sweep that hangs up every connected revoked peer.
674
+ *
675
+ * The gate is otherwise poll-driven (default
676
+ * `DEFAULT_REVOCATION_POLL_INTERVAL_MS`), because neither a replicated
677
+ * revocation nor a local `revokeMember`/`leaveStrand` raises anything this
678
+ * runtime can hook. A caller that just wrote a revocation against the
679
+ * strand's database should follow it with this call, which makes the cut
680
+ * immediate instead of waiting out the interval.
681
+ *
682
+ * Quiet no-op — matching `quiesceStrand`'s posture — when the strand is not
683
+ * tracked, is quiesced, is open, or has the gate disabled: in every one of
684
+ * those cases there is no enforcer to refresh. Never rejects (the enforcer's
685
+ * refresh contains its own failures), and resolves only once the sweep has
686
+ * finished.
687
+ */
688
+ async refreshRevocationEnforcement(strandId: string): Promise<void> {
689
+ const enforcer = this.revocationEnforcers.get(strandId);
690
+ if (!enforcer) {
691
+ log('refreshRevocationEnforcement: strand %s has no armed revocation enforcer', strandId);
692
+ return;
693
+ }
694
+ await enforcer.refresh();
695
+ }
696
+
697
+ /**
698
+ * Quiesce a strand: release its strand-network resources (stop the libp2p node,
699
+ * close the StrandDatabase) while RETAINING the instance record — identity,
700
+ * sAppInfo, keys, latency hint, metadata — and its launch config so it can be
701
+ * resumed later. Mechanically this is `stopStrand` minus the instance/config
702
+ * deletion. The caller sets the post-quiesce status (e.g. `hibernating`).
703
+ * No-ops when the strand is missing or already quiesced.
704
+ */
705
+ async quiesceStrand(strandId: string): Promise<void> {
706
+ const instance = this.instances.get(strandId);
707
+ if (!instance) {
708
+ log('quiesceStrand: strand %s not found', strandId);
709
+ return;
710
+ }
711
+ if (!instance.libp2pNode && !instance.database) {
712
+ log('quiesceStrand: strand %s already quiesced', strandId);
713
+ return;
714
+ }
715
+
716
+ log('Quiescing strand instance: %s', strandId);
717
+ await this.releaseRuntime(instance);
718
+ log('Strand %s quiesced (resources released, instance retained)', strandId);
719
+ }
720
+
721
+ /**
722
+ * Resume a previously-quiesced strand: rebuild its libp2p node + StrandDatabase
723
+ * from the retained launch config and re-attach them, transitioning it back to
724
+ * `active`. `overrides` re-applies volatile inputs that may have changed since
725
+ * launch (the cohort `bootstrapNodes` seed and the strand's `servingMachines`
726
+ * count) and updates the retained config so a later resume reuses the latest
727
+ * values. Returns the live instance unchanged if it is already running.
728
+ */
729
+ async resumeStrand(strandId: string, overrides?: ResumeStrandOverrides): Promise<StrandInstance> {
730
+ if (this.stopping) {
731
+ throw new Error('StrandInstanceManager is stopping');
732
+ }
733
+ const instance = this.instances.get(strandId);
734
+ if (!instance) {
735
+ throw new Error(`Cannot resume strand ${strandId}: not tracked`);
736
+ }
737
+ const launchConfig = this.launchConfigs.get(strandId);
738
+ if (!launchConfig) {
739
+ throw new Error(`Cannot resume strand ${strandId}: no retained launch config`);
740
+ }
741
+ if (instance.libp2pNode || instance.database) {
742
+ log('resumeStrand: strand %s already live', strandId);
743
+ return instance;
744
+ }
745
+
746
+ log('Resuming strand instance: %s', strandId);
747
+ const tTotal = performance.now();
748
+
749
+ // Re-apply volatile inputs and persist them so a subsequent resume reuses them.
750
+ // Each `??` matters: a resume that passes no override must keep the retained value,
751
+ // and a resume that passes one must leave it retained for the next resume — otherwise
752
+ // a later no-override wake silently reverts to whatever launch time saw.
753
+ const resumeConfig: StartStrandConfig = {
754
+ ...launchConfig,
755
+ bootstrapNodes: overrides?.bootstrapNodes ?? launchConfig.bootstrapNodes,
756
+ // NOTE: `??` retains, so an override can raise or lower the count but cannot CLEAR it
757
+ // back to "this node no longer knows" — the direction that would declare nothing. Moot
758
+ // while nothing feeds `servingMachines` at all; if a source lands
759
+ // (`backlog/feat-strand-yardstick-from-serving-machines`) that can legitimately lose the
760
+ // count — a strand whose member rows became unreadable — this merge must gain an explicit
761
+ // clear rather than silently declaring a stale number over a serving set it can no longer
762
+ // see. Same applies to `bootstrapNodes` above, where a stale seed is harmless.
763
+ servingMachines: overrides?.servingMachines ?? launchConfig.servingMachines
764
+ };
765
+ this.launchConfigs.set(strandId, resumeConfig);
766
+
767
+ instance.status = 'starting';
768
+ try {
769
+ await this.buildStrandRuntime(instance, resumeConfig);
770
+ timing('[resumeStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
771
+ log('Strand %s resumed successfully', strandId);
772
+ return instance;
773
+ } catch (error) {
774
+ instance.status = 'error';
775
+ instance.error = error instanceof Error ? error.message : String(error);
776
+ log('Failed to resume strand %s: %s', strandId, instance.error);
777
+ throw error;
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Honor a founder request against an ALREADY-TRACKED strand — the seam that
783
+ * closes the "whoever launches first decides whether the bootstrap runs" gap:
784
+ * an instance first launched as a joiner (an app's own attach, or a watcher
785
+ * poll winning the launch race) used to swallow a later founder request
786
+ * silently, leaving the strand active with no `Strand.Header`.
787
+ *
788
+ * Flips the RETAINED launch config's `founder` to true, so every later
789
+ * quiesce → resume rebuild founds as well (the bootstrap is insert-if-absent —
790
+ * {@link StrandDatabase.ensureFounderBootstrap} — so re-running it per rebuild
791
+ * writes nothing twice), and runs the bootstrap against the live database now.
792
+ *
793
+ * @returns how the request resolved:
794
+ * - `'already-founder'` — the retained config already founds; nothing to do.
795
+ * - `'bootstrapped'` — config flipped and the live database ran the bootstrap.
796
+ * - `'needs-resume'` — config flipped, but the instance is quiesced (no live
797
+ * database), so the bootstrap could not run here: the CALLER must wake the
798
+ * strand (`CadreNode.wakeStrand`, which owns the hibernation bookkeeping this
799
+ * manager does not) so the rebuild — which now founds — runs it.
800
+ * @throws when the strand is not tracked — this seam exists only for the
801
+ * tracked-instance launch path; an untracked id is a caller bug.
802
+ */
803
+ async foundExistingStrand(strandId: string): Promise<'already-founder' | 'bootstrapped' | 'needs-resume'> {
804
+ const instance = this.instances.get(strandId);
805
+ const config = this.launchConfigs.get(strandId);
806
+ if (!instance || !config) {
807
+ throw new Error(`Cannot found strand ${strandId}: not tracked`);
808
+ }
809
+ if (config.founder === true) {
810
+ return 'already-founder';
811
+ }
812
+ // A fresh object rather than mutating in place: startStrand retains the CALLER'S
813
+ // config object, which is not ours to rewrite.
814
+ this.launchConfigs.set(strandId, { ...config, founder: true });
815
+ if (!instance.database) {
816
+ return 'needs-resume';
817
+ }
818
+ await this.ensureFounderBootstrap(strandId);
819
+ return 'bootstrapped';
820
+ }
821
+
822
+ /**
823
+ * Run the (idempotent) founder bootstrap against a tracked strand's LIVE
824
+ * database, independently of what the retained launch config says.
825
+ *
826
+ * Separate from {@link foundExistingStrand} because a caller that resolved
827
+ * `'needs-resume'` and woke the strand must not assume the wake's own rebuild
828
+ * founded it: `HibernationManager` COALESCES wakes, so a wake already in flight
829
+ * when the config flipped had already read the PRE-flip config and rebuilt as a
830
+ * joiner. Re-running the bootstrap costs one insert-if-absent probe per table
831
+ * and is the only thing that makes "founding resolves once the Header is
832
+ * written" true on that path.
833
+ *
834
+ * @throws when the strand is not tracked, or is still quiesced (no live
835
+ * database) — both mean the bootstrap did NOT run, which a founder request
836
+ * must never swallow.
837
+ */
838
+ async ensureFounderBootstrap(strandId: string): Promise<void> {
839
+ const instance = this.instances.get(strandId);
840
+ if (!instance) {
841
+ throw new Error(`Cannot run the founder bootstrap for strand ${strandId}: not tracked`);
842
+ }
843
+ if (!instance.database) {
844
+ throw new Error(
845
+ `Cannot run the founder bootstrap for strand ${strandId}: it is quiesced, so there ` +
846
+ 'is no live database to write to — resume it first.'
847
+ );
848
+ }
849
+ await instance.database.ensureFounderBootstrap();
850
+ }
851
+
852
+ /**
853
+ * Stop a strand instance
854
+ */
855
+ async stopStrand(strandId: string): Promise<void> {
856
+ const instance = this.instances.get(strandId);
857
+ if (!instance) {
858
+ log('Strand %s not found', strandId);
859
+ return;
860
+ }
861
+
862
+ log('Stopping strand instance: %s', strandId);
863
+ instance.status = 'stopping';
864
+
865
+ try {
866
+ await this.releaseRuntime(instance);
867
+ instance.status = 'stopped';
868
+ this.instances.delete(strandId);
869
+ this.launchConfigs.delete(strandId);
870
+ // Storage is released only here — NOT in releaseRuntime, which a quiesce shares.
871
+ await this.disposeStrandStorage(strandId);
872
+ log('Strand %s stopped successfully', strandId);
873
+ } catch (error) {
874
+ instance.status = 'error';
875
+ instance.error = error instanceof Error ? error.message : String(error);
876
+ log('Error stopping strand %s: %s', strandId, instance.error);
877
+ throw error;
878
+ }
879
+ }
880
+
881
+ /**
882
+ * Stop all strand instances
883
+ */
884
+ async stopAll(): Promise<void> {
885
+ this.stopping = true;
886
+ log('Stopping all strand instances (%d)', this.instances.size);
887
+
888
+ const stopPromises = Array.from(this.instances.keys()).map(id =>
889
+ this.stopStrand(id).catch(err => {
890
+ log('Error stopping strand %s during shutdown: %s', id, err);
891
+ })
892
+ );
893
+
894
+ await Promise.all(stopPromises);
895
+ this.stopping = false;
896
+ log('All strand instances stopped');
897
+ }
898
+ }
899
+