@serfab/cadre-core 1.7.0 → 1.9.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 (188) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +527 -206
  3. package/dist/cadre-node.js +1084 -528
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-authorization.d.ts +2 -2
  6. package/dist/control-authorization.js +1 -0
  7. package/dist/control-authorization.js.map +1 -1
  8. package/dist/control-cohort.d.ts +6 -0
  9. package/dist/control-cohort.js +6 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +46 -8
  12. package/dist/control-database.js +97 -18
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +37 -14
  15. package/dist/control-formation-recorder.js +60 -10
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +13 -12
  18. package/dist/control-read-retry.js +14 -12
  19. package/dist/control-read-retry.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +67 -5
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +9 -1
  24. package/dist/control-stream.js +24 -1
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +12 -2
  27. package/dist/control-write-retry.js +13 -2
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +11 -8
  30. package/dist/delegate-admission.js +19 -11
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/diagnostics/connection-path.d.ts +0 -7
  33. package/dist/diagnostics/connection-path.js +0 -7
  34. package/dist/diagnostics/connection-path.js.map +1 -1
  35. package/dist/formation-approval.d.ts +7 -0
  36. package/dist/formation-approval.js +9 -3
  37. package/dist/formation-approval.js.map +1 -1
  38. package/dist/fs-atomic.d.ts +11 -2
  39. package/dist/fs-atomic.js +13 -3
  40. package/dist/fs-atomic.js.map +1 -1
  41. package/dist/hibernation-manager.d.ts +111 -8
  42. package/dist/hibernation-manager.js +234 -49
  43. package/dist/hibernation-manager.js.map +1 -1
  44. package/dist/index.d.ts +10 -10
  45. package/dist/index.js +25 -20
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +90 -25
  48. package/dist/joined-strand-store.js +223 -55
  49. package/dist/joined-strand-store.js.map +1 -1
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/link-budget.d.ts +367 -69
  53. package/dist/link-budget.js +375 -72
  54. package/dist/link-budget.js.map +1 -1
  55. package/dist/membership-connection-gater.d.ts +30 -43
  56. package/dist/membership-connection-gater.js +31 -42
  57. package/dist/membership-connection-gater.js.map +1 -1
  58. package/dist/node-local-snapshot.d.ts +6 -4
  59. package/dist/node-local-snapshot.js +7 -3
  60. package/dist/node-local-snapshot.js.map +1 -1
  61. package/dist/peer-dial.d.ts +24 -4
  62. package/dist/peer-dial.js +74 -5
  63. package/dist/peer-dial.js.map +1 -1
  64. package/dist/peer-join-backfill.d.ts +2 -10
  65. package/dist/peer-join-backfill.js +8 -8
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/push-fanout.d.ts +6 -0
  68. package/dist/push-fanout.js +6 -0
  69. package/dist/push-fanout.js.map +1 -1
  70. package/dist/relay-reservation.d.ts +3 -1
  71. package/dist/relay-reservation.js +3 -1
  72. package/dist/relay-reservation.js.map +1 -1
  73. package/dist/relay-server.d.ts +79 -0
  74. package/dist/relay-server.js +74 -0
  75. package/dist/relay-server.js.map +1 -0
  76. package/dist/seed-bootstrap.d.ts +57 -4
  77. package/dist/seed-bootstrap.js +107 -83
  78. package/dist/seed-bootstrap.js.map +1 -1
  79. package/dist/storage-scope.d.ts +33 -17
  80. package/dist/storage-scope.js +44 -23
  81. package/dist/storage-scope.js.map +1 -1
  82. package/dist/strand-addr-protocol.d.ts +58 -21
  83. package/dist/strand-addr-protocol.js +133 -85
  84. package/dist/strand-addr-protocol.js.map +1 -1
  85. package/dist/strand-database.d.ts +20 -2
  86. package/dist/strand-database.js +42 -5
  87. package/dist/strand-database.js.map +1 -1
  88. package/dist/strand-first-sync-gate.d.ts +59 -21
  89. package/dist/strand-first-sync-gate.js +58 -19
  90. package/dist/strand-first-sync-gate.js.map +1 -1
  91. package/dist/strand-formation-deadlines.d.ts +151 -0
  92. package/dist/strand-formation-deadlines.js +171 -0
  93. package/dist/strand-formation-deadlines.js.map +1 -0
  94. package/dist/strand-formation-manager.d.ts +63 -30
  95. package/dist/strand-formation-manager.js +67 -41
  96. package/dist/strand-formation-manager.js.map +1 -1
  97. package/dist/strand-formation-protocol.d.ts +45 -46
  98. package/dist/strand-formation-protocol.js +62 -126
  99. package/dist/strand-formation-protocol.js.map +1 -1
  100. package/dist/strand-fret-addrs.d.ts +54 -0
  101. package/dist/strand-fret-addrs.js +108 -0
  102. package/dist/strand-fret-addrs.js.map +1 -0
  103. package/dist/strand-instance-manager.d.ts +122 -44
  104. package/dist/strand-instance-manager.js +270 -114
  105. package/dist/strand-instance-manager.js.map +1 -1
  106. package/dist/strand-membership-writer.d.ts +6 -0
  107. package/dist/strand-membership-writer.js +11 -0
  108. package/dist/strand-membership-writer.js.map +1 -1
  109. package/dist/strand-network-state-file.d.ts +12 -0
  110. package/dist/strand-network-state-file.js +38 -0
  111. package/dist/strand-network-state-file.js.map +1 -0
  112. package/dist/strand-network-state.d.ts +111 -0
  113. package/dist/strand-network-state.js +191 -0
  114. package/dist/strand-network-state.js.map +1 -0
  115. package/dist/strand-revocation-enforcer.js +2 -1
  116. package/dist/strand-revocation-enforcer.js.map +1 -1
  117. package/dist/strand-solicitation.d.ts +36 -20
  118. package/dist/strand-solicitation.js +4 -4
  119. package/dist/strand-solicitation.js.map +1 -1
  120. package/dist/strand-wake-protocol.d.ts +80 -23
  121. package/dist/strand-wake-protocol.js +89 -64
  122. package/dist/strand-wake-protocol.js.map +1 -1
  123. package/dist/types.d.ts +148 -61
  124. package/dist/types.js +4 -3
  125. package/dist/types.js.map +1 -1
  126. package/package.json +11 -10
  127. package/src/cadre-node.ts +8400 -7776
  128. package/src/control-authorization.ts +2 -1
  129. package/src/control-cohort.ts +6 -0
  130. package/src/control-database.ts +122 -18
  131. package/src/control-formation-recorder.ts +354 -305
  132. package/src/control-read-retry.ts +14 -12
  133. package/src/control-schema.ts +67 -5
  134. package/src/control-stream.ts +25 -1
  135. package/src/control-write-retry.ts +13 -2
  136. package/src/delegate-admission.ts +23 -13
  137. package/src/diagnostics/connection-path.ts +0 -7
  138. package/src/formation-approval.ts +610 -604
  139. package/src/fs-atomic.ts +14 -4
  140. package/src/hibernation-manager.ts +638 -437
  141. package/src/index.ts +43 -65
  142. package/src/joined-strand-store.ts +247 -57
  143. package/src/key-store-file.ts +14 -5
  144. package/src/link-budget.ts +393 -74
  145. package/src/membership-connection-gater.ts +31 -45
  146. package/src/node-local-snapshot.ts +12 -7
  147. package/src/peer-dial.ts +81 -7
  148. package/src/peer-join-backfill.ts +10 -9
  149. package/src/push-fanout.ts +6 -0
  150. package/src/relay-reservation.ts +3 -1
  151. package/src/relay-server.ts +98 -0
  152. package/src/seed-bootstrap.ts +133 -91
  153. package/src/storage-scope.ts +44 -23
  154. package/src/strand-addr-protocol.ts +547 -458
  155. package/src/strand-database.ts +287 -242
  156. package/src/strand-first-sync-gate.ts +58 -19
  157. package/src/strand-formation-deadlines.ts +227 -0
  158. package/src/strand-formation-manager.ts +700 -647
  159. package/src/strand-formation-protocol.ts +106 -154
  160. package/src/strand-fret-addrs.ts +145 -0
  161. package/src/strand-instance-manager.ts +307 -140
  162. package/src/strand-membership-writer.ts +12 -0
  163. package/src/strand-network-state-file.ts +40 -0
  164. package/src/strand-network-state.ts +243 -0
  165. package/src/strand-revocation-enforcer.ts +2 -1
  166. package/src/strand-solicitation.ts +519 -500
  167. package/src/strand-wake-protocol.ts +401 -355
  168. package/src/types.ts +150 -61
  169. package/dist/strand-peer-book-file.d.ts +0 -12
  170. package/dist/strand-peer-book-file.js +0 -37
  171. package/dist/strand-peer-book-file.js.map +0 -1
  172. package/dist/strand-peer-book-protocol.d.ts +0 -159
  173. package/dist/strand-peer-book-protocol.js +0 -360
  174. package/dist/strand-peer-book-protocol.js.map +0 -1
  175. package/dist/strand-peer-book-swap.d.ts +0 -104
  176. package/dist/strand-peer-book-swap.js +0 -284
  177. package/dist/strand-peer-book-swap.js.map +0 -1
  178. package/dist/strand-peer-book.d.ts +0 -159
  179. package/dist/strand-peer-book.js +0 -354
  180. package/dist/strand-peer-book.js.map +0 -1
  181. package/dist/strand-peer-observer.d.ts +0 -88
  182. package/dist/strand-peer-observer.js +0 -180
  183. package/dist/strand-peer-observer.js.map +0 -1
  184. package/src/strand-peer-book-file.ts +0 -39
  185. package/src/strand-peer-book-protocol.ts +0 -463
  186. package/src/strand-peer-book-swap.ts +0 -338
  187. package/src/strand-peer-book.ts +0 -468
  188. package/src/strand-peer-observer.ts +0 -235
@@ -4,9 +4,7 @@ import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
4
4
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
5
5
  import { StrandDatabase } from './strand-database.js';
6
6
  import { PeerJoinBackfill, type PeerJoinBackfillConfig } from './peer-join-backfill.js';
7
- import { StrandPeerObserver, type StrandPeerObservation } from './strand-peer-observer.js';
8
- import { StrandPeerBookSwap } from './strand-peer-book-swap.js';
9
- import type { StrandPeerBookStore } from './strand-peer-book.js';
7
+ import { strandNetworkStatePersistence, type StrandNetworkStateStore } from './strand-network-state.js';
10
8
  import {
11
9
  StrandRevocationEnforcer,
12
10
  createRevocationConnectionGater,
@@ -43,8 +41,9 @@ import type {
43
41
  } from './types.js';
44
42
  import { DEFAULT_CONNECTION_MONITOR, resolveStrandClusterSize, strandClusterPolicy } from './types.js';
45
43
  import { strandNodeAddrs } from './strand-network-config.js';
44
+ import { resolveRelayServer } from './relay-server.js';
46
45
  import { superviseRelayReservation, type RelayReservationSupervisor } from './relay-reservation.js';
47
- import { connectionManagerTimeouts, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
46
+ import { declaredCohortReadDeadlineMs, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
48
47
 
49
48
  const log = debug('sereus:cadre:strand-manager');
50
49
  const timing = debug('sereus:cadre:timing');
@@ -89,8 +88,15 @@ export function liveStrandStatus(instance: StrandInstance): 'active' | 'syncing'
89
88
  */
90
89
  export interface StartStrandConfig {
91
90
  strandRow: StrandRow;
92
- /** sApp configuration provided by the hosting application */
93
- sAppConfig: SAppConfig;
91
+ /**
92
+ * sApp configuration provided by the hosting application. Absent for a **storage
93
+ * replica** — a strand this node stores and serves without the app installed
94
+ * (`CadreNodeConfig.hostUnclaimedStrands`): no schema signature check, no `App`
95
+ * schema, no `sAppInfo` on the instance, and the default latency hint. A replica is
96
+ * always a joiner; `StrandDatabase` refuses to found one. An app that later claims the
97
+ * strand upgrades the running replica in place ({@link StrandInstanceManager.attachSApp}).
98
+ */
99
+ sAppConfig?: SAppConfig;
94
100
  storage?: StorageConfig;
95
101
  network?: NetworkConfig;
96
102
  profile: NodeProfile;
@@ -266,26 +272,14 @@ export interface StartStrandConfig {
266
272
  announceDelegateToRelay?: (strandId: string, relayAddr: string, delegatePeerId: string) => Promise<void>;
267
273
 
268
274
  /**
269
- * Called when the strand's libp2p node identifies a STRAND peer — one whose
270
- * identify result names this strand's block-transfer protocol, so never the
271
- * circuit relay or a bootstrap node — with the addresses it is dialable at
272
- * (`strand-peer-observer.ts`). Throttled per peer; must not throw. `CadreNode`
273
- * wires it to the node-local strand peer book, which is what lets a restarted
274
- * machine dial the peers it was talking to. Absent ⇒ nothing is observed.
275
- * Retained with the launch config, so a hibernation wake re-arms it on the
276
- * rebuilt node.
277
- */
278
- onStrandPeerIdentified?: (strandId: string, observation: StrandPeerObservation) => void;
279
-
280
- /**
281
- * The node-local strand peer book the book SWAP reads and writes
282
- * (`strand-peer-book-swap.ts`): the strand node signs its own addresses with
283
- * {@link privateKey}, exchanges signed entries with every strand peer it identifies,
284
- * and files what verifies. Absent ⇒ no swap runs — a peer that dials this node's
285
- * swap handler gets no answer. `CadreNode` passes its book store. Retained with the
286
- * launch config, so a hibernation wake re-arms the swap on the rebuilt node.
275
+ * The node-local strand network state (`strand-network-state.ts`): this strand's
276
+ * slice of it is handed to db-p2p as `persistence`, so the strand node saves its
277
+ * FRET routing table (with each peer's signed address record) on every connection
278
+ * and re-imports it when it is next built. Absent ⇒ nothing is saved and the node
279
+ * starts with an empty table. `CadreNode` passes its store. Retained with the launch
280
+ * config, so a hibernation wake rebuilds the node over the same state.
287
281
  */
288
- strandPeerBook?: StrandPeerBookStore;
282
+ networkState?: StrandNetworkStateStore;
289
283
  }
290
284
 
291
285
  /**
@@ -311,6 +305,11 @@ export interface ResumeStrandOverrides {
311
305
  servingMachines?: number;
312
306
  }
313
307
 
308
+ /** A {@link StrandInstanceManager.resumeStrand} or {@link StrandInstanceManager.quiesceStrand} of one strand. */
309
+ type RuntimeTransition =
310
+ | { kind: 'resume'; operation: Promise<StrandInstance> }
311
+ | { kind: 'quiesce'; operation: Promise<void> };
312
+
314
313
  /**
315
314
  * Get the isolated storage path for a specific strand.
316
315
  *
@@ -374,6 +373,26 @@ function resolveStrandStorage(
374
373
  return wrapStorageWithCache(storage, strandId);
375
374
  }
376
375
 
376
+ /**
377
+ * Verify an sApp's schema signature (fail-closed unless `requireSignedSchemas` is
378
+ * false) and project the config onto the instance's `sAppInfo`.
379
+ */
380
+ function verifiedSAppInfo(strandId: string, sAppConfig: SAppConfig, requireSignedSchemas: boolean | undefined): SAppInfo {
381
+ assertSchemaSignature(sAppConfig, { requireSignature: requireSignedSchemas ?? true });
382
+ log('Strand %s sApp schema signature verified (author: %s)', strandId, sAppConfig.id);
383
+ return {
384
+ id: sAppConfig.id,
385
+ version: sAppConfig.version,
386
+ schema: sAppConfig.schema,
387
+ signature: sAppConfig.signature
388
+ };
389
+ }
390
+
391
+ /** Log wording for what a launch runs: the sApp and its version, or a storage replica. */
392
+ function describeSApp(sAppConfig: SAppConfig | undefined): string {
393
+ return sAppConfig ? `sApp: ${sAppConfig.id} v${sAppConfig.version}` : 'storage replica';
394
+ }
395
+
377
396
  /**
378
397
  * Manages individual strand instances - creates and destroys isolated libp2p nodes
379
398
  * for each strand the cadre participates in.
@@ -395,24 +414,6 @@ export class StrandInstanceManager {
395
414
  * missed writes).
396
415
  */
397
416
  private backfills: Map<string, PeerJoinBackfill> = new Map();
398
- /**
399
- * The per-strand peer-book observation writer (`strand-peer-observer.ts`), keyed
400
- * by strand id. Same lifecycle rationale as {@link backfills}: started in
401
- * `buildStrandRuntime` right after the libp2p node exists, stopped and dropped in
402
- * `releaseRuntime`, so quiesce → resume re-arms it on the rebuilt node. Only
403
- * present when the launch config supplied `onStrandPeerIdentified`.
404
- */
405
- private peerObservers: Map<string, StrandPeerObserver> = new Map();
406
-
407
- /**
408
- * Per-strand strand peer book SWAP driver ({@link StrandPeerBookSwap}) — the signed
409
- * exchange of book entries between strand peers. Same lifetime as the peer-book
410
- * observer above: armed in `buildStrandRuntime` right after the libp2p node exists
411
- * (its protocol handler must be registered before an early peer dials it), stopped
412
- * and dropped in `releaseRuntime`. Only present when the launch config supplied
413
- * `strandPeerBook`.
414
- */
415
- private peerBookSwaps: Map<string, StrandPeerBookSwap> = new Map();
416
417
  /**
417
418
  * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
418
419
  * id. Same lifecycle rationale as {@link backfills}: created in
@@ -479,6 +480,23 @@ export class StrandInstanceManager {
479
480
  * wake becomes the better trade.
480
481
  */
481
482
  private strandStorages: Map<string, IRawStorage> = new Map();
483
+ /**
484
+ * The runtime build in flight per strand id (`buildStrandRuntime`, from `startStrand` or
485
+ * `resumeStrand`), present until it settles. The instance is tracked for the whole build,
486
+ * with its database not yet constructed or not yet initialized, so a caller that must act
487
+ * on the FINISHED runtime ({@link attachSApp}, {@link whenRuntimeBuilt}) waits on this instead. A failed build's
488
+ * rejection belongs to the call that started it.
489
+ */
490
+ private runtimeBuilds: Map<string, Promise<void>> = new Map();
491
+ /**
492
+ * The latest resume or quiesce requested per strand id, present until it settles. Each one
493
+ * starts only once the one requested before it has settled, so a wake that lands during a
494
+ * quiesce rebuilds after it, and a quiesce requested during a rebuild releases what that
495
+ * rebuild finished. The WHOLE operation, not its entry in {@link runtimeBuilds}: that settles
496
+ * before a resume's own catch records `'error'`, so a joiner awaiting it could read a stale
497
+ * status.
498
+ */
499
+ private transitions: Map<string, RuntimeTransition> = new Map();
482
500
  private stopping = false;
483
501
 
484
502
  constructor() {
@@ -543,7 +561,7 @@ export class StrandInstanceManager {
543
561
  return this.instances.get(strandId)!;
544
562
  }
545
563
 
546
- log('Starting strand instance: %s (sApp: %s v%s)', strandId, sAppConfig.id, sAppConfig.version);
564
+ log('Starting strand instance: %s (%s)', strandId, describeSApp(sAppConfig));
547
565
  const tTotal = performance.now();
548
566
 
549
567
  // The id becomes two names below: the storage scope key the embedder's provider
@@ -555,18 +573,7 @@ export class StrandInstanceManager {
555
573
  // still reaches buildStrandRuntime and still mints the protocol prefix.
556
574
  assertStrandScopeKey(strandId);
557
575
 
558
- // Verify schema signature before proceeding (fail-closed by default)
559
- const requireSignature = config.requireSignedSchemas ?? true;
560
- assertSchemaSignature(sAppConfig, { requireSignature });
561
- log('Strand %s sApp schema signature verified (author: %s)', strandId, sAppConfig.id);
562
-
563
- // Convert SAppConfig to SAppInfo for the instance
564
- const sAppInfo: SAppInfo = {
565
- id: sAppConfig.id,
566
- version: sAppConfig.version,
567
- schema: sAppConfig.schema,
568
- signature: sAppConfig.signature
569
- };
576
+ const sAppInfo = sAppConfig ? verifiedSAppInfo(strandId, sAppConfig, config.requireSignedSchemas) : undefined;
570
577
 
571
578
  // Resolve this strand's storage ONCE, before anything is recorded. If a factory
572
579
  // function is provided, it is called with the strandId to create strand-specific
@@ -578,7 +585,7 @@ export class StrandInstanceManager {
578
585
  }
579
586
 
580
587
  // Determine latency hint: sApp config > default
581
- const latencyHint = sAppConfig.latencyHint ?? config.defaultLatencyHint;
588
+ const latencyHint = sAppConfig?.latencyHint ?? config.defaultLatencyHint;
582
589
 
583
590
  const instance: StrandInstance = {
584
591
  strandId,
@@ -597,9 +604,9 @@ export class StrandInstanceManager {
597
604
  }
598
605
 
599
606
  try {
600
- await this.buildStrandRuntime(instance, config);
607
+ await this.trackRuntimeBuild(strandId, this.buildStrandRuntime(instance, config));
601
608
  timing('[startStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
602
- log('Strand %s started successfully with sApp %s', strandId, sAppConfig.id);
609
+ log('Strand %s started successfully (%s)', strandId, describeSApp(sAppConfig));
603
610
  return instance;
604
611
  } catch (error) {
605
612
  // Status/error first — the (now discarded) record is still what `log` reports on.
@@ -618,6 +625,35 @@ export class StrandInstanceManager {
618
625
  }
619
626
  }
620
627
 
628
+ /** Hold `build` in {@link runtimeBuilds} until it settles, and settle as it does. */
629
+ private async trackRuntimeBuild(strandId: string, build: Promise<void>): Promise<void> {
630
+ this.runtimeBuilds.set(strandId, build);
631
+ try {
632
+ await build;
633
+ } finally {
634
+ if (this.runtimeBuilds.get(strandId) === build) {
635
+ this.runtimeBuilds.delete(strandId);
636
+ }
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Wait until no runtime build of `strandId` is in flight. Never rejects: a failed build
642
+ * is reported by the call that started it, and the caller re-reads the instance after.
643
+ *
644
+ * NOTE: after a failed launch the re-read sees the instance gone only because
645
+ * `startStrand`'s catch deletes it synchronously, and its continuation is queued before
646
+ * this waiter's. If that cleanup ever awaits before `instances.delete`, a waiter can act
647
+ * on the dying record; move the cleanup inside the tracked promise then.
648
+ */
649
+ private async settleRuntimeBuilds(strandId: string): Promise<void> {
650
+ for (let build = this.runtimeBuilds.get(strandId); build; build = this.runtimeBuilds.get(strandId)) {
651
+ await build.catch((error: unknown) => {
652
+ log('Strand %s: the runtime build waited on failed (reported by its launcher): %o', strandId, error);
653
+ });
654
+ }
655
+ }
656
+
621
657
  /**
622
658
  * Build (or rebuild) the libp2p node + StrandDatabase for an instance and
623
659
  * attach them, transitioning it to `active`. Shared by `startStrand` (fresh
@@ -649,9 +685,9 @@ export class StrandInstanceManager {
649
685
  const networkName = `strand-${strandId}`;
650
686
  const protocolPrefix = `/optimystic/${networkName}`;
651
687
 
652
- // Determine relay mode: if explicitly set in config, use that;
653
- // otherwise default to true for storage profile nodes.
654
- const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
688
+ // Whether this node runs the circuit-relay server, and its init — the same
689
+ // resolution the control node takes from the same config (`relay-server.ts`).
690
+ const relayServer = resolveRelayServer(config.network, config.profile);
655
691
  // The strand-node VIEW of the machine's one `NetworkConfig`, not the control
656
692
  // node's resolution: fixed direct listen ports become ephemeral (two nodes cannot
657
693
  // bind one port), the announce config is dropped (it names the control node's
@@ -725,7 +761,12 @@ export class StrandInstanceManager {
725
761
  networkName,
726
762
  storage: strandStorage,
727
763
  fretProfile: config.profile === 'storage' ? 'core' : 'edge',
728
- relay: enableRelay,
764
+ relay: relayServer.enabled,
765
+ // NOTE: unlike the control node, a strand node has no unauthorized-reservation budget,
766
+ // so with the per-connection cap off any peer that reaches this node can hold one of
767
+ // `maxReservations` slots and forward uncapped through it. If strand relays are ever
768
+ // abused for bandwidth, give strand nodes a budget or a capped init of their own.
769
+ ...(relayServer.enabled && { relayServerInit: relayServer.init }),
729
770
  clusterSize: strandClusterSize,
730
771
  // Deliberately NOT CONTROL_CLUSTER_POLICY: a strand is application data with its own
731
772
  // breadth reasoning, and the shape match with the control policy is a coincidence.
@@ -737,16 +778,29 @@ export class StrandInstanceManager {
737
778
  // returns the frozen STRAND_CLUSTER_POLICY itself, declaring nothing. Resolved
738
779
  // HERE rather than at `startStrand`, so a wake from hibernation would pick up a
739
780
  // serving set that changed while the strand slept.
740
- // The read deadline is the same field the control node reads, because the two
741
- // networks ride one link; absent, the base policy's COHORT_READ_DEADLINE_MS stands.
781
+ // The read deadline is settled by the same helper the control node uses, because the
782
+ // two networks ride one link: the host's own, else derived from its declared
783
+ // `linkRoundTripMs`, else nothing, and the base policy's COHORT_READ_DEADLINE_MS stands.
742
784
  clusterPolicy: strandClusterPolicy(strandClusterSize, {
743
785
  servingMachines: config.servingMachines,
744
- cohortQueryTimeoutMs: config.network?.cohortQueryTimeoutMs
786
+ cohortQueryTimeoutMs: declaredCohortReadDeadlineMs(config.network)
745
787
  }),
746
788
  arachnode: {
747
789
  enableRingZulu: config.profile === 'storage'
748
790
  },
749
791
  ...(config.privateKey && { privateKey: config.privateKey }),
792
+ // Built per runtime, not per launch: the adapter stops saving once the strand's
793
+ // state is forgotten, and a rebuilt runtime is what starts it saving again.
794
+ //
795
+ // NOTE: the saved state also restores which peers db-p2p saw serving the strand,
796
+ // and it counts them in the cohort until FRET finds them unreachable. A node built
797
+ // while every other member is offline therefore fails its first writes ("Failed to
798
+ // get super-majority") — for 4-6 s on loopback, the window a running node already
799
+ // sees when a member drops. If apps report failed sends after relaunching alone on
800
+ // a slow relayed link, retry the write in the app or raise it upstream (db-p2p's
801
+ // remembered-serving fallback); dropping `persistence` brings back the split
802
+ // restart.
803
+ ...(config.networkState && { persistence: strandNetworkStatePersistence(config.networkState, strandId) }),
750
804
  ...(config.network?.transports && { transports: config.network.transports }),
751
805
  ...(config.network?.noiseCrypto && { noiseCrypto: config.network.noiseCrypto }),
752
806
  // Unconditional, and the same default the control node takes: every node of
@@ -754,10 +808,10 @@ export class StrandInstanceManager {
754
808
  // widened ping deadline has to reach the strand nodes too (see
755
809
  // DEFAULT_CONNECTION_MONITOR).
756
810
  connectionMonitor: config.network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
757
- // The same declared-link limits the control node takes, and for the same reason: a
758
- // strand node is the listener for every other member's strand node (`link-budget.ts`,
759
- // `connectionManagerTimeouts`).
760
- connectionManager: connectionManagerTimeouts(config.network?.linkRoundTripMs),
811
+ // The same declared link the control node states, and for the same reason: a strand
812
+ // node is also the listener for every other member's strand node, and Optimystic
813
+ // derives that listener's limit from this (`link-budget.ts`).
814
+ linkRoundTripMs: resolveLinkRoundTripMs(config.network?.linkRoundTripMs),
761
815
  // Listen entries plus the WebSocket transport switch they imply — a strand node
762
816
  // announces nothing the operator configured (`strand-network-config.ts`), and
763
817
  // spreads AFTER `transports` above because the switch is a no-op whenever the
@@ -777,48 +831,6 @@ export class StrandInstanceManager {
777
831
 
778
832
  instance.libp2pNode = node;
779
833
 
780
- // The peer-book observation writer, armed BEFORE the database bring-up below
781
- // (unlike the backfill) because the bootstrap dials that follow `libp2p.start()`
782
- // land during that bring-up, and they are exactly the peers worth remembering:
783
- // identify for an early peer may already have fired by the time anything after
784
- // the database initializes runs. Registered in the map immediately so the
785
- // failure rollback (releaseRuntime) stops it like every other runtime component.
786
- const onStrandPeerIdentified = config.onStrandPeerIdentified;
787
- if (onStrandPeerIdentified) {
788
- const observer = new StrandPeerObserver({
789
- label: strandId,
790
- libp2p: node,
791
- protocolPrefix,
792
- onObserved: (observation) => onStrandPeerIdentified(strandId, observation)
793
- });
794
- this.peerObservers.set(strandId, observer);
795
- observer.start();
796
- }
797
-
798
- // The strand peer book SWAP, armed at the same moment and for the same reason as
799
- // the observer: a peer that dials this node's swap handler before it is registered
800
- // fails its exchange and sits out a ten-minute throttle. The own entry is signed
801
- // now and re-signed on every address change, which covers the relay reservation
802
- // landing below (`self:peer:update` fires when the circuit addr is added), so no
803
- // separate post-relay step is needed. Registered in the map immediately so the
804
- // failure rollback (releaseRuntime) stops it like every other runtime component.
805
- //
806
- // NOTE: this is a third `peer:identify` listener per running strand, beside the
807
- // observer's and the backfill's — see the backfill's NOTE below for when to fold
808
- // them into one dispatcher.
809
- if (config.strandPeerBook) {
810
- const swap = new StrandPeerBookSwap({
811
- strandId,
812
- libp2p: node,
813
- protocolPrefix,
814
- store: config.strandPeerBook,
815
- privateKey: config.privateKey,
816
- linkRoundTripMs: config.network?.linkRoundTripMs
817
- });
818
- this.peerBookSwaps.set(strandId, swap);
819
- swap.start();
820
- }
821
-
822
834
  // One reservation supervisor PER RELAY, started now so the first attempts
823
835
  // overlap the database bring-up below (the relay is never in a strand's
824
836
  // Optimystic cohort — its protocol ids are namespaced `/optimystic/strand-<id>/…`
@@ -973,7 +985,7 @@ export class StrandInstanceManager {
973
985
  // failing here would only trade that for `StrandWatcher`'s full-rebuild retry.
974
986
  //
975
987
  // NOTE: a relay that is down costs this launch one full drive — the reservation budget
976
- // counted from the declared link round trip, 14 s at its default (`link-budget.ts`) — and
988
+ // counted from the declared link round trip, 18 s at its default (`link-budget.ts`) — and
977
989
  // `StrandWatcher` launches strands one at a time, so N strands cost N of those in
978
990
  // bring-up during a relay outage, and MORE on a host that declared a slower link. If that
979
991
  // ever matters, stop awaiting here (the circuit addr then lands after `active`) rather
@@ -982,8 +994,9 @@ export class StrandInstanceManager {
982
994
  await this.awaitFirstRelayAttempts(strandId);
983
995
  timing('[buildStrandRuntime:%s] relay first attempts: %dms', strandId, Math.round(performance.now() - t0));
984
996
 
997
+ // No `lastActivity` stamp: bringing a runtime up is not activity. A check-in marks
998
+ // activity before its resume and must see only what landed while the strand was up.
985
999
  instance.status = liveStrandStatus(instance);
986
- instance.lastActivity = new Date();
987
1000
  if (instance.status === 'syncing') {
988
1001
  log('Strand %s launched as a joiner with no Strand.Header held yet — writes are withheld until ' +
989
1002
  'a member of the strand is reached', strandId);
@@ -1031,20 +1044,6 @@ export class StrandInstanceManager {
1031
1044
  backfill.stop();
1032
1045
  this.backfills.delete(instance.strandId);
1033
1046
  }
1034
- // The peer-book observer goes with the node it listened on; a resume re-arms
1035
- // one on the rebuilt node.
1036
- const peerObserver = this.peerObservers.get(instance.strandId);
1037
- if (peerObserver) {
1038
- peerObserver.stop();
1039
- this.peerObservers.delete(instance.strandId);
1040
- }
1041
- // The book swap likewise: unsubscribed and its handler unregistered while the node is
1042
- // still up; a resume re-arms one, which re-signs the own entry over the new addresses.
1043
- const peerBookSwap = this.peerBookSwaps.get(instance.strandId);
1044
- if (peerBookSwap) {
1045
- this.peerBookSwaps.delete(instance.strandId);
1046
- await peerBookSwap.stop();
1047
- }
1048
1047
  // The revoked-peer enforcer goes with the runtime it gated: a resume
1049
1048
  // rebuilds it with a fresh (initially empty, fail-open) snapshot.
1050
1049
  const revocationEnforcer = this.revocationEnforcers.get(instance.strandId);
@@ -1257,24 +1256,54 @@ export class StrandInstanceManager {
1257
1256
  * Quiesce a strand: release its strand-network resources (stop the libp2p node,
1258
1257
  * close the StrandDatabase) while RETAINING the instance record — identity,
1259
1258
  * sAppInfo, keys, latency hint, metadata — and its launch config so it can be
1260
- * resumed later. Mechanically this is `stopStrand` minus the instance/config
1261
- * deletion. The caller sets the post-quiesce status (e.g. `hibernating`).
1262
- * No-ops when the strand is missing or already quiesced.
1259
+ * resumed later, then mark it `hibernating`. Mechanically this is `stopStrand` minus
1260
+ * the instance/config deletion. A tracked strand with no runtime left (a failed
1261
+ * rebuild already released it) is only marked; an untracked one is left alone.
1262
+ *
1263
+ * Ordered with {@link resumeStrand} (see {@link transitions}): it starts once the resume
1264
+ * or launch build before it has settled, so it releases a finished runtime, and the
1265
+ * status write is its last step, so a resume requested meanwhile writes after it. A call
1266
+ * made while another quiesce is the latest request joins it. A failed release leaves the
1267
+ * status as it was and rejects.
1263
1268
  */
1264
1269
  async quiesceStrand(strandId: string): Promise<void> {
1270
+ const previous = this.transitions.get(strandId);
1271
+ if (previous?.kind === 'quiesce') {
1272
+ log('quiesceStrand: strand %s — joining the quiesce already requested', strandId);
1273
+ return previous.operation;
1274
+ }
1275
+ const operation = this.runQuiesce(strandId, previous);
1276
+ this.holdTransition(strandId, { kind: 'quiesce', operation });
1277
+ return operation;
1278
+ }
1279
+
1280
+ /** Body of {@link quiesceStrand}. */
1281
+ private async runQuiesce(strandId: string, previous: RuntimeTransition | undefined): Promise<void> {
1282
+ await this.settleTransition(strandId, previous);
1283
+ await this.settleRuntimeBuilds(strandId);
1265
1284
  const instance = this.instances.get(strandId);
1266
1285
  if (!instance) {
1267
1286
  log('quiesceStrand: strand %s not found', strandId);
1268
1287
  return;
1269
1288
  }
1270
- if (!instance.libp2pNode && !instance.database) {
1289
+ if (instance.libp2pNode || instance.database) {
1290
+ log('Quiescing strand instance: %s', strandId);
1291
+ await this.releaseRuntime(instance);
1292
+ log('Strand %s quiesced (resources released, instance retained)', strandId);
1293
+ } else {
1271
1294
  log('quiesceStrand: strand %s already quiesced', strandId);
1272
- return;
1273
1295
  }
1296
+ instance.status = 'hibernating';
1297
+ }
1274
1298
 
1275
- log('Quiescing strand instance: %s', strandId);
1276
- await this.releaseRuntime(instance);
1277
- log('Strand %s quiesced (resources released, instance retained)', strandId);
1299
+ /**
1300
+ * Whether the latest runtime transition requested for `strandId` is a quiesce that has
1301
+ * not finished. Its handles may still be attached and its status still live, but it is on
1302
+ * its way to `hibernating`, so a caller that needs it up must {@link resumeStrand} it — the
1303
+ * resume runs after the quiesce — rather than treat it as live.
1304
+ */
1305
+ isQuiescing(strandId: string): boolean {
1306
+ return this.transitions.get(strandId)?.kind === 'quiesce';
1278
1307
  }
1279
1308
 
1280
1309
  /**
@@ -1284,11 +1313,65 @@ export class StrandInstanceManager {
1284
1313
  * launch (the cohort `bootstrapNodes` seed and the strand's `servingMachines`
1285
1314
  * count) and updates the retained config so a later resume reuses the latest
1286
1315
  * values. Returns the live instance unchanged if it is already running.
1316
+ *
1317
+ * Overlapping calls share one rebuild: a call made while a resume is the latest request
1318
+ * for the strand — a wake landing during a check-in — joins it and settles exactly as it
1319
+ * does, `'error'` status included. The joiner's own `overrides` are ignored; the first
1320
+ * resume's seed wins, and both callers resolved the cohort seed moments apart. A call made
1321
+ * while a quiesce is the latest request runs after that quiesce and rebuilds (see
1322
+ * {@link transitions}), and one made while `startStrand` is still building waits for that
1323
+ * build rather than starting a second.
1287
1324
  */
1288
1325
  async resumeStrand(strandId: string, overrides?: ResumeStrandOverrides): Promise<StrandInstance> {
1289
1326
  if (this.stopping) {
1290
1327
  throw new Error('StrandInstanceManager is stopping');
1291
1328
  }
1329
+ // No `await` between this read and `holdTransition`, or a second caller slips through
1330
+ // and builds.
1331
+ const previous = this.transitions.get(strandId);
1332
+ if (previous?.kind === 'resume') {
1333
+ log('resumeStrand: strand %s — joining the resume already in flight', strandId);
1334
+ return previous.operation;
1335
+ }
1336
+ const operation = this.runResume(strandId, previous, overrides);
1337
+ this.holdTransition(strandId, { kind: 'resume', operation });
1338
+ return operation;
1339
+ }
1340
+
1341
+ /** Record `transition` as the latest for `strandId` until it settles. */
1342
+ private holdTransition(strandId: string, transition: RuntimeTransition): void {
1343
+ this.transitions.set(strandId, transition);
1344
+ const release = (): void => {
1345
+ if (this.transitions.get(strandId) === transition) {
1346
+ this.transitions.delete(strandId);
1347
+ }
1348
+ };
1349
+ // The rejection itself reaches the caller through the operation returned to it.
1350
+ void transition.operation.then(release, release);
1351
+ }
1352
+
1353
+ /** Wait for the transition requested before this one. Never rejects: its failure belongs to its caller. */
1354
+ private async settleTransition(strandId: string, previous: RuntimeTransition | undefined): Promise<void> {
1355
+ if (!previous) {
1356
+ return;
1357
+ }
1358
+ await (previous.operation as Promise<unknown>).catch((error: unknown) => {
1359
+ log('Strand %s: the %s queued ahead failed (reported by its caller): %o', strandId, previous.kind, error);
1360
+ });
1361
+ }
1362
+
1363
+ /** Body of {@link resumeStrand}; at most one runs per strand at a time. */
1364
+ private async runResume(
1365
+ strandId: string,
1366
+ previous: RuntimeTransition | undefined,
1367
+ overrides?: ResumeStrandOverrides
1368
+ ): Promise<StrandInstance> {
1369
+ // Before any "already live" test: a quiesce ahead still has the handles attached, and
1370
+ // mid-build (a launch still building) the libp2p node is attached while the database is
1371
+ // not. A failed launch has already dropped the instance, and the check below reports it
1372
+ // untracked.
1373
+ await this.settleTransition(strandId, previous);
1374
+ await this.settleRuntimeBuilds(strandId);
1292
1375
  const instance = this.instances.get(strandId);
1293
1376
  if (!instance) {
1294
1377
  throw new Error(`Cannot resume strand ${strandId}: not tracked`);
@@ -1325,7 +1408,7 @@ export class StrandInstanceManager {
1325
1408
 
1326
1409
  instance.status = 'starting';
1327
1410
  try {
1328
- await this.buildStrandRuntime(instance, resumeConfig);
1411
+ await this.trackRuntimeBuild(strandId, this.buildStrandRuntime(instance, resumeConfig));
1329
1412
  timing('[resumeStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
1330
1413
  log('Strand %s resumed successfully', strandId);
1331
1414
  return instance;
@@ -1353,7 +1436,8 @@ export class StrandInstanceManager {
1353
1436
  * - `'already-founder'` — the retained config already founds; nothing to do.
1354
1437
  * - `'bootstrapped'` — config flipped and the live database ran the bootstrap.
1355
1438
  * - `'needs-resume'` — config flipped, but the instance is quiesced (no live
1356
- * database), so the bootstrap could not run here: the CALLER must wake the
1439
+ * database) or being quiesced (its database is closing), so the bootstrap could
1440
+ * not run here: the CALLER must wake the
1357
1441
  * strand (`CadreNode.wakeStrand`, which owns the hibernation bookkeeping this
1358
1442
  * manager does not) so the rebuild — which now founds — runs it.
1359
1443
  * @param resolvePartyKey - Asked for the party's own membership key when (and only
@@ -1386,7 +1470,7 @@ export class StrandInstanceManager {
1386
1470
  // A fresh object rather than mutating in place: startStrand retains the CALLER'S
1387
1471
  // config object, which is not ours to rewrite.
1388
1472
  this.launchConfigs.set(strandId, { ...config, founder: true, partyMemberPrivateKey });
1389
- if (!instance.database && !this.firstSyncGates.has(strandId)) {
1473
+ if (this.isQuiescing(strandId) || (!instance.database && !this.firstSyncGates.has(strandId))) {
1390
1474
  return 'needs-resume';
1391
1475
  }
1392
1476
  try {
@@ -1398,6 +1482,75 @@ export class StrandInstanceManager {
1398
1482
  return 'bootstrapped';
1399
1483
  }
1400
1484
 
1485
+ /**
1486
+ * Give a tracked storage replica ({@link StartStrandConfig.sAppConfig} absent) the app's
1487
+ * schema in place — the seam for an app on this machine claiming a strand this node was
1488
+ * already hosting. No runtime rebuild: the libp2p node, its peer id and connections, the
1489
+ * store and its warm cache all stay, and the new `App` tables read the blocks the replica
1490
+ * already holds. Callers attach BEFORE {@link foundExistingStrand}: the founder bootstrap
1491
+ * writes the sApp into `Strand.Header` and refuses a database that has none.
1492
+ *
1493
+ * A runtime build in flight (the replica's launch, or a hibernation wake) is waited out
1494
+ * first, so the attach acts on the database that build produced. Then, in order: the
1495
+ * schema signature is checked before anything changes; the retained launch config takes
1496
+ * the sApp, so every later rebuild applies it through `composeStrand`; the live database —
1497
+ * published, or still held by the first-sync gate — gets the schema; and only then does
1498
+ * the instance record `sAppInfo` and the sApp's latency hint. A quiesced instance gets the
1499
+ * config alone, and its next resume applies the schema.
1500
+ *
1501
+ * @returns `'already-attached'` when the instance already runs an sApp — a DIFFERENT one
1502
+ * is logged and left as it is, as a second claim of a claimed strand always has been —
1503
+ * else `'attached'`.
1504
+ * @throws when the strand is not tracked (a caller bug, or a launch that failed while
1505
+ * this waited on it); when the schema signature is refused, having changed nothing; and
1506
+ * whatever the live apply throws (e.g. a quiesce closed the database mid-apply) — the
1507
+ * retained config then still carries the sApp and `sAppInfo` stays unset, so the next
1508
+ * claim retries the apply.
1509
+ *
1510
+ * NOTE: two claims of one replica racing each other both pass the `sAppInfo` check and
1511
+ * apply concurrently; the loser of the declarative diff can reject, and its `addStrand`
1512
+ * retry then resolves `'already-attached'`. Needs an app calling `addStrand` twice at once
1513
+ * (or beside a watcher retry); if it is ever seen, chain attaches per strand id.
1514
+ */
1515
+ async attachSApp(
1516
+ strandId: string,
1517
+ sAppConfig: SAppConfig,
1518
+ options: { requireSignedSchemas?: boolean } = {}
1519
+ ): Promise<'attached' | 'already-attached'> {
1520
+ await this.settleRuntimeBuilds(strandId);
1521
+ const instance = this.instances.get(strandId);
1522
+ const config = this.launchConfigs.get(strandId);
1523
+ if (!instance || !config) {
1524
+ throw new Error(`Cannot attach an sApp to strand ${strandId}: not tracked`);
1525
+ }
1526
+ if (instance.sAppInfo) {
1527
+ if (instance.sAppInfo.id !== sAppConfig.id) {
1528
+ log('attachSApp: strand %s already runs sApp %s — the claim for sApp %s is ignored',
1529
+ strandId, instance.sAppInfo.id, sAppConfig.id);
1530
+ }
1531
+ return 'already-attached';
1532
+ }
1533
+ const sAppInfo = verifiedSAppInfo(strandId, sAppConfig, options.requireSignedSchemas);
1534
+ // A fresh object rather than mutating in place: startStrand retains the CALLER'S
1535
+ // config object, which is not ours to rewrite.
1536
+ this.launchConfigs.set(strandId, { ...config, sAppConfig });
1537
+ const database = instance.database ?? this.firstSyncGates.get(strandId)?.database;
1538
+ if (database) {
1539
+ await database.attachAppSchema(sAppConfig);
1540
+ } else {
1541
+ log('attachSApp: strand %s is quiesced — its next resume applies the sApp schema', strandId);
1542
+ }
1543
+ instance.sAppInfo = sAppInfo;
1544
+ if (sAppConfig.latencyHint) {
1545
+ // HibernationManager reads the hint whenever it arms a timer, so the app's hint governs
1546
+ // from the next one: a timer already armed runs once at the old duration, and a replica
1547
+ // launched under a realtime default was never tracked, so it stays up until relaunched.
1548
+ instance.latencyHint = sAppConfig.latencyHint;
1549
+ }
1550
+ log('Strand %s upgraded from storage replica to %s', strandId, describeSApp(sAppConfig));
1551
+ return 'attached';
1552
+ }
1553
+
1401
1554
  /**
1402
1555
  * Whether `strandId` is launched but still waiting for its first sync — a joiner whose
1403
1556
  * runtime is up while its database is withheld (status `'syncing'`, or `'idle'` after
@@ -1453,6 +1606,18 @@ export class StrandInstanceManager {
1453
1606
  });
1454
1607
  }
1455
1608
 
1609
+ /**
1610
+ * The tracked instance once no runtime build of it is in flight, or `undefined` when none
1611
+ * is tracked by then — never launched, or the launch waited on failed and dropped its
1612
+ * record. For a caller about to hand a tracked instance to an app: until its build settles
1613
+ * the instance is `'starting'` with no database, and a failed launch leaves no instance.
1614
+ * Never rejects; a failed build is reported by the call that started it.
1615
+ */
1616
+ async whenRuntimeBuilt(strandId: string): Promise<StrandInstance | undefined> {
1617
+ await this.settleRuntimeBuilds(strandId);
1618
+ return this.instances.get(strandId);
1619
+ }
1620
+
1456
1621
  /**
1457
1622
  * Hand a launch's database to the app: set `instance.database`, retire the gate that
1458
1623
  * held it, release every {@link whenWritable} waiter, and — when the launch had already
@@ -1482,9 +1647,11 @@ export class StrandInstanceManager {
1482
1647
  this.firstSyncGates.delete(strandId);
1483
1648
  }
1484
1649
  instance.database = database;
1485
- instance.lastActivity = new Date();
1486
1650
  const wasGated = instance.status !== 'starting';
1487
1651
  if (wasGated) {
1652
+ // A Header delivered by a peer is strand activity; a publish during bring-up is not
1653
+ // (see the build's end in `buildStrandRuntime`).
1654
+ instance.lastActivity = new Date();
1488
1655
  instance.status = 'active';
1489
1656
  log('Strand %s is now writable: Strand.Header held', strandId);
1490
1657
  }