@serfab/cadre-core 0.9.0 → 0.11.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 (193) hide show
  1. package/README.md +255 -253
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +1121 -81
  9. package/dist/cadre-node.js +2276 -262
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-cohort.d.ts +26 -0
  15. package/dist/control-cohort.js +26 -0
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +959 -54
  18. package/dist/control-database.js +1421 -148
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +90 -10
  21. package/dist/control-formation-recorder.js +130 -14
  22. package/dist/control-formation-recorder.js.map +1 -1
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +651 -72
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-stream.d.ts +38 -4
  27. package/dist/control-stream.js +70 -4
  28. package/dist/control-stream.js.map +1 -1
  29. package/dist/control-write-retry.d.ts +161 -0
  30. package/dist/control-write-retry.js +412 -0
  31. package/dist/control-write-retry.js.map +1 -0
  32. package/dist/delegate-admission.d.ts +134 -0
  33. package/dist/delegate-admission.js +244 -0
  34. package/dist/delegate-admission.js.map +1 -0
  35. package/dist/device-token.d.ts +11 -11
  36. package/dist/device-token.js +14 -13
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +26 -0
  39. package/dist/ed25519-key.js +56 -1
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/file-durable-slot.d.ts +20 -0
  42. package/dist/file-durable-slot.js +53 -0
  43. package/dist/file-durable-slot.js.map +1 -0
  44. package/dist/formation-approval.d.ts +157 -0
  45. package/dist/formation-approval.js +375 -0
  46. package/dist/formation-approval.js.map +1 -0
  47. package/dist/fs-atomic.d.ts +38 -0
  48. package/dist/fs-atomic.js +97 -0
  49. package/dist/fs-atomic.js.map +1 -0
  50. package/dist/identity-key.d.ts +49 -0
  51. package/dist/identity-key.js +81 -0
  52. package/dist/identity-key.js.map +1 -0
  53. package/dist/index.d.ts +17 -7
  54. package/dist/index.js +40 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/key-store-file.d.ts +2 -24
  57. package/dist/key-store-file.js +10 -77
  58. package/dist/key-store-file.js.map +1 -1
  59. package/dist/membership-connection-gater.d.ts +146 -0
  60. package/dist/membership-connection-gater.js +180 -0
  61. package/dist/membership-connection-gater.js.map +1 -0
  62. package/dist/node-local-snapshot.d.ts +109 -0
  63. package/dist/node-local-snapshot.js +182 -0
  64. package/dist/node-local-snapshot.js.map +1 -0
  65. package/dist/peer-addr-book.d.ts +92 -0
  66. package/dist/peer-addr-book.js +220 -0
  67. package/dist/peer-addr-book.js.map +1 -0
  68. package/dist/peer-authorization.d.ts +135 -18
  69. package/dist/peer-authorization.js +152 -21
  70. package/dist/peer-authorization.js.map +1 -1
  71. package/dist/peer-record.d.ts +21 -8
  72. package/dist/peer-record.js +26 -10
  73. package/dist/peer-record.js.map +1 -1
  74. package/dist/relay-addrs.d.ts +45 -0
  75. package/dist/relay-addrs.js +103 -0
  76. package/dist/relay-addrs.js.map +1 -0
  77. package/dist/relay-reservation.d.ts +266 -0
  78. package/dist/relay-reservation.js +613 -0
  79. package/dist/relay-reservation.js.map +1 -0
  80. package/dist/seed-bootstrap.d.ts +228 -56
  81. package/dist/seed-bootstrap.js +340 -198
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +47 -21
  84. package/dist/seed-trust-policy.js +24 -15
  85. package/dist/seed-trust-policy.js.map +1 -1
  86. package/dist/strand-addr-protocol.d.ts +42 -13
  87. package/dist/strand-addr-protocol.js +58 -52
  88. package/dist/strand-addr-protocol.js.map +1 -1
  89. package/dist/strand-backfill.d.ts +109 -0
  90. package/dist/strand-backfill.js +279 -0
  91. package/dist/strand-backfill.js.map +1 -0
  92. package/dist/strand-cohort.d.ts +4 -37
  93. package/dist/strand-cohort.js +4 -15
  94. package/dist/strand-cohort.js.map +1 -1
  95. package/dist/strand-database.d.ts +11 -16
  96. package/dist/strand-database.js +19 -7
  97. package/dist/strand-database.js.map +1 -1
  98. package/dist/strand-formation-manager.d.ts +36 -7
  99. package/dist/strand-formation-manager.js +115 -15
  100. package/dist/strand-formation-manager.js.map +1 -1
  101. package/dist/strand-formation-protocol.d.ts +103 -4
  102. package/dist/strand-formation-protocol.js +232 -4
  103. package/dist/strand-formation-protocol.js.map +1 -1
  104. package/dist/strand-instance-manager.d.ts +88 -19
  105. package/dist/strand-instance-manager.js +178 -34
  106. package/dist/strand-instance-manager.js.map +1 -1
  107. package/dist/strand-member-registry.d.ts +5 -5
  108. package/dist/strand-member-registry.js +18 -10
  109. package/dist/strand-member-registry.js.map +1 -1
  110. package/dist/strand-membership-writer.d.ts +484 -88
  111. package/dist/strand-membership-writer.js +751 -128
  112. package/dist/strand-membership-writer.js.map +1 -1
  113. package/dist/strand-solicitation.d.ts +99 -33
  114. package/dist/strand-solicitation.js +100 -52
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-transport-key.d.ts +40 -0
  117. package/dist/strand-transport-key.js +56 -0
  118. package/dist/strand-transport-key.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +9 -7
  120. package/dist/strand-wake-protocol.js +22 -46
  121. package/dist/strand-wake-protocol.js.map +1 -1
  122. package/dist/strand-watcher.d.ts +17 -2
  123. package/dist/strand-watcher.js +47 -3
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/trusted-owner-store-file.d.ts +12 -0
  126. package/dist/trusted-owner-store-file.js +39 -0
  127. package/dist/trusted-owner-store-file.js.map +1 -0
  128. package/dist/trusted-owner-store.d.ts +64 -0
  129. package/dist/trusted-owner-store.js +126 -0
  130. package/dist/trusted-owner-store.js.map +1 -0
  131. package/dist/types.d.ts +307 -44
  132. package/dist/types.js +13 -0
  133. package/dist/types.js.map +1 -1
  134. package/package.json +20 -8
  135. package/src/arachnode-stub.ts +159 -159
  136. package/src/bootstrap-peer-store-file.ts +44 -0
  137. package/src/bootstrap-peer-store.ts +172 -0
  138. package/src/cadre-node.ts +5158 -2981
  139. package/src/canonical-json.ts +30 -30
  140. package/src/control-authorization.ts +114 -0
  141. package/src/control-cohort.ts +130 -103
  142. package/src/control-database.ts +2406 -948
  143. package/src/control-formation-recorder.ts +305 -151
  144. package/src/control-schema.ts +651 -72
  145. package/src/control-stream.ts +205 -128
  146. package/src/control-write-retry.ts +483 -0
  147. package/src/delegate-admission.ts +302 -0
  148. package/src/device-token.ts +94 -89
  149. package/src/diagnostics/connection-path.ts +277 -277
  150. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  151. package/src/ed25519-key.ts +66 -1
  152. package/src/enrollment.ts +199 -199
  153. package/src/file-durable-slot.ts +61 -0
  154. package/src/formation-approval.ts +604 -0
  155. package/src/fs-atomic.ts +107 -0
  156. package/src/hibernation-manager.ts +437 -437
  157. package/src/identity-key.ts +109 -0
  158. package/src/index.ts +137 -5
  159. package/src/key-store-file.ts +132 -203
  160. package/src/key-store.ts +99 -99
  161. package/src/membership-connection-gater.ts +204 -0
  162. package/src/node-local-snapshot.ts +260 -0
  163. package/src/peer-addr-book.ts +250 -0
  164. package/src/peer-authorization.ts +259 -81
  165. package/src/peer-record.ts +164 -146
  166. package/src/push-credentials.ts +84 -84
  167. package/src/push-node.ts +64 -64
  168. package/src/push-notifier-apns.ts +247 -247
  169. package/src/push-notifier-fcm.ts +220 -220
  170. package/src/push-notifier-shared.ts +28 -28
  171. package/src/push-notifier.ts +52 -52
  172. package/src/relay-addrs.ts +111 -0
  173. package/src/relay-reservation.ts +823 -0
  174. package/src/seed-bootstrap.ts +409 -209
  175. package/src/seed-trust-policy.ts +140 -107
  176. package/src/strand-addr-protocol.ts +441 -410
  177. package/src/strand-backfill.ts +366 -0
  178. package/src/strand-cohort.ts +38 -77
  179. package/src/strand-database.ts +202 -204
  180. package/src/strand-formation-manager.ts +491 -368
  181. package/src/strand-formation-protocol.ts +692 -391
  182. package/src/strand-instance-manager.ts +201 -46
  183. package/src/strand-member-key.ts +39 -39
  184. package/src/strand-member-registry.ts +182 -177
  185. package/src/strand-membership-writer.ts +938 -146
  186. package/src/strand-solicitation.ts +472 -407
  187. package/src/strand-transport-key.ts +58 -0
  188. package/src/strand-wake-payload.ts +32 -32
  189. package/src/strand-wake-protocol.ts +314 -331
  190. package/src/strand-watcher.ts +314 -251
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +1309 -1032
@@ -1,7 +1,9 @@
1
1
  import debug from 'debug';
2
2
  import type { PrivateKey } from '@libp2p/interface';
3
3
  import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
4
+ import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
4
5
  import { StrandDatabase } from './strand-database.js';
6
+ import { StrandBackfill, type StrandBackfillConfig } from './strand-backfill.js';
5
7
  import { assertSchemaSignature } from './schema-verification.js';
6
8
  import type {
7
9
  StrandInstance,
@@ -13,9 +15,10 @@ import type {
13
15
  SAppConfig,
14
16
  SAppInfo,
15
17
  RawStorageProvider,
16
- StrandMode,
17
18
  Libp2pNodeWithRepo
18
19
  } from './types.js';
20
+ import { resolveStrandClusterSize, STRAND_CLUSTER_POLICY } from './types.js';
21
+ import { resolveListenAddrs } from './relay-addrs.js';
19
22
 
20
23
  const log = debug('sereus:cadre:strand-manager');
21
24
  const timing = debug('sereus:cadre:timing');
@@ -34,12 +37,6 @@ export interface StartStrandConfig {
34
37
  privateKey?: PrivateKey;
35
38
  /** Cohort-derived discovery seed (multiaddr strings). Defaults to [] when omitted. */
36
39
  bootstrapNodes?: string[];
37
- /**
38
- * Lifecycle mode for this strand; selects the default transactor used by the
39
- * StrandDatabase. When omitted, the StrandDatabase falls back to `'networked'`;
40
- * callers (CadreNode) infer it from cohort membership before reaching here.
41
- */
42
- mode?: StrandMode;
43
40
  /**
44
41
  * Require a valid author signature on the sApp schema before bring-up.
45
42
  * Defaults to true (fail closed) when omitted; set false only for dev/test
@@ -53,18 +50,35 @@ export interface StartStrandConfig {
53
50
  * {@link StrandConfig.founder}.
54
51
  */
55
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;
56
72
  }
57
73
 
58
74
  /**
59
- * Volatile inputs re-resolved when resuming a quiesced strand. These may have
60
- * changed since the strand first launched — the cohort discovery seed grows as
61
- * peers are learned, and cohort membership can push a strand `bootstrap → networked`.
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.
62
78
  */
63
79
  export interface ResumeStrandOverrides {
64
80
  /** Freshly-resolved cohort discovery seed (multiaddr strings). */
65
81
  bootstrapNodes?: string[];
66
- /** Freshly-resolved lifecycle mode. */
67
- mode?: StrandMode;
68
82
  }
69
83
 
70
84
  /**
@@ -106,6 +120,9 @@ export function getStrandStoragePath(basePath: string, strandId: string): string
106
120
  * Resolve a storage provider for a specific strand.
107
121
  * If the provider is a factory function, call it with the strandId.
108
122
  *
123
+ * Called only from {@link StrandInstanceManager.startStrand}, which owns the result
124
+ * for the instance's lifetime — see `strandStorages`.
125
+ *
109
126
  * @param provider - Storage provider (instance or factory)
110
127
  * @param strandId - The strand ID to create storage for
111
128
  * @returns The resolved IRawStorage instance, or undefined if no provider
@@ -117,7 +134,11 @@ function resolveStrandStorage(
117
134
  if (!provider) {
118
135
  return undefined;
119
136
  }
120
- return typeof provider === 'function' ? provider(strandId) : provider;
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);
121
142
  }
122
143
 
123
144
  /**
@@ -132,6 +153,35 @@ export class StrandInstanceManager {
132
153
  * without the caller re-threading storage/network/profile/key/sApp config.
133
154
  */
134
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();
135
185
  private stopping = false;
136
186
 
137
187
  constructor() {
@@ -160,7 +210,20 @@ export class StrandInstanceManager {
160
210
  }
161
211
 
162
212
  /**
163
- * Start a new strand instance
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.
164
227
  */
165
228
  async startStrand(config: StartStrandConfig): Promise<StrandInstance> {
166
229
  const { strandRow, sAppConfig } = config;
@@ -191,6 +254,15 @@ export class StrandInstanceManager {
191
254
  signature: sAppConfig.signature
192
255
  };
193
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
+
194
266
  // Determine latency hint: sApp config > default
195
267
  const latencyHint = sAppConfig.latencyHint ?? config.defaultLatencyHint;
196
268
 
@@ -206,6 +278,9 @@ export class StrandInstanceManager {
206
278
 
207
279
  this.instances.set(strandId, instance);
208
280
  this.launchConfigs.set(strandId, config);
281
+ if (strandStorage) {
282
+ this.strandStorages.set(strandId, strandStorage);
283
+ }
209
284
 
210
285
  try {
211
286
  await this.buildStrandRuntime(instance, config);
@@ -213,9 +288,18 @@ export class StrandInstanceManager {
213
288
  log('Strand %s started successfully with sApp %s', strandId, sAppConfig.id);
214
289
  return instance;
215
290
  } catch (error) {
291
+ // Status/error first — the (now discarded) record is still what `log` reports on.
216
292
  instance.status = 'error';
217
293
  instance.error = error instanceof Error ? error.message : String(error);
218
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);
219
303
  throw error;
220
304
  }
221
305
  }
@@ -224,61 +308,68 @@ export class StrandInstanceManager {
224
308
  * Build (or rebuild) the libp2p node + StrandDatabase for an instance and
225
309
  * attach them, transitioning it to `active`. Shared by `startStrand` (fresh
226
310
  * launch) and `resumeStrand` (rehydrating a quiesced instance). Reads all
227
- * volatile inputs (bootstrapNodes, mode, storage, network, profile,
228
- * privateKey, sApp config) from `config`, so the caller controls the
229
- * cohort-derived values.
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`.
230
315
  */
231
316
  private async buildStrandRuntime(instance: StrandInstance, config: StartStrandConfig): Promise<void> {
232
317
  const strandId = instance.strandId;
233
318
  const { sAppConfig } = config;
234
319
 
235
- // Resolve storage for this strand. If a factory function is provided, it is
236
- // called with the strandId to create strand-specific storage (e.g.,
237
- // strand-isolated directories).
238
- const strandStorage = resolveStrandStorage(config.storage?.provider, strandId);
239
- if (strandStorage) {
240
- log('Strand %s using provided storage provider', strandId);
241
- }
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);
242
323
 
243
- // Note: protocolPrefix would be used by libp2p services, but createLibp2pNode
244
- // may not support it yet - tracked for future implementation.
245
- const _protocolPrefix = `/sereus/strand/${strandId}`;
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}`;
246
330
 
247
331
  // Determine relay mode: if explicitly set in config, use that;
248
332
  // otherwise default to true for storage profile nodes.
249
333
  const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
334
+ const listenAddrs = resolveListenAddrs(config.network);
250
335
 
251
336
  try {
252
337
  let t0 = performance.now();
253
338
  const node = await createLibp2pNode({
254
339
  port: 0, // Random port
255
340
  bootstrapNodes: config.bootstrapNodes ?? [],
256
- networkName: `strand-${strandId}`,
341
+ networkName,
257
342
  storage: strandStorage,
258
343
  fretProfile: config.profile === 'storage' ? 'core' : 'edge',
259
344
  relay: enableRelay,
260
- clusterSize: 3,
261
- clusterPolicy: {
262
- allowDownsize: true,
263
- sizeTolerance: 0.5
264
- },
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,
265
349
  arachnode: {
266
350
  enableRingZulu: config.profile === 'storage'
267
351
  },
268
352
  ...(config.privateKey && { privateKey: config.privateKey }),
269
353
  ...(config.network?.transports && { transports: config.network.transports }),
270
- ...(config.network?.listenAddrs && { listenAddrs: config.network.listenAddrs }),
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 }),
271
366
  ...(config.network?.connectionGater && { connectionGater: config.network.connectionGater })
272
367
  }) as Libp2pNodeWithRepo;
273
368
  timing('[buildStrandRuntime:%s] createLibp2pNode: %dms', strandId, Math.round(performance.now() - t0));
274
369
 
275
370
  instance.libp2pNode = node;
276
371
 
277
- // Create and initialize the StrandDatabase. In bootstrap mode the same
278
- // strandStorage instance also backs the optimystic local transactor so
279
- // DML lands on the host's persistent storage (e.g. MMKV on RN). Sharing
280
- // the instance — not creating a second one over the same id+prefix —
281
- // avoids cache divergence between the libp2p and database paths.
372
+ // Create and initialize the StrandDatabase.
282
373
  //
283
374
  // Attach before initialize so a failed init is cleaned up by
284
375
  // releaseRuntime below (close() is safe on a partially-initialized db).
@@ -288,8 +379,6 @@ export class StrandInstanceManager {
288
379
  sAppConfig,
289
380
  libp2pNode: node,
290
381
  coordinatedRepo: node.coordinatedRepo,
291
- mode: config.mode ?? 'networked',
292
- rawStorage: strandStorage,
293
382
  // Founder bootstrap inputs: the strand's type drives which membership rows
294
383
  // are written, and the closed-strand MemberPrivateKey derives the founding
295
384
  // Member/Manager key. Both come off the control-network strand row.
@@ -301,6 +390,37 @@ export class StrandInstanceManager {
301
390
  await strandDb.initialize();
302
391
  timing('[buildStrandRuntime:%s] strandDatabase.initialize: %dms', strandId, Math.round(performance.now() - t0));
303
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
+
304
424
  instance.status = 'active';
305
425
  instance.lastActivity = new Date();
306
426
  } catch (error) {
@@ -321,8 +441,19 @@ export class StrandInstanceManager {
321
441
  * and zeroing connectedPeers. Tolerant of partially-built state — either handle
322
442
  * may be absent — so it doubles as rollback for a failed `buildStrandRuntime`.
323
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.
324
447
  */
325
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
+ }
326
457
  if (instance.database) {
327
458
  await instance.database.close();
328
459
  instance.database = undefined;
@@ -334,6 +465,29 @@ export class StrandInstanceManager {
334
465
  instance.connectedPeers = 0;
335
466
  }
336
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
+
337
491
  /**
338
492
  * Quiesce a strand: release its strand-network resources (stop the libp2p node,
339
493
  * close the StrandDatabase) while RETAINING the instance record — identity,
@@ -362,9 +516,9 @@ export class StrandInstanceManager {
362
516
  * Resume a previously-quiesced strand: rebuild its libp2p node + StrandDatabase
363
517
  * from the retained launch config and re-attach them, transitioning it back to
364
518
  * `active`. `overrides` re-applies volatile inputs that may have changed since
365
- * launch (cohort `bootstrapNodes`, lifecycle `mode`) and updates the retained
366
- * config so a later resume reuses the latest values. Returns the live instance
367
- * unchanged if it is already running.
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.
368
522
  */
369
523
  async resumeStrand(strandId: string, overrides?: ResumeStrandOverrides): Promise<StrandInstance> {
370
524
  if (this.stopping) {
@@ -389,8 +543,7 @@ export class StrandInstanceManager {
389
543
  // Re-apply volatile inputs and persist them so a subsequent resume reuses them.
390
544
  const resumeConfig: StartStrandConfig = {
391
545
  ...launchConfig,
392
- bootstrapNodes: overrides?.bootstrapNodes ?? launchConfig.bootstrapNodes,
393
- mode: overrides?.mode ?? launchConfig.mode
546
+ bootstrapNodes: overrides?.bootstrapNodes ?? launchConfig.bootstrapNodes
394
547
  };
395
548
  this.launchConfigs.set(strandId, resumeConfig);
396
549
 
@@ -426,6 +579,8 @@ export class StrandInstanceManager {
426
579
  instance.status = 'stopped';
427
580
  this.instances.delete(strandId);
428
581
  this.launchConfigs.delete(strandId);
582
+ // Storage is released only here — NOT in releaseRuntime, which a quiesce shares.
583
+ await this.disposeStrandStorage(strandId);
429
584
  log('Strand %s stopped successfully', strandId);
430
585
  } catch (error) {
431
586
  instance.status = 'error';
@@ -1,39 +1,39 @@
1
- import { generateKeyPair, privateKeyToProtobuf, privateKeyFromProtobuf } from '@libp2p/crypto/keys';
2
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
- import { ed25519KeyPairFromLibp2p, type Ed25519KeyPair } from './ed25519-key.js';
4
-
5
- /**
6
- * Mint a fresh ed25519 strand member private key, encoded as base64 protobuf.
7
- *
8
- * Closed strands (`Type:'c'`) gate membership on a `MemberPrivateKey` column
9
- * (see the `Strand` control-schema table). This produces one in the SAME
10
- * encoding {@link StrandSolicitationService.formStrand} uses for its
11
- * `invitePrivateKey`, so a host-minted key and a formation-issued key are
12
- * interchangeable when attaching a closed strand.
13
- */
14
- export async function generateStrandMemberKey(): Promise<string> {
15
- const privateKey = await generateKeyPair('Ed25519');
16
- return uint8ArrayToString(privateKeyToProtobuf(privateKey), 'base64');
17
- }
18
-
19
- /**
20
- * Bridge a strand's `MemberPrivateKey` (base64 protobuf libp2p ed25519 key, as
21
- * minted by {@link generateStrandMemberKey} or issued by formation) into the
22
- * base64url keypair the strand RBAC constraints consume.
23
- *
24
- * The `Strand` control-layer `MemberPrivateKey` is the closed-strand read-gating
25
- * secret; the founding `Member.Key`/`Manager.MemberKey` (the strand RBAC layer)
26
- * are *derived from it* — they are the `publicKeyB64` of the keypair this returns.
27
- * Decode the protobuf to a libp2p private key, then reuse
28
- * {@link ed25519KeyPairFromLibp2p} so the same seed→public derivation used for node
29
- * owner keys yields a stable `{ privateKeyB64, publicKeyB64 }` whose public
30
- * key a later strand signature verifies against.
31
- *
32
- * @param memberPrivateKey - The strand's `MemberPrivateKey` (base64 protobuf).
33
- * @returns The base64url seed/public-key pair for founding membership/manager.
34
- * @throws If the decoded key is not Ed25519 or the raw bytes are malformed.
35
- */
36
- export function strandMemberKeyPair(memberPrivateKey: string): Ed25519KeyPair {
37
- const privateKey = privateKeyFromProtobuf(uint8ArrayFromString(memberPrivateKey, 'base64'));
38
- return ed25519KeyPairFromLibp2p(privateKey);
39
- }
1
+ import { generateKeyPair, privateKeyToProtobuf, privateKeyFromProtobuf } from '@libp2p/crypto/keys';
2
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
+ import { ed25519KeyPairFromLibp2p, type Ed25519KeyPair } from './ed25519-key.js';
4
+
5
+ /**
6
+ * Mint a fresh ed25519 strand member private key, encoded as base64 protobuf.
7
+ *
8
+ * Closed strands (`Type:'c'`) gate membership on a `MemberPrivateKey` column
9
+ * (see the `Strand` control-schema table). This produces one in the SAME
10
+ * encoding {@link StrandSolicitationService.formStrand} uses for its
11
+ * `invitePrivateKey`, so a host-minted key and a formation-issued key are
12
+ * interchangeable when attaching a closed strand.
13
+ */
14
+ export async function generateStrandMemberKey(): Promise<string> {
15
+ const privateKey = await generateKeyPair('Ed25519');
16
+ return uint8ArrayToString(privateKeyToProtobuf(privateKey), 'base64');
17
+ }
18
+
19
+ /**
20
+ * Bridge a strand's `MemberPrivateKey` (base64 protobuf libp2p ed25519 key, as
21
+ * minted by {@link generateStrandMemberKey} or issued by formation) into the
22
+ * base64url keypair the strand RBAC constraints consume.
23
+ *
24
+ * The `Strand` control-layer `MemberPrivateKey` is the closed-strand read-gating
25
+ * secret; the founding `Member.Key`/`Manager.MemberKey` (the strand RBAC layer)
26
+ * are *derived from it* — they are the `publicKeyB64` of the keypair this returns.
27
+ * Decode the protobuf to a libp2p private key, then reuse
28
+ * {@link ed25519KeyPairFromLibp2p} so the same seed→public derivation used for node
29
+ * owner keys yields a stable `{ privateKeyB64, publicKeyB64 }` whose public
30
+ * key a later strand signature verifies against.
31
+ *
32
+ * @param memberPrivateKey - The strand's `MemberPrivateKey` (base64 protobuf).
33
+ * @returns The base64url seed/public-key pair for founding membership/manager.
34
+ * @throws If the decoded key is not Ed25519 or the raw bytes are malformed.
35
+ */
36
+ export function strandMemberKeyPair(memberPrivateKey: string): Ed25519KeyPair {
37
+ const privateKey = privateKeyFromProtobuf(uint8ArrayFromString(memberPrivateKey, 'base64'));
38
+ return ed25519KeyPairFromLibp2p(privateKey);
39
+ }