@serfab/cadre-core 1.6.0 → 1.8.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 (179) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +548 -137
  3. package/dist/cadre-node.js +1208 -383
  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 +13 -6
  45. package/dist/index.js +27 -5
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +177 -0
  48. package/dist/joined-strand-store.js +431 -0
  49. package/dist/joined-strand-store.js.map +1 -0
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/key-store.d.ts +6 -0
  53. package/dist/key-store.js.map +1 -1
  54. package/dist/link-budget.d.ts +367 -69
  55. package/dist/link-budget.js +375 -72
  56. package/dist/link-budget.js.map +1 -1
  57. package/dist/membership-connection-gater.d.ts +30 -43
  58. package/dist/membership-connection-gater.js +31 -42
  59. package/dist/membership-connection-gater.js.map +1 -1
  60. package/dist/node-local-snapshot.d.ts +5 -4
  61. package/dist/node-local-snapshot.js +6 -3
  62. package/dist/node-local-snapshot.js.map +1 -1
  63. package/dist/peer-dial.d.ts +24 -4
  64. package/dist/peer-dial.js +74 -5
  65. package/dist/peer-dial.js.map +1 -1
  66. package/dist/peer-join-backfill.d.ts +35 -15
  67. package/dist/peer-join-backfill.js +115 -55
  68. package/dist/peer-join-backfill.js.map +1 -1
  69. package/dist/push-fanout.d.ts +6 -0
  70. package/dist/push-fanout.js +6 -0
  71. package/dist/push-fanout.js.map +1 -1
  72. package/dist/relay-reservation.d.ts +3 -1
  73. package/dist/relay-reservation.js +3 -1
  74. package/dist/relay-reservation.js.map +1 -1
  75. package/dist/relay-server.d.ts +79 -0
  76. package/dist/relay-server.js +74 -0
  77. package/dist/relay-server.js.map +1 -0
  78. package/dist/seed-bootstrap.d.ts +57 -4
  79. package/dist/seed-bootstrap.js +107 -83
  80. package/dist/seed-bootstrap.js.map +1 -1
  81. package/dist/storage-scope.d.ts +33 -17
  82. package/dist/storage-scope.js +44 -23
  83. package/dist/storage-scope.js.map +1 -1
  84. package/dist/strand-addr-protocol.d.ts +58 -21
  85. package/dist/strand-addr-protocol.js +133 -85
  86. package/dist/strand-addr-protocol.js.map +1 -1
  87. package/dist/strand-database.d.ts +20 -2
  88. package/dist/strand-database.js +42 -5
  89. package/dist/strand-database.js.map +1 -1
  90. package/dist/strand-first-sync-gate.d.ts +59 -21
  91. package/dist/strand-first-sync-gate.js +58 -19
  92. package/dist/strand-first-sync-gate.js.map +1 -1
  93. package/dist/strand-formation-deadlines.d.ts +151 -0
  94. package/dist/strand-formation-deadlines.js +171 -0
  95. package/dist/strand-formation-deadlines.js.map +1 -0
  96. package/dist/strand-formation-manager.d.ts +63 -30
  97. package/dist/strand-formation-manager.js +67 -41
  98. package/dist/strand-formation-manager.js.map +1 -1
  99. package/dist/strand-formation-protocol.d.ts +45 -35
  100. package/dist/strand-formation-protocol.js +59 -122
  101. package/dist/strand-formation-protocol.js.map +1 -1
  102. package/dist/strand-instance-manager.d.ts +152 -6
  103. package/dist/strand-instance-manager.js +331 -42
  104. package/dist/strand-instance-manager.js.map +1 -1
  105. package/dist/strand-membership-writer.d.ts +6 -0
  106. package/dist/strand-membership-writer.js +11 -0
  107. package/dist/strand-membership-writer.js.map +1 -1
  108. package/dist/strand-peer-book-file.d.ts +12 -0
  109. package/dist/strand-peer-book-file.js +37 -0
  110. package/dist/strand-peer-book-file.js.map +1 -0
  111. package/dist/strand-peer-book-protocol.d.ts +168 -0
  112. package/dist/strand-peer-book-protocol.js +356 -0
  113. package/dist/strand-peer-book-protocol.js.map +1 -0
  114. package/dist/strand-peer-book-swap.d.ts +104 -0
  115. package/dist/strand-peer-book-swap.js +284 -0
  116. package/dist/strand-peer-book-swap.js.map +1 -0
  117. package/dist/strand-peer-book.d.ts +159 -0
  118. package/dist/strand-peer-book.js +361 -0
  119. package/dist/strand-peer-book.js.map +1 -0
  120. package/dist/strand-peer-observer.d.ts +88 -0
  121. package/dist/strand-peer-observer.js +180 -0
  122. package/dist/strand-peer-observer.js.map +1 -0
  123. package/dist/strand-revocation-enforcer.js +2 -1
  124. package/dist/strand-revocation-enforcer.js.map +1 -1
  125. package/dist/strand-solicitation.d.ts +36 -20
  126. package/dist/strand-solicitation.js +4 -4
  127. package/dist/strand-solicitation.js.map +1 -1
  128. package/dist/strand-wake-protocol.d.ts +80 -23
  129. package/dist/strand-wake-protocol.js +89 -64
  130. package/dist/strand-wake-protocol.js.map +1 -1
  131. package/dist/types.d.ts +181 -33
  132. package/dist/types.js +4 -3
  133. package/dist/types.js.map +1 -1
  134. package/package.json +11 -7
  135. package/src/cadre-node.ts +1316 -398
  136. package/src/control-authorization.ts +2 -1
  137. package/src/control-cohort.ts +6 -0
  138. package/src/control-database.ts +122 -18
  139. package/src/control-formation-recorder.ts +354 -305
  140. package/src/control-read-retry.ts +14 -12
  141. package/src/control-schema.ts +67 -5
  142. package/src/control-stream.ts +25 -1
  143. package/src/control-write-retry.ts +13 -2
  144. package/src/delegate-admission.ts +23 -13
  145. package/src/diagnostics/connection-path.ts +0 -7
  146. package/src/formation-approval.ts +610 -604
  147. package/src/fs-atomic.ts +14 -4
  148. package/src/hibernation-manager.ts +638 -437
  149. package/src/index.ts +91 -6
  150. package/src/joined-strand-store.ts +498 -0
  151. package/src/key-store-file.ts +14 -5
  152. package/src/key-store.ts +6 -0
  153. package/src/link-budget.ts +393 -74
  154. package/src/membership-connection-gater.ts +31 -45
  155. package/src/node-local-snapshot.ts +293 -289
  156. package/src/peer-dial.ts +81 -7
  157. package/src/peer-join-backfill.ts +120 -57
  158. package/src/push-fanout.ts +6 -0
  159. package/src/relay-reservation.ts +3 -1
  160. package/src/relay-server.ts +98 -0
  161. package/src/seed-bootstrap.ts +133 -91
  162. package/src/storage-scope.ts +44 -23
  163. package/src/strand-addr-protocol.ts +547 -458
  164. package/src/strand-database.ts +287 -242
  165. package/src/strand-first-sync-gate.ts +58 -19
  166. package/src/strand-formation-deadlines.ts +227 -0
  167. package/src/strand-formation-manager.ts +700 -647
  168. package/src/strand-formation-protocol.ts +103 -150
  169. package/src/strand-instance-manager.ts +387 -45
  170. package/src/strand-membership-writer.ts +12 -0
  171. package/src/strand-peer-book-file.ts +39 -0
  172. package/src/strand-peer-book-protocol.ts +466 -0
  173. package/src/strand-peer-book-swap.ts +338 -0
  174. package/src/strand-peer-book.ts +475 -0
  175. package/src/strand-peer-observer.ts +235 -0
  176. package/src/strand-revocation-enforcer.ts +2 -1
  177. package/src/strand-solicitation.ts +519 -500
  178. package/src/strand-wake-protocol.ts +401 -355
  179. package/src/types.ts +185 -33
package/src/cadre-node.ts CHANGED
@@ -2,6 +2,7 @@ import debug from 'debug';
2
2
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
3
  import type { Libp2p, PeerId, PrivateKey, Connection } from '@libp2p/interface';
4
4
  import { peerIdFromString, peerIdFromPrivateKey } from '@libp2p/peer-id';
5
+ import { generateKeyPair } from '@libp2p/crypto/keys';
5
6
  import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
6
7
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
7
8
  import { multiaddr } from '@multiformats/multiaddr';
@@ -49,13 +50,22 @@ import {
49
50
  isValidStrandScopeKey
50
51
  } from './storage-scope.js';
51
52
  import { generateStrandMemberKey, strandMemberKeyPair } from './strand-member-key.js';
52
- import { assertNotPreSplitStrand, issueInvite, PreSplitStrandIdentityError } from './strand-membership-writer.js';
53
+ import { assertNotPreSplitStrand, issueInvite, PreSplitStrandIdentityError, readStrandHeaderSAppId } from './strand-membership-writer.js';
53
54
  import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
54
55
  import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
55
56
  import { loadOrCreateIdentityKey } from './identity-key.js';
56
57
  import { MemoryTrustedOwnerStore, type TrustedOwnerStore, type TrustSource } from './trusted-owner-store.js';
57
58
  import { MemoryBootstrapPeerStore, type BootstrapPeerStore } from './bootstrap-peer-store.js';
59
+ import { MemoryStrandPeerBookStore, type StrandPeerBookStore, type StrandPeerEntry } from './strand-peer-book.js';
60
+ import type { StrandPeerObservation } from './strand-peer-observer.js';
58
61
  import { MemoryEnrolledMachineStore, type EnrolledMachineStore } from './enrolled-machine-store.js';
62
+ import {
63
+ JoinedStrandSession,
64
+ KeyStoreJoinedStrandStore,
65
+ MemoryJoinedStrandStore,
66
+ type JoinedStrandStore,
67
+ type PartyJoinedStrandLedger
68
+ } from './joined-strand-store.js';
59
69
  import { mergePeerAddrs, groupAddrsByPeerId, type MergeAddrsResult } from './peer-addr-book.js';
60
70
  import { verifyCadrePeerVoucher } from './peer-authorization.js';
61
71
  import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
@@ -89,21 +99,21 @@ import {
89
99
  } from './control-cohort.js';
90
100
  import {
91
101
  dialPeerAddrs,
102
+ SelfRelayOnlyError,
92
103
  CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS,
93
104
  type PeerDialBudget
94
105
  } from './peer-dial.js';
95
- import { connectionManagerTimeouts, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
106
+ import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
96
107
  import { EnrollmentService } from './enrollment.js';
97
108
  import { HibernationManager, type HibernationCallbacks } from './hibernation-manager.js';
98
109
  import { ControlDatabase, isStrandIdConflict, type RevokedRowRef } from './control-database.js';
99
110
  import type { ControlRetryAbandonment } from './control-retry.js';
100
- import { SeedBootstrapService, type SeedEventCallbacks } from './seed-bootstrap.js';
111
+ import { SeedBootstrapService, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
101
112
  import type { SeedTrustPolicy } from './seed-trust-policy.js';
102
113
  import {
103
114
  StrandSolicitationService,
104
115
  type StrandSolicitationServiceOptions
105
116
  } from './strand-solicitation.js';
106
- import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
107
117
  import {
108
118
  createMembershipConnectionGater,
109
119
  DEFAULT_ENROLLMENT_WINDOW_MS,
@@ -111,16 +121,17 @@ import {
111
121
  type InboundConnectionVerdict
112
122
  } from './membership-connection-gater.js';
113
123
  import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
114
- import { StrandAddrService, collectStrandAddrs, type StrandAddrPeer } from './strand-addr-protocol.js';
124
+ import { StrandAddrService, collectStrandAddrs, type StrandAddrPeer, type StrandAddrOutcome, type StrandAddrCollection } from './strand-addr-protocol.js';
115
125
  import {
116
126
  DelegateAdmissionStore,
117
127
  extractCircuitRelayTargets,
118
128
  dueRelayAnnounces,
119
- pruneStoppedStrandAnnounces,
129
+ prunePeerStrandKeys,
120
130
  peerStrandKey,
121
131
  type CircuitRelayTarget
122
132
  } from './delegate-admission.js';
123
133
  import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
134
+ import { resolveRelayServer, type ResolvedRelayServer } from './relay-server.js';
124
135
  import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
125
136
  import {
126
137
  superviseRelayReservation,
@@ -151,10 +162,10 @@ const timing = debug('sereus:cadre:timing');
151
162
  const TURN_CONSUME_WINDOW_MS = 1000;
152
163
 
153
164
  /**
154
- * How often a running strand re-resolves its siblings' strand-network addresses
155
- * and re-merges them into its own libp2p address book
156
- * ({@link CadreNode.refreshStrandPeerAddrs}). Overridable per node via
157
- * `network.controlCohort.strandAddrRefreshMs`.
165
+ * How often a running strand re-asks each connected sibling that last ANSWERED
166
+ * (even with nothing) for its strand-network addresses, and re-merges them into
167
+ * its own libp2p address book ({@link CadreNode.refreshStrandPeerAddrs}).
168
+ * Overridable per node via `network.controlCohort.strandAddrRefreshMs`.
158
169
  *
159
170
  * Ten minutes sits comfortably inside the peerStore's one-hour address expiry
160
171
  * (`MAX_ADDRESS_AGE`, see `peer-addr-book.ts`) with headroom for several missed
@@ -163,6 +174,17 @@ const TURN_CONSUME_WINDOW_MS = 1000;
163
174
  */
164
175
  export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
165
176
 
177
+ /**
178
+ * How soon {@link CadreNode.refreshStrandPeerAddrs} re-asks a sibling that did NOT
179
+ * answer — unreachable, `unavailable`, or `refused`. A refusal matters most: a
180
+ * phone asking before its `CadrePeer` row has replicated to the sibling is refused
181
+ * and its delegate grant goes unrecorded, and waiting the full
182
+ * {@link STRAND_PEER_ADDR_REFRESH_MS} for the next try is the delay this bounds.
183
+ * Four reconcile ticks, so a sibling that keeps failing costs one timed-out RPC a
184
+ * minute rather than one a tick.
185
+ */
186
+ export const STRAND_PEER_ADDR_RETRY_MS = 60 * 1000;
187
+
166
188
  type EventHandler<T> = (data: T) => void;
167
189
 
168
190
  /**
@@ -173,12 +195,21 @@ function relayStrandAddrPeer(relay: CircuitRelayTarget): StrandAddrPeer {
173
195
  return { peerId: relay.relayPeerId, addrs: [multiaddr(relay.relayAddr)] };
174
196
  }
175
197
 
198
+ /**
199
+ * Whether a sibling actually answered a strand-addr RPC — with addresses or
200
+ * without — as opposed to being unreachable, unavailable, or refusing us. Decides
201
+ * which of the refresh and retry intervals it waits before the next ask.
202
+ */
203
+ function siblingAnswered(outcome: StrandAddrOutcome): boolean {
204
+ return outcome === 'answered' || outcome === 'empty';
205
+ }
206
+
176
207
  /**
177
208
  * `primary` followed by the entries of `extra` it does not already contain, de-duplicated
178
209
  * and order-preserving.
179
210
  *
180
211
  * Used wherever a strand's discovery seed unions two sources of address strings — the
181
- * freshly-RPC'd sibling answers and the cross-party addresses learned at formation. The
212
+ * freshly-RPC'd sibling answers and the strand peer book's last-known addresses. The
182
213
  * caller passes the FRESHER source as `primary`, so a stale entry can only ever be
183
214
  * appended, never promoted ahead of a current one, and each source keeps whatever
184
215
  * signaling-first ordering it arrived with.
@@ -274,6 +305,34 @@ function requireMatchingStrandRow(live: StrandRow, desired: StrandRow, situation
274
305
  return live;
275
306
  }
276
307
 
308
+ /**
309
+ * Await `work` unless `signal` aborts first: `true` once `work` resolves, `false` when the
310
+ * signal aborted before it did (already at the call, or during the wait). A rejection of
311
+ * `work` that lands before the abort propagates. `work` is NOT cancelled — a caller that
312
+ * gets `false` owns its late settlement. The abort listener is detached on both endings, so
313
+ * a long-lived signal does not collect listeners.
314
+ */
315
+ async function resolvesBeforeAbort(work: Promise<unknown>, signal: AbortSignal | undefined): Promise<boolean> {
316
+ if (!signal) {
317
+ await work;
318
+ return true;
319
+ }
320
+ if (signal.aborted) {
321
+ return false;
322
+ }
323
+ let detach = (): void => { /* replaced once the listener is attached */ };
324
+ const abortedFirst = new Promise<false>((resolve) => {
325
+ const onAbort = (): void => resolve(false);
326
+ signal.addEventListener('abort', onAbort, { once: true });
327
+ detach = () => signal.removeEventListener('abort', onAbort);
328
+ });
329
+ try {
330
+ return await Promise.race([work.then(() => true), abortedFirst]);
331
+ } finally {
332
+ detach();
333
+ }
334
+ }
335
+
277
336
  /**
278
337
  * CadreNode is the main entry point for a cadre member.
279
338
  * It manages:
@@ -411,6 +470,30 @@ export class CadreNode implements SAppIdLookup {
411
470
  */
412
471
  private discoveredStrands: Map<string, StrandRow> = new Map();
413
472
 
473
+ /**
474
+ * Whether an unclaimed strand the filter admits is launched as a storage replica
475
+ * ({@link CadreNodeConfig.hostUnclaimedStrands}), resolved once from the config.
476
+ *
477
+ * NOTE: a replica host stores every admitted strand of its party with no quota (Arachnode
478
+ * quotas are unimplemented). Fine at a party's handful of strands; if always-on nodes come
479
+ * to host strands by the hundred, per-strand quotas or a narrower default filter is the lever.
480
+ */
481
+ private readonly hostUnclaimedStrands: boolean;
482
+
483
+ /**
484
+ * The sApp id each storage replica read from its own `Strand.Header` — {@link getSAppId}'s
485
+ * answer for a strand no local config claims, so an `sAppId` strand filter can reject a
486
+ * replica of some other app instead of admitting it provisionally forever.
487
+ *
488
+ * Cleared by {@link cleanup} only, deliberately NOT by {@link detachStrand}: the watcher's
489
+ * filter rejection stops a replica through that very method, and forgetting the id there
490
+ * would have the next poll find it unknown again, re-admit the strand provisionally and
491
+ * relaunch the replica — once every other poll, forever. A strand id's sApp never changes,
492
+ * and the map is bounded by the strands this party has published, like
493
+ * {@link discoveredStrands}.
494
+ */
495
+ private replicaSAppIds: Map<string, string> = new Map();
496
+
414
497
  /**
415
498
  * Most-recently pushed invite addresses (see {@link setInviteAddresses}).
416
499
  * When non-null these take priority over `libp2pNode.getMultiaddrs()` when
@@ -491,10 +574,22 @@ export class CadreNode implements SAppIdLookup {
491
574
  * `denyInboundRelayReservation` policy); authorized members and announced
492
575
  * delegates are admitted before it and never counted. Cap from
493
576
  * `network.unauthorizedRelayReservationCap` (default
494
- * `MAX_UNAUTHORIZED_RELAY_RESERVATIONS`).
577
+ * `MAX_UNAUTHORIZED_RELAY_RESERVATIONS`); entries expire on the relay
578
+ * server's own resolved `reservationTtl` ({@link relayServer}).
495
579
  */
496
580
  private readonly unauthorizedRelayReservations: UnauthorizedReservationBudget;
497
581
 
582
+ /**
583
+ * Does this node's CONTROL libp2p run the circuit-relay server, and with which
584
+ * init? Resolved once from `network` and `profile` by the same function every
585
+ * strand node's build uses (`relay-server.ts`). Read by
586
+ * {@link buildControlNodeOptions} (which configures the server from it),
587
+ * {@link admitInboundControlConnection} (whose deny/admit-for-relay branch must
588
+ * agree with whether a reservation is even servable here), and the
589
+ * unauthorized budget above (whose TTL is the server's).
590
+ */
591
+ private readonly relayServer: ResolvedRelayServer;
592
+
498
593
  /**
499
594
  * When this node last ANNOUNCED a delegate, keyed `<targetPeerId>\n<strandId>`
500
595
  * (the peer announced TO, not the delegate). Throttles
@@ -506,48 +601,50 @@ export class CadreNode implements SAppIdLookup {
506
601
  private readonly delegateAnnounceAt = new Map<string, number>();
507
602
 
508
603
  /**
509
- * When each running strand last had its siblings' strand addresses re-resolved
510
- * into its own address book, keyed by strandId. Throttles
511
- * {@link refreshStrandPeerAddrs} to once per {@link STRAND_PEER_ADDR_REFRESH_MS}
512
- * per strand so the 15 s reconcile tick never becomes per-tick RPC chatter.
513
- * Entries for strands that are no longer running are pruned on each pass, so a
514
- * hibernated-then-resumed strand refreshes immediately rather than inheriting a
515
- * stale stamp.
516
- */
517
- private readonly strandPeerAddrRefreshAt = new Map<string, number>();
518
-
519
- /**
520
- * CROSS-PARTY strand-network addresses learned at formation, keyed by strandId — the
521
- * responder's live strand addrs, carried back on the formation result
522
- * (`FormationResultMessage.strandAddrs`) and recorded by {@link formStrand}.
604
+ * When each connected sibling is next due a strand-addr RPC for each running
605
+ * strand, keyed `peerStrandKey(siblingControlPeerId, strandId)` → epoch ms; a
606
+ * missing key is due. Bounds {@link refreshStrandPeerAddrs}'s fan-out per
607
+ * (sibling, strand) rather than per strand, so a sibling that connects after a
608
+ * pass is asked on the next 15 s tick instead of waiting out a stamp another
609
+ * sibling's answer set. An answer (even an empty one) makes the sibling due again
610
+ * in {@link STRAND_PEER_ADDR_REFRESH_MS}; no answer, in
611
+ * {@link STRAND_PEER_ADDR_RETRY_MS}. Keys whose strand stopped running or whose
612
+ * sibling is no longer connected are pruned every pass, so a resumed strand and a
613
+ * reconnected sibling are both asked at once.
614
+ */
615
+ private readonly strandAddrAskDueAt = new Map<string, number>();
616
+
617
+ /**
618
+ * Node-local strand peer book (see `strand-peer-book.ts`): per strand, the strand
619
+ * peers this node has met and their last-known strand-network addresses. Three
620
+ * writers: {@link recordFormationStrandPeers} files the responder's live strand
621
+ * addrs a formation result carried back (`FormationResultMessage.strandAddrs`),
622
+ * {@link observeStrandPeer} files every strand peer a running strand node
623
+ * identifies, and the per-strand book swap (`strand-peer-book-swap.ts`, handed the
624
+ * store as `StartStrandConfig.strandPeerBook`) files the node's own signed entry
625
+ * plus every signed entry a strand peer sends it — the only writer whose entries
626
+ * carry a signature, and the only one that can refresh a peer's addresses without
627
+ * meeting that peer. Read by {@link resolveCohortSeed} (launch + hibernation resume) and
628
+ * re-merged by every {@link refreshStrandPeerAddrs} pass, which is what keeps the
629
+ * entries alive past the peerStore's one-hour address expiry.
523
630
  *
524
631
  * This is the ONLY cross-party discovery input there is. The strand-addr RPC that
525
632
  * resolves a strand's addresses is membership-gated and answers own-party siblings
526
- * only, so without this map a joiner's cohort seed for a two-party strand is empty and
527
- * the mesh never forms.
528
- *
529
- * Read by {@link resolveCohortSeed} (launch + hibernation resume) and re-merged by
530
- * every {@link refreshStrandPeerAddrs} pass — the re-merge is what keeps the entries
531
- * alive past the peerStore's one-hour address expiry, since nothing can re-resolve
532
- * them.
533
- *
534
- * IN-MEMORY and ONE-SHOT, deliberately (see `docs/strands.md`): the addresses die with
535
- * the process, and a restarted initiator with no sibling running the strand is back to
536
- * an empty seed until the durability work lands
537
- * (`backlog/feat-cross-party-strand-addr-durability`). They are also never refreshed,
538
- * so a responder whose relay reservation rotates before the initiator dials leaves a
539
- * dead entry behind; recovery today is a fresh invitation.
633
+ * only, so without the book a joiner's cohort seed for a two-party strand is empty
634
+ * and the mesh never forms — and, with an in-memory book, never RE-forms after a
635
+ * restart (gotchoices/sereus#18). A STORE rather than a map so an embedder can make
636
+ * it outlive the process; constructed (or adopted from `config.strandPeers.store`)
637
+ * by {@link initializeStrandPeerBookStore} during {@link start}, deliberately NOT
638
+ * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same node
639
+ * instance — same lifecycle as {@link bootstrapPeerStore}.
540
640
  *
541
- * NOTE: entries are kept for the node's lifetime, including across a
542
- * {@link stopStrand} — a stopped strand may be claimed again with {@link addStrand}
543
- * (the watcher itself will not re-offer it: the stop suppresses the id for the rest of
544
- * the session) and must still get its seed. Each strand's list is capped at
545
- * `MAX_STRAND_ADDRS`, but the
546
- * number of KEYS is bounded only by the strands this node has ever formed as an
547
- * initiator (not by time); if a node ever forms strands at scale, evict on
548
- * `unpublishStrand` or cap the map.
641
+ * Entries survive a {@link stopStrand} (a stopped strand may be claimed again with
642
+ * {@link addStrand}, and is rediscovered on restart) and are forgotten by
643
+ * {@link unpublishStrand}, {@link forgetJoinedStrand} and self-revocation; the store
644
+ * ages the rest out after 14 days and caps each strand at 16 peers, so the book is
645
+ * bounded by time and by the strands this node runs rather than by its history.
549
646
  */
550
- private readonly crossPartyStrandAddrs = new Map<string, string[]>();
647
+ private strandPeerBookStore: StrandPeerBookStore | null = null;
551
648
 
552
649
  /**
553
650
  * PENDING strand membership invitations learned at formation, keyed by strandId — the
@@ -556,10 +653,9 @@ export class CadreNode implements SAppIdLookup {
556
653
  * to redeem (`consumeInvite` seats the `Strand.Member` row under this party's own key
557
654
  * — the `strand-node-binds-member-peer` half of the party-identity chain).
558
655
  *
559
- * IN-MEMORY, deliberately, like {@link crossPartyStrandAddrs}: a restarted joiner
560
- * re-forms from scratch anyway (durability is
561
- * `backlog/feat-cross-party-strand-addr-durability`'s problem), and re-forming issues
562
- * a fresh invitation. A re-formation against the same strand REPLACES the entry — the
656
+ * IN-MEMORY, deliberately: an invitation is single-use and short-lived, a restarted
657
+ * joiner that never redeemed it re-forms from scratch, and re-forming issues a fresh
658
+ * one. A re-formation against the same strand REPLACES the entry — the
563
659
  * fresh invitation supersedes one that may have expired. The party key the invitation
564
660
  * admits, by contrast, IS persisted (`StrandPartyKey`, seated by
565
661
  * {@link adoptFormationMembershipInvite} before the entry lands here), so a lost
@@ -575,8 +671,9 @@ export class CadreNode implements SAppIdLookup {
575
671
  * just waits; once launched, staging notifies the manager, which re-arms a finished loop.
576
672
  *
577
673
  * NOTE: entries live for the node's lifetime (one small pair per formed closed
578
- * strand), the same unbounded-keys tripwire {@link crossPartyStrandAddrs} documents —
579
- * if a node ever forms strands at scale, evict alongside that map.
674
+ * strand); the number of keys is bounded only by the strands this node has ever
675
+ * formed, not by time — if a node ever forms strands at scale, evict on
676
+ * `unpublishStrand` / `forgetJoinedStrand` as the strand peer book does.
580
677
  */
581
678
  private readonly pendingMembershipInvites = new Map<string, StrandMembershipInvite>();
582
679
 
@@ -651,6 +748,25 @@ export class CadreNode implements SAppIdLookup {
651
748
  */
652
749
  private enrolledMachineStore: EnrolledMachineStore | null = null;
653
750
 
751
+ /**
752
+ * Node-local record of the strands this node joined from ANOTHER party and has not yet
753
+ * published party-wide (see `joined-strand-store.ts`). Written by {@link formStrand}
754
+ * and by an {@link addStrand} of a foreign row; drained once the party-wide
755
+ * `JoinedStrand` row is visible. Adopted from `config.joinedStrands.store`, else built over
756
+ * `config.keyStore`, else in-memory, by {@link initializeJoinedStrandStore}. Like
757
+ * {@link bootstrapPeerStore} it is deliberately NOT cleared by {@link cleanup}.
758
+ */
759
+ private joinedStrandStore: JoinedStrandStore | null = null;
760
+
761
+ /**
762
+ * This session's joined strands: {@link joinedStrandStore} and the party-wide
763
+ * `JoinedStrand` table, unioned with the control rows each watcher poll, published and
764
+ * removed by the reconcile pass. Per SESSION, unlike the store — rebuilt at every
765
+ * {@link start}, which is what ends a revoked join's "keep offering until this session
766
+ * ends" (see `JoinedStrandSession.forgetAfterThisSession`).
767
+ */
768
+ private joinedStrands: JoinedStrandSession | null = null;
769
+
654
770
  /**
655
771
  * The enrolled-machine count this node's CONTROL libp2p node was (or will be)
656
772
  * built with — read out of {@link enrolledMachineStore} during {@link start},
@@ -798,10 +914,13 @@ export class CadreNode implements SAppIdLookup {
798
914
 
799
915
  constructor(config: CadreNodeConfig) {
800
916
  this.config = config;
917
+ this.hostUnclaimedStrands = config.hostUnclaimedStrands ?? config.profile === 'storage';
801
918
  this.strandManager = new StrandInstanceManager();
802
919
  this.enrollmentService = new EnrollmentService();
920
+ this.relayServer = resolveRelayServer(config.network, config.profile);
803
921
  this.unauthorizedRelayReservations = new UnauthorizedReservationBudget(
804
- config.network?.unauthorizedRelayReservationCap
922
+ config.network?.unauthorizedRelayReservationCap,
923
+ this.relayServer.init.reservations.reservationTtl
805
924
  );
806
925
 
807
926
  // Create hibernation manager with callbacks
@@ -809,7 +928,8 @@ export class CadreNode implements SAppIdLookup {
809
928
  onIdle: async (strandId) => this.handleStrandIdle(strandId),
810
929
  onHibernate: async (strandId) => this.handleStrandHibernate(strandId),
811
930
  onWake: async (strandId) => this.handleStrandWake(strandId),
812
- onCheckIn: async (strandId) => this.handleStrandCheckIn(strandId)
931
+ onCheckIn: async (strandId) => this.handleStrandCheckIn(strandId),
932
+ isQuiescing: (strandId) => this.strandManager.isQuiescing(strandId)
813
933
  };
814
934
  this.hibernationManager = new HibernationManager(
815
935
  config.hibernation ?? { enabled: false },
@@ -820,10 +940,11 @@ export class CadreNode implements SAppIdLookup {
820
940
  }
821
941
 
822
942
  /**
823
- * SAppIdLookup implementation - get sAppId for a strand
943
+ * SAppIdLookup implementation - get sAppId for a strand: the claiming config's, else
944
+ * the one a storage replica read from the strand's own `Strand.Header`.
824
945
  */
825
946
  getSAppId(strandId: string): string | undefined {
826
- return this.sAppConfigs.get(strandId)?.id;
947
+ return this.sAppConfigs.get(strandId)?.id ?? this.replicaSAppIds.get(strandId);
827
948
  }
828
949
 
829
950
  /**
@@ -951,6 +1072,7 @@ export class CadreNode implements SAppIdLookup {
951
1072
  }
952
1073
 
953
1074
  log('Starting CadreNode for party: %s', this.config.controlNetwork.partyId);
1075
+ this.logRelayServerSettings();
954
1076
 
955
1077
  try {
956
1078
  const tTotal = performance.now();
@@ -987,6 +1109,14 @@ export class CadreNode implements SAppIdLookup {
987
1109
  // loaded before the first reconcile pass could consult them.
988
1110
  this.initializeBootstrapPeerStore();
989
1111
 
1112
+ // The remembered cross-party joins, which the strand watcher built below polls
1113
+ // beside the control rows. Before network bring-up, so a mis-scoped injected store
1114
+ // fails closed like the two above.
1115
+ this.initializeJoinedStrandStore();
1116
+
1117
+ // The strand peer book those joins are dialed from. Same placement, same reason.
1118
+ this.initializeStrandPeerBookStore();
1119
+
990
1120
  // Read the party's enrolled-machine count out of its node-local record and
991
1121
  // capture it for buildControlNodeOptions below. This MUST precede
992
1122
  // createControlNode: Optimystic freezes the cluster policy when the node is
@@ -1098,7 +1228,7 @@ export class CadreNode implements SAppIdLookup {
1098
1228
  getStrand: (strandId) => this.strandManager.getInstance(strandId),
1099
1229
  wake: (strandId) => this.wakeStrand(strandId),
1100
1230
  });
1101
- this.strandWakeService.initialize(this.controlNode);
1231
+ await this.strandWakeService.initialize(this.controlNode);
1102
1232
 
1103
1233
  // Register the control-network strand-address responder: a same-cadre peer
1104
1234
  // resolving a strand's bootstrap seed asks us for our live strand-network
@@ -1114,7 +1244,7 @@ export class CadreNode implements SAppIdLookup {
1114
1244
  onDelegateAnnounce: (announcer, strandId, delegate) =>
1115
1245
  this.grantDelegateAdmission(announcer, strandId, delegate),
1116
1246
  });
1117
- this.strandAddrService.initialize(this.controlNode);
1247
+ await this.strandAddrService.initialize(this.controlNode);
1118
1248
 
1119
1249
  // Server-side push-wake fan-out: only when push is configured.
1120
1250
  if (this.config.push) {
@@ -1387,6 +1517,54 @@ export class CadreNode implements SAppIdLookup {
1387
1517
  log('control repair yardstick will be declared from %o enrolled machine(s)', this.declaredEnrolledMachines);
1388
1518
  }
1389
1519
 
1520
+ /**
1521
+ * Construct (or adopt) the node-local joined-strand store (see
1522
+ * {@link joinedStrandStore}) and this session's {@link joinedStrands} over it.
1523
+ *
1524
+ * The store instance is kept across stop()→start(); the view is rebuilt. A store
1525
+ * scoped to a different party is a configuration error, fail closed: its joins
1526
+ * would be offered to this party's app as if this party had made them.
1527
+ */
1528
+ private initializeJoinedStrandStore(): void {
1529
+ const partyId = this.config.controlNetwork.partyId;
1530
+ const { keyStore } = this.config;
1531
+ const store = this.joinedStrandStore
1532
+ ?? this.config.joinedStrands?.store
1533
+ ?? (keyStore ? new KeyStoreJoinedStrandStore(keyStore, partyId) : new MemoryJoinedStrandStore(partyId));
1534
+ if (store.partyId !== partyId) {
1535
+ throw new Error(
1536
+ `CadreNodeConfig: joinedStrands.store is scoped to party ${store.partyId}, ` +
1537
+ `but this node serves party ${partyId} — refusing to offer another party's joined strands`
1538
+ );
1539
+ }
1540
+ this.joinedStrandStore = store;
1541
+ this.joinedStrands = new JoinedStrandSession(store, this.createPartyJoinedStrandLedger());
1542
+ }
1543
+
1544
+ /**
1545
+ * Construct (or adopt) the node-local strand peer book (see
1546
+ * {@link strandPeerBookStore}). Synchronous for the reason
1547
+ * {@link initializeBootstrapPeerStore} is: an injected store has already loaded its
1548
+ * persisted entries by the time it is handed in, and the in-memory fallback has
1549
+ * nothing to load. The instance is kept across stop()→start(). A store scoped to a
1550
+ * different party is a configuration error, fail closed: another party's strand
1551
+ * peers must never enter this node's dial loop.
1552
+ */
1553
+ private initializeStrandPeerBookStore(): void {
1554
+ const partyId = this.config.controlNetwork.partyId;
1555
+ if (this.strandPeerBookStore) {
1556
+ return;
1557
+ }
1558
+ const store = this.config.strandPeers?.store ?? new MemoryStrandPeerBookStore(partyId);
1559
+ if (store.partyId !== partyId) {
1560
+ throw new Error(
1561
+ `CadreNodeConfig: strandPeers.store is scoped to party ${store.partyId}, ` +
1562
+ `but this node serves party ${partyId} — refusing to mix strand peer books`
1563
+ );
1564
+ }
1565
+ this.strandPeerBookStore = store;
1566
+ }
1567
+
1390
1568
  /**
1391
1569
  * The node-local enrolled-machine store (null before {@link start}) — what this
1392
1570
  * node last knew about its party's size. Exposed for diagnostics and for a host
@@ -1406,6 +1584,16 @@ export class CadreNode implements SAppIdLookup {
1406
1584
  return this.bootstrapPeerStore;
1407
1585
  }
1408
1586
 
1587
+ /**
1588
+ * The node-local strand peer book (null before {@link start}) — per strand, the
1589
+ * strand peers this node dials first on launch, resume and refresh. Exposed for
1590
+ * diagnostics and for a host that wants to show "whom would this strand re-find
1591
+ * after a restart?".
1592
+ */
1593
+ getStrandPeerBookStore(): StrandPeerBookStore | null {
1594
+ return this.strandPeerBookStore;
1595
+ }
1596
+
1409
1597
  /**
1410
1598
  * The node-local trusted-owner anchor (null before {@link start}). This is
1411
1599
  * the set the authorized-membership predicate and seed-trust anchor consult —
@@ -1483,10 +1671,10 @@ export class CadreNode implements SAppIdLookup {
1483
1671
  * needs no work of its own: `createLibp2pNode` registers the block-transfer
1484
1672
  * handler on every node it builds, control node included, and this node's
1485
1673
  * per-stream gate (`authorizeInboundControlStream`) covers the inbound
1486
- * direction. A denied peer is retried on its next `connection:open`, and —
1487
- * because the production join order is connect-then-authorize — on every
1488
- * committed membership change via {@link refreshAuthorizedControlPeers}'s
1489
- * `scheduleConnectedPeers()` call.
1674
+ * direction. A denied peer is retried on its next `peer:identify` (a
1675
+ * reconnect re-runs identify), and — because the production join order is
1676
+ * connect-then-authorize — on every committed membership change via
1677
+ * {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
1490
1678
  *
1491
1679
  * No-ops (logged) when the embedder configured no control storage or the
1492
1680
  * node exposes no key network — the backfill would have nothing to read or
@@ -1576,7 +1764,7 @@ export class CadreNode implements SAppIdLookup {
1576
1764
  const scope = controlStorageScope(this.config.controlNetwork.partyId);
1577
1765
  // The second seam that hands a scope key to an embedder's provider; the first
1578
1766
  // (`StrandInstanceManager.startStrand`) asserts the stricter strand rule. Holds by
1579
- // construction today — base64url is inside the charset — so this guards a future
1767
+ // construction today — lowercase hex is inside the charset — so this guards a future
1580
1768
  // edit to `controlStorageScope`, not a reachable input.
1581
1769
  assertScopeKeyCharset(scope);
1582
1770
  const resolved = typeof provider === 'function' ? provider(scope) : provider;
@@ -1648,8 +1836,6 @@ export class CadreNode implements SAppIdLookup {
1648
1836
  // being silently dropped at bring-up. See `relay-addrs.ts`.
1649
1837
  const transportOptions = resolveTransportOptions(network, listenAddrs);
1650
1838
 
1651
- const enableRelay = this.relayServerEnabled();
1652
-
1653
1839
  // The control node's own storage, resolved once per runtime (see
1654
1840
  // {@link resolveControlStorage}) rather than per call.
1655
1841
  const controlStorageProvider = this.resolveControlStorage();
@@ -1660,7 +1846,10 @@ export class CadreNode implements SAppIdLookup {
1660
1846
  networkName: this.controlNetworkName(),
1661
1847
  storage: controlStorageProvider,
1662
1848
  fretProfile: profile === 'storage' ? 'core' : 'edge',
1663
- relay: enableRelay,
1849
+ relay: this.relayServer.enabled,
1850
+ // Merged party-run defaults: no per-connection data/duration cap, a store sized for
1851
+ // one party's NAT'd machines. Strand nodes get the same init (`relay-server.ts`).
1852
+ ...(this.relayServer.enabled && { relayServerInit: this.relayServer.init }),
1664
1853
  // Fixed, and deliberately above any party's node count: every member reads the
1665
1854
  // whole control database, so a cohort that excludes a member leaves it dependent
1666
1855
  // on read repair — which cannot converge at a two-member cohort. Not a knob;
@@ -1676,12 +1865,14 @@ export class CadreNode implements SAppIdLookup {
1676
1865
  // itself, so the unknown case is provably byte-for-byte the old behaviour.
1677
1866
  // The yardstick moves ALONE: `assumedClusterSize` stays pinned at 2, because a
1678
1867
  // party of phones cannot promise three quarters of its machines are awake.
1679
- // With neither declared this is still the frozen constant by identity, which is the
1680
- // production path. A degenerate deadline is Optimystic's to refuse, in
1681
- // createControlNode below.
1868
+ // The read deadline is the host's own, else two link round trips at the declared
1869
+ // `linkRoundTripMs`, else nothing — the frozen constant already carries the same
1870
+ // derivation at the default declaration (`link-budget.ts`). With neither declared this
1871
+ // is still the frozen constant by identity, which is the production path. A degenerate
1872
+ // deadline is Optimystic's to refuse, in createControlNode below.
1682
1873
  clusterPolicy: controlClusterPolicy({
1683
1874
  enrolledMachines: this.declaredEnrolledMachines,
1684
- cohortQueryTimeoutMs: network?.cohortQueryTimeoutMs
1875
+ cohortQueryTimeoutMs: declaredCohortReadDeadlineMs(network)
1685
1876
  }),
1686
1877
  arachnode: { enableRingZulu: profile === 'storage' },
1687
1878
  ...(identityKey && { privateKey: identityKey }),
@@ -1692,10 +1883,12 @@ export class CadreNode implements SAppIdLookup {
1692
1883
  // whose event loop is saturated by pure-JS Noise crypto. A configured value
1693
1884
  // replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
1694
1885
  connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
1695
- // libp2p's own dial and inbound-upgrade limits, from the same declared link as every
1696
- // cadre dial budget. Left to db-p2p, both stay at 10 s, which cannot open a relayed
1697
- // connection above a 2.5 s round trip — and the listener's side of that fails silently.
1698
- connectionManager: connectionManagerTimeouts(network?.linkRoundTripMs),
1886
+ // The declared link every cadre budget is derived from, stated to Optimystic too so it
1887
+ // derives its own deadlines from the same number: its request dials and responses, its
1888
+ // block pushes, and libp2p's `dialTimeout` and `inboundUpgradeTimeout`. Always stated,
1889
+ // default included: undeclared, Optimystic keeps LAN deadlines that cannot open a
1890
+ // relayed connection at the link sereus supports (`link-budget.ts`).
1891
+ linkRoundTripMs: resolveLinkRoundTripMs(network?.linkRoundTripMs),
1699
1892
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1700
1893
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1701
1894
  // then. Spread NEXT to `transports` because the two answer the same question.
@@ -1720,7 +1913,9 @@ export class CadreNode implements SAppIdLookup {
1720
1913
  admitRelayReservation: (remotePeerId) => this.admitControlRelayReservation(remotePeerId),
1721
1914
  bringUpInFlight: () => this.controlBringUpInFlight
1722
1915
  },
1723
- network?.connectionGater
1916
+ network?.connectionGater,
1917
+ ADMISSION_DECISION_TIMEOUT_MS,
1918
+ relayAdmissionReserveDeadlineMs(network?.linkRoundTripMs)
1724
1919
  ),
1725
1920
  // Fail-closed per-stream authorization for the four Optimystic control-DB
1726
1921
  // protocols — the members-only layer the connection gater's stranger
@@ -1734,15 +1929,18 @@ export class CadreNode implements SAppIdLookup {
1734
1929
  }
1735
1930
 
1736
1931
  /**
1737
- * Does this node's CONTROL libp2p run the circuit-relay server? An explicit
1738
- * `network.enableRelay` wins; otherwise storage-profile nodes default to on
1739
- * (better connectivity/uptime). Shared by {@link buildControlNodeOptions}
1740
- * (which configures the server from it) and
1741
- * {@link admitInboundControlConnection} (whose deny/admit-for-relay branch
1742
- * must agree with whether a reservation is even servable here).
1932
+ * One debug line naming what this machine's relay server forwards and holds —
1933
+ * the same fields the dedicated relay container prints at boot
1934
+ * (`ops/docker/libp2p-infra/src/main.ts`). Once per start, not per strand:
1935
+ * strand nodes resolve the same settings from the same config.
1743
1936
  */
1744
- private relayServerEnabled(): boolean {
1745
- return this.config.network?.enableRelay ?? (this.config.profile === 'storage');
1937
+ private logRelayServerSettings(): void {
1938
+ if (!this.relayServer.enabled) {
1939
+ return;
1940
+ }
1941
+ const { applyDefaultLimit, maxReservations, reservationTtl } = this.relayServer.init.reservations;
1942
+ log('Relay server on: applyDefaultLimit=%s maxReservations=%d reservationTtl=%dms unauthorizedCap=%d',
1943
+ applyDefaultLimit, maxReservations, reservationTtl, this.unauthorizedRelayReservations.cap);
1746
1944
  }
1747
1945
 
1748
1946
  /**
@@ -1792,7 +1990,7 @@ export class CadreNode implements SAppIdLookup {
1792
1990
  * accepted it, exactly like the unreplicated-membership-row case below.
1793
1991
  *
1794
1992
  * When every check falls through, the verdict depends on whether this node
1795
- * runs the circuit-relay server ({@link relayServerEnabled}): without one,
1993
+ * runs the circuit-relay server ({@link relayServer}): without one,
1796
1994
  * `'deny'`; with one, `'admit-for-relay'` — a circuit-relay reservation is
1797
1995
  * established by the reserving peer DIALING the relay, so a connection deny
1798
1996
  * here kills the reservation, and that deny is NOT self-healing: an outbound
@@ -1841,7 +2039,7 @@ export class CadreNode implements SAppIdLookup {
1841
2039
  log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
1842
2040
  return 'admit';
1843
2041
  }
1844
- if (this.relayServerEnabled()) {
2042
+ if (this.relayServer.enabled) {
1845
2043
  log('admitInboundControlConnection: admitting %s FOR RELAY ONLY — not an authorized member and no enrollment path open; the gater drops the connection unless a reservation is admitted', remotePeerId);
1846
2044
  return 'admit-for-relay';
1847
2045
  }
@@ -2055,7 +2253,9 @@ export class CadreNode implements SAppIdLookup {
2055
2253
  // the count of machines serving the network — the quantity
2056
2254
  // `resolveRepairYardstick` asks for. It is NOT that quantity for a strand, which
2057
2255
  // launches only on machines whose embedder registered its sApp config (see
2058
- // {@link addStrand}), so this number must never be routed to a strand node:
2256
+ // {@link addStrand}) plus storage-profile machines hosting it as a storage replica
2257
+ // ({@link CadreNodeConfig.hostUnclaimedStrands}) whose filter admits it — phones
2258
+ // serve only what their app claims — so this number must never be routed to a strand node:
2059
2259
  // over-declaring pins Optimystic's repair corroboration floor at two peers and a
2060
2260
  // strand that can field only one can then never repair. `launchStrand` therefore
2061
2261
  // declares nothing; see the NOTE there.
@@ -2106,7 +2306,7 @@ export class CadreNode implements SAppIdLookup {
2106
2306
  // ever holds many such connections, or refreshes get frequent, skip
2107
2307
  // re-arming peers whose last pass was denied under the SAME membership
2108
2308
  // snapshot instead of re-arming unconditionally.
2109
- this.controlBackfill?.scheduleConnectedPeers();
2309
+ void this.controlBackfill?.scheduleConnectedPeers();
2110
2310
  } catch (error) {
2111
2311
  log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
2112
2312
  }
@@ -2142,6 +2342,11 @@ export class CadreNode implements SAppIdLookup {
2142
2342
  return this.bootstrapPeerIds;
2143
2343
  }
2144
2344
 
2345
+ /**
2346
+ * What the strand watcher polls: this party's control rows plus a row per cross-party
2347
+ * join (see {@link joinedStrands}), so a joined strand is offered and relaunched on
2348
+ * the same path as the party's own.
2349
+ */
2145
2350
  private createStrandQueryable(): StrandQueryable {
2146
2351
  return {
2147
2352
  queryStrands: async (): Promise<StrandRow[]> => {
@@ -2150,11 +2355,78 @@ export class CadreNode implements SAppIdLookup {
2150
2355
  return [];
2151
2356
  }
2152
2357
  log('Querying strands from control database');
2153
- return await this.controlDatabase.queryStrands();
2358
+ const control = await this.controlDatabase.queryStrands();
2359
+ return this.joinedStrands ? await this.joinedStrands.withControlRows(control) : control;
2154
2360
  }
2155
2361
  };
2156
2362
  }
2157
2363
 
2364
+ /**
2365
+ * The party-wide `JoinedStrand` table as {@link joinedStrands} reads and writes it.
2366
+ * Reads {@link controlDatabase} at call time, so start-up order does not matter.
2367
+ */
2368
+ private createPartyJoinedStrandLedger(): PartyJoinedStrandLedger {
2369
+ const database = (): ControlDatabase => {
2370
+ if (!this.controlDatabase) {
2371
+ throw new Error('CadreNode must be started before using the party-wide joined-strand table');
2372
+ }
2373
+ return this.controlDatabase;
2374
+ };
2375
+ return {
2376
+ // NOTE: one more control read per watcher poll (5 s), and for a party that never
2377
+ // joined anything it reads a never-written block, which consults the cohort. If poll
2378
+ // cost shows up in a device profile, cache the list and re-read it on the reconcile
2379
+ // cadence.
2380
+ list: () => database().queryJoinedStrands(),
2381
+ names: async (strandId) => (await database().queryStrand(strandId)) !== null
2382
+ || (await database().queryJoinedStrand(strandId)) !== null,
2383
+ canSign: async () => (await this.enrolledOwnerSigningKey()) !== null,
2384
+ publish: async (record) => {
2385
+ const signingKey = await this.enrolledOwnerSigningKey();
2386
+ if (!signingKey) {
2387
+ throw new Error(`Cannot publish joined strand ${record.Id}: this machine is not an enrolled owner`);
2388
+ }
2389
+ try {
2390
+ await database().insertJoinedStrand(record, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
2391
+ } catch (error) {
2392
+ // Another machine of the party published this join first; its row wins.
2393
+ if (!isStrandIdConflict(error, 'JoinedStrand')) {
2394
+ throw error;
2395
+ }
2396
+ }
2397
+ },
2398
+ remove: async (strandId) => {
2399
+ const signingKey = await this.enrolledOwnerSigningKey();
2400
+ if (signingKey) {
2401
+ return database().deleteJoinedStrand(strandId, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
2402
+ }
2403
+ if (await database().queryJoinedStrand(strandId)) {
2404
+ throw new Error(
2405
+ `Cannot leave strand ${strandId} for the whole party: the join is recorded party-wide, and ` +
2406
+ `removing it takes an owner machine, which this one is not. stopStrand('${strandId}') stops ` +
2407
+ 'it on this machine only.'
2408
+ );
2409
+ }
2410
+ return false;
2411
+ }
2412
+ };
2413
+ }
2414
+
2415
+ /**
2416
+ * This machine's owner signing key when it can make owner-gated control writes: its seed
2417
+ * bootstrap holds an owner key (`SeedBootstrapService.canAuthorize`) and the party's
2418
+ * `OwnerKey` table enrolls it. A phone that ran self-genesis after another machine
2419
+ * founded the party passes the first test and not the second (`runOwnerGenesis` in the
2420
+ * reference apps), and a write it signed would only be refused.
2421
+ */
2422
+ private async enrolledOwnerSigningKey(): Promise<{ privateKeyB64: string; publicKeyB64: string } | null> {
2423
+ const signingKey = this.seedBootstrapService?.canAuthorize() ? this.getSelfSigningKey() : null;
2424
+ if (!signingKey || !this.controlDatabase) {
2425
+ return null;
2426
+ }
2427
+ return (await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64) ? signingKey : null;
2428
+ }
2429
+
2158
2430
  /**
2159
2431
  * Schedule the node's initial self-record publish + ongoing refresh shortly
2160
2432
  * after start (non-blocking). {@link registerSelf} is idempotent and safely
@@ -2818,14 +3090,18 @@ export class CadreNode implements SAppIdLookup {
2818
3090
  // Ride this pass's cadence to refresh the per-stream gate's materialized
2819
3091
  // authorized set — membership changes that ARRIVED BY REPLICATION (rather
2820
3092
  // than a local write) are picked up here, bounding the snapshot's staleness
2821
- // to the reconcile interval.
3093
+ // to the time between pass starts: the reconcile interval, or the previous
3094
+ // pass's length when that is longer (passes are single-flight, and one
3095
+ // unreachable sibling's dial alone can take `controlDialBudget().totalMs`).
2822
3096
  // NOTE: this refresh and the sibling enumeration below each run their own
2823
3097
  // CadrePeer query (two reads per pass), plus a third from
2824
- // `refreshStrandPeerAddrs` on the passes where a strand is due AND this node
2825
- // holds a control connection (one read for the whole pass, not one per
2826
- // strand). Each also reads Revocation first. Before the Revocation ledger
2827
- // marker exists (filed below, once connected) that block is missing, so every
2828
- // one of those reads consults the cohort about it; once the marker exists every
3098
+ // `refreshStrandPeerAddrs` on every pass where a strand is running AND this
3099
+ // node holds a control connection (one read for the whole pass, not one per
3100
+ // strand; it runs even when no sibling is due, because pruning departed
3101
+ // siblings needs the current target set). Each also reads Revocation first.
3102
+ // Before the Revocation ledger marker exists (filed below, once connected)
3103
+ // that block is missing, so every one of those reads consults the cohort
3104
+ // about it; once the marker exists every
2829
3105
  // block they touch is held and none of them does (both states pinned in
2830
3106
  // control-founding-consult-budget.spec.ts). If those reads ever get costly,
2831
3107
  // share one row-set across all three.
@@ -2841,8 +3117,8 @@ export class CadreNode implements SAppIdLookup {
2841
3117
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2842
3118
  return { dialed: [] };
2843
3119
  }
2844
- // Then re-warm each running strand's own address book from its siblings, on
2845
- // its own (much longer) throttle. Same reasoning as warmSiblingAddrBook
3120
+ // Then re-warm each running strand's own address book from its peer book and
3121
+ // from whichever siblings are due an ask. Same reasoning as warmSiblingAddrBook
2846
3122
  // below, one layer down: replication runs on the STRAND network, and every
2847
3123
  // layer under cadre-core dials strand peers by bare peer id.
2848
3124
  await this.refreshStrandPeerAddrs();
@@ -2912,6 +3188,19 @@ export class CadreNode implements SAppIdLookup {
2912
3188
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2913
3189
  return { dialed: [] };
2914
3190
  }
3191
+
3192
+ // Publish this machine's unpublished cross-party joins party-wide and remove the rows a
3193
+ // self-revocation queued (JoinedStrandSession.syncWithParty). Connected-only for the
3194
+ // reap's reason above; owner-only by the session's own signer check. Nothing is urgent:
3195
+ // a join reaches the party within one pass of this machine being connected.
3196
+ try {
3197
+ await this.joinedStrands?.syncWithParty();
3198
+ } catch (error) {
3199
+ log('reconcileControlCohort: joined-strand sync failed (retrying next pass): %o', error);
3200
+ }
3201
+ if (!this._running || !this.controlNode || !this.controlDatabase) {
3202
+ return { dialed: [] };
3203
+ }
2915
3204
  }
2916
3205
 
2917
3206
  // 1. Enumerate known siblings. This membership read is itself a pull-on-read
@@ -3137,6 +3426,34 @@ export class CadreNode implements SAppIdLookup {
3137
3426
  };
3138
3427
  }
3139
3428
 
3429
+ /**
3430
+ * The link-derived limits shared by every {@link SeedBootstrapService} this node builds: its
3431
+ * owner and invite dials ({@link controlDialBudget}) and its seed delivery deadline, derived
3432
+ * from `network.linkRoundTripMs`. One helper so the four construction sites cannot drift.
3433
+ */
3434
+ private seedServiceBudgets(): Pick<SeedBootstrapConfig, 'dialBudget' | 'linkRoundTripMs'> {
3435
+ return {
3436
+ dialBudget: this.controlDialBudget(),
3437
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
3438
+ };
3439
+ }
3440
+
3441
+ /**
3442
+ * {@link collectStrandAddrs} at this node's declared `network.linkRoundTripMs`, so each ask's
3443
+ * deadline derives from it. One helper so the four call sites cannot drift.
3444
+ */
3445
+ private async collectSiblingStrandAddrs(
3446
+ controlNode: Libp2p,
3447
+ peers: StrandAddrPeer[],
3448
+ strandId: string,
3449
+ delegatePeerId?: string
3450
+ ): Promise<StrandAddrCollection> {
3451
+ return await collectStrandAddrs(controlNode, peers, strandId, {
3452
+ delegatePeerId,
3453
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
3454
+ });
3455
+ }
3456
+
3140
3457
  /**
3141
3458
  * Dial one sibling from its already-resolved addresses, best-effort. Returns
3142
3459
  * whether the dial resolved (false when no address resolves or the dial fails).
@@ -3145,7 +3462,9 @@ export class CadreNode implements SAppIdLookup {
3145
3462
  * the {@link controlDialBudget} expiring) is logged and swallowed so one
3146
3463
  * unreachable sibling never aborts the pass — exactly like
3147
3464
  * {@link SeedBootstrapService.applySeed}'s owner-dial loop. A failed dial is
3148
- * simply retried on the next pass.
3465
+ * simply retried on the next pass. A sibling whose every address relays
3466
+ * through this node ({@link SelfRelayOnlyError}) is not dialed at all and gets
3467
+ * a one-line log instead: only the sibling can reconnect.
3149
3468
  */
3150
3469
  private async dialControlSibling(sibling: CohortPeerRow, resolved: Multiaddr[]): Promise<boolean> {
3151
3470
  const controlNode = this.controlNode;
@@ -3167,7 +3486,11 @@ export class CadreNode implements SAppIdLookup {
3167
3486
  );
3168
3487
  return true;
3169
3488
  } catch (error) {
3170
- log('reconcileControlCohort: dial of sibling %s failed (continuing): %o', sibling.peerId, error);
3489
+ if (error instanceof SelfRelayOnlyError) {
3490
+ log('reconcileControlCohort: sibling %s is reachable only by relaying through this node; waiting for it to reconnect', sibling.peerId);
3491
+ } else {
3492
+ log('reconcileControlCohort: dial of sibling %s failed (continuing): %o', sibling.peerId, error);
3493
+ }
3171
3494
  return false;
3172
3495
  }
3173
3496
  }
@@ -3426,7 +3749,11 @@ export class CadreNode implements SAppIdLookup {
3426
3749
  );
3427
3750
  return true;
3428
3751
  } catch (error) {
3429
- log('reconcileControlCohort(cold-start): dial of bootstrap peer %s failed (continuing): %o', peerId, error);
3752
+ if (error instanceof SelfRelayOnlyError) {
3753
+ log('reconcileControlCohort(cold-start): bootstrap peer %s is reachable only by relaying through this node; waiting for it to reconnect', peerId);
3754
+ } else {
3755
+ log('reconcileControlCohort(cold-start): dial of bootstrap peer %s failed (continuing): %o', peerId, error);
3756
+ }
3430
3757
  return false;
3431
3758
  }
3432
3759
  }
@@ -3444,7 +3771,7 @@ export class CadreNode implements SAppIdLookup {
3444
3771
  // local-only). The remedy: detect "wrote while alone" (0 connections is a sound
3445
3772
  // lower bound), queue the affected row, and RE-ISSUE the write once the cohort
3446
3773
  // grows (0→≥1 connection), as an idempotent monotonic update that now broadcasts.
3447
- // See docs/architecture.md (Control Network → write-while-alone durability).
3774
+ // See docs/architecture.md (Control Network Convergence → "Writes made while alone").
3448
3775
  // ============================================================================
3449
3776
 
3450
3777
  /**
@@ -4143,27 +4470,21 @@ export class CadreNode implements SAppIdLookup {
4143
4470
  // Check if we have sApp config for this strand
4144
4471
  const sAppConfig = this.sAppConfigs.get(strand.Id);
4145
4472
  if (!sAppConfig) {
4146
- log('No sAppConfig registered for strand %s - emitting strand:discovered', strand.Id);
4147
- // Strand created by another member and not yet configured locally. Surface
4148
- // it as a discovery event so the hosting app can decide whether to join
4149
- // (register a config + addStrand); the strand-agnostic seam keeps this
4150
- // class free of any app's join policy. A self-configured strand (config
4151
- // already present) keeps auto-starting below, unchanged.
4152
- //
4153
- // Recorded BEFORE the emit so a handler that synchronously drains
4154
- // `getDiscoveredStrands()` sees this strand too. The watcher does not offer the
4155
- // same strand twice — its `knownStrands` retains the id, and the one thing that
4156
- // un-retains it (a failed `addStrand`, via `StrandWatcher.forgetStrand`) leaves the
4157
- // sApp config registered, so the retry takes the auto-launch branch below rather
4158
- // than this one. So this map — not the event — is what a late subscriber reads.
4159
- // See the `strand:discovered` doc in types.ts.
4160
- this.discoveredStrands.set(strand.Id, strand);
4161
- this.emit('strand:discovered', { strandId: strand.Id, strand });
4162
- return;
4473
+ this.announceDiscoveredStrand(strand);
4474
+ // Announce-only unless this node hosts storage replicas. Also skipped when a
4475
+ // listener claimed the strand synchronously (an `addStrand` call registers its
4476
+ // config before its first await): that claim is launching it with the app's schema,
4477
+ // and a replica launch here would only race it.
4478
+ if (!this.hostUnclaimedStrands || this.sAppConfigs.has(strand.Id)) {
4479
+ return;
4480
+ }
4163
4481
  }
4164
4482
 
4165
4483
  try {
4166
- await this.launchStrand(strand, sAppConfig);
4484
+ const instance = await this.launchStrand(strand, sAppConfig);
4485
+ if (!sAppConfig) {
4486
+ void this.recordReplicaSAppId(instance.strandId);
4487
+ }
4167
4488
  } catch (error) {
4168
4489
  log('Error starting strand %s: %o', strand.Id, error);
4169
4490
  this.emit('strand:error', {
@@ -4180,6 +4501,60 @@ export class CadreNode implements SAppIdLookup {
4180
4501
  }
4181
4502
  }
4182
4503
 
4504
+ /**
4505
+ * Offer a strand no local config claims to the hosting app as `strand:discovered`, so
4506
+ * it can decide whether to join (register a config + addStrand); the strand-agnostic
4507
+ * seam keeps this class free of any app's join policy.
4508
+ *
4509
+ * Once per strand: a storage-replica launch that fails is retried by the watcher
4510
+ * (`StrandWatcher.forgetStrand` + backoff), and each retry comes back through
4511
+ * {@link handleStrandAdded} with no config — the app must still see one announcement.
4512
+ * The other thing that un-retains a strand in the watcher (a failed `addStrand`) leaves
4513
+ * the sApp config registered, so that retry takes the claimed branch instead.
4514
+ *
4515
+ * Recorded BEFORE the emit so a handler that synchronously drains
4516
+ * `getDiscoveredStrands()` sees this strand too. So this map — not the event — is what
4517
+ * a late subscriber reads. See the `strand:discovered` doc in types.ts.
4518
+ *
4519
+ * NOTE: a replica launch that keeps failing leaves the watcher not tracking the strand,
4520
+ * so if its control row is removed during the backoff no `handleStrandRemoved` arrives
4521
+ * and this entry outlives the row. Needs a persistently failing launch AND a removal
4522
+ * inside the backoff; if it is ever seen, have the watcher report removal of rows it
4523
+ * forgot, since `detachStrand` is already a no-op for an untracked instance.
4524
+ */
4525
+ private announceDiscoveredStrand(strand: StrandRow): void {
4526
+ if (this.discoveredStrands.has(strand.Id)) {
4527
+ return;
4528
+ }
4529
+ log('No sAppConfig registered for strand %s - emitting strand:discovered', strand.Id);
4530
+ this.discoveredStrands.set(strand.Id, strand);
4531
+ this.emit('strand:discovered', { strandId: strand.Id, strand });
4532
+ }
4533
+
4534
+ /**
4535
+ * Record the sApp id a storage replica's `Strand.Header` names, for {@link getSAppId}.
4536
+ * Runs whenever a replica's database may have just been published (launch, the
4537
+ * first-sync gate opening, a wake); a no-op for a claimed strand, an unpublished
4538
+ * database, or an id already recorded. Never throws: a failed read logs and leaves the
4539
+ * id unknown, so an `sAppId` filter keeps the strand provisionally admitted — a
4540
+ * syncing replica, which is harmless.
4541
+ */
4542
+ private async recordReplicaSAppId(strandId: string): Promise<void> {
4543
+ const database = this.strandManager.getInstance(strandId)?.database;
4544
+ if (!database || this.sAppConfigs.has(strandId) || this.replicaSAppIds.has(strandId)) {
4545
+ return;
4546
+ }
4547
+ try {
4548
+ const sAppId = await readStrandHeaderSAppId(database.getDatabase());
4549
+ if (sAppId !== undefined) {
4550
+ this.replicaSAppIds.set(strandId, sAppId);
4551
+ log('Storage replica %s serves sApp %s', strandId, sAppId);
4552
+ }
4553
+ } catch (error) {
4554
+ log('Could not read the sApp id of storage replica %s (left unknown): %o', strandId, error);
4555
+ }
4556
+ }
4557
+
4183
4558
  private async handleStrandRemoved(strandId: string): Promise<void> {
4184
4559
  log('Handling strand removed: %s', strandId);
4185
4560
 
@@ -4265,7 +4640,7 @@ export class CadreNode implements SAppIdLookup {
4265
4640
 
4266
4641
  // Unregister strand solicitation service
4267
4642
  if (this.strandSolicitationService && this.controlNode) {
4268
- this.strandSolicitationService.unregisterResponder(this.controlNode);
4643
+ await this.strandSolicitationService.unregisterResponder(this.controlNode);
4269
4644
  this.strandSolicitationService = null;
4270
4645
  }
4271
4646
 
@@ -4282,6 +4657,7 @@ export class CadreNode implements SAppIdLookup {
4282
4657
  this.sAppConfigs.clear();
4283
4658
  this.strandLaunchRefusals.clear();
4284
4659
  this.discoveredStrands.clear();
4660
+ this.replicaSAppIds.clear();
4285
4661
 
4286
4662
  // Drop delegate-admission state: the grants are scoped to the session that
4287
4663
  // recorded them, so a stop()/start() cycle on this object must not keep
@@ -4351,6 +4727,7 @@ export class CadreNode implements SAppIdLookup {
4351
4727
  if (instance) {
4352
4728
  this.hibernationManager.recordActivity(instance);
4353
4729
  }
4730
+ void this.recordReplicaSAppId(strandId);
4354
4731
  this.emit('strand:writable', { strandId });
4355
4732
  }
4356
4733
 
@@ -4366,29 +4743,19 @@ export class CadreNode implements SAppIdLookup {
4366
4743
 
4367
4744
  /**
4368
4745
  * Hibernate a strand: release its strand-network resources via the strand
4369
- * manager (stop the libp2p node, close the StrandDatabase) and mark it
4746
+ * manager (stop the libp2p node, close the StrandDatabase), which marks it
4370
4747
  * `hibernating`. A quiesced strand holds no open strand-network connections,
4371
- * transports, or DB handles. No-ops if the strand is missing; if already
4372
- * quiesced (defensive), just marks status and emits.
4748
+ * transports, or DB handles. No-ops if the strand is missing; one already quiesced
4749
+ * is only marked, and still emits.
4373
4750
  */
4374
4751
  private async handleStrandHibernate(strandId: string): Promise<void> {
4375
- const instance = this.strandManager.getInstance(strandId);
4376
- if (!instance) {
4752
+ if (!this.strandManager.getInstance(strandId)) {
4377
4753
  log('handleStrandHibernate: strand %s not found', strandId);
4378
4754
  return;
4379
4755
  }
4380
4756
 
4381
- if (!instance.libp2pNode && !instance.database) {
4382
- // Already quiesced — only the status flag needs updating.
4383
- instance.status = 'hibernating';
4384
- log('Strand %s already quiesced; marked hibernating', strandId);
4385
- this.emit('strand:hibernating', { strandId });
4386
- return;
4387
- }
4388
-
4389
4757
  log('Hibernating strand %s — releasing strand-network resources', strandId);
4390
4758
  await this.strandManager.quiesceStrand(strandId);
4391
- instance.status = 'hibernating';
4392
4759
  this.emit('strand:hibernating', { strandId });
4393
4760
  log('Strand %s hibernating (resources released)', strandId);
4394
4761
  }
@@ -4399,7 +4766,13 @@ export class CadreNode implements SAppIdLookup {
4399
4766
  * its runtime via the strand manager. If it is still live (e.g. waking an idle
4400
4767
  * strand, which retains its resources), just flip the status. Overlapping wake
4401
4768
  * triggers are coalesced upstream by `HibernationManager`, so this runs once
4402
- * per wake; `resumeStrand` is itself idempotent as a backstop.
4769
+ * per wake; a wake racing a check-in joins the check-in's rebuild in `resumeStrand`.
4770
+ *
4771
+ * A failed rebuild re-hibernates the strand (see {@link rehibernateIfResumeFailed});
4772
+ * every failure rethrows, so the waker still sees the error.
4773
+ *
4774
+ * Records no activity itself: whoever asked for the wake did ({@link wakeStrand},
4775
+ * `HibernationManager.recordActivity`), and {@link serviceWake}'s own probe must not.
4403
4776
  */
4404
4777
  private async handleStrandWake(strandId: string): Promise<void> {
4405
4778
  const instance = this.strandManager.getInstance(strandId);
@@ -4409,10 +4782,14 @@ export class CadreNode implements SAppIdLookup {
4409
4782
  }
4410
4783
 
4411
4784
  // Still live (idle wake, or defensive double-wake): no rebuild needed. A joiner
4412
- // still behind its first-sync gate wakes back to `'syncing'`, not `'active'`.
4413
- if (instance.libp2pNode || instance.database) {
4785
+ // still behind its first-sync gate wakes back to `'syncing'`, not `'active'`. A
4786
+ // `'starting'` strand is mid-build (a check-in's resume, or its launch) with the node
4787
+ // attached before the database: not live yet, so it falls through and joins that build.
4788
+ // A strand being quiesced still holds its handles but is on its way down: it falls
4789
+ // through too, and its resume runs after the quiesce.
4790
+ if (instance.status !== 'starting' && (instance.libp2pNode || instance.database)
4791
+ && !this.strandManager.isQuiescing(strandId)) {
4414
4792
  instance.status = liveStrandStatus(instance);
4415
- instance.lastActivity = new Date();
4416
4793
  log('Strand %s woke (already live)', strandId);
4417
4794
  this.emit('strand:waking', { strandId });
4418
4795
  return;
@@ -4421,18 +4798,60 @@ export class CadreNode implements SAppIdLookup {
4421
4798
  // Quiesced: re-resolve the volatile cohort input (the seed may have grown)
4422
4799
  // and rebuild the runtime.
4423
4800
  log('Waking strand %s — rebuilding strand-network resources', strandId);
4424
- await this.resumeStrandRuntime(strandId);
4425
- instance.lastActivity = new Date();
4801
+ try {
4802
+ await this.resumeStrandRuntime(strandId);
4803
+ } catch (error) {
4804
+ await this.rehibernateIfResumeFailed(instance, 'Wake', error);
4805
+ throw error;
4806
+ }
4426
4807
  this.emit('strand:waking', { strandId });
4427
4808
  log('Strand %s awake (resources rebuilt)', strandId);
4428
4809
  }
4429
4810
 
4811
+ /**
4812
+ * Undo a failed {@link resumeStrandRuntime}, but only when the rebuild itself failed — this
4813
+ * caller's, or the one it joined in `resumeStrand` — which leaves the instance `'error'`. A
4814
+ * failure before the rebuild (the cohort seed read) built nothing, and may find the strand
4815
+ * mid-build or just brought up by a concurrent wake or check-in; that runtime belongs to
4816
+ * whoever is building or holding it, so quiescing it here would stop a node still in use.
4817
+ */
4818
+ private async rehibernateIfResumeFailed(instance: StrandInstance, context: string, error: unknown): Promise<void> {
4819
+ if (instance.status !== 'error') {
4820
+ log('%s of strand %s failed before its rebuild (status=%s); leaving the strand as it is: %o',
4821
+ context, instance.strandId, instance.status, error);
4822
+ return;
4823
+ }
4824
+ log('%s of strand %s failed; re-hibernating so a later wake or check-in retries: %o', context, instance.strandId, error);
4825
+ await this.rehibernateAfterFailedResume(instance, context);
4826
+ }
4827
+
4828
+ /**
4829
+ * Put a strand whose rebuild or wake window failed back to `'hibernating'`, through a
4830
+ * best-effort quiesce that releases any runtime left up. `resumeStrand` leaves a failed
4831
+ * strand `'error'`, which nothing retries: `HibernationManager` wakes only
4832
+ * `idle`/`hibernating` strands, and reads any other status after a check-in as "woke",
4833
+ * ending its chain. Safe to run twice for one failure (a wake that joined a failed
4834
+ * check-in's rebuild): quiescing a quiesced strand only marks it.
4835
+ *
4836
+ * The quiesce writes the status itself, ordered before any resume requested meanwhile;
4837
+ * this writes it only when the quiesce failed, since a failed release leaves the status
4838
+ * alone.
4839
+ */
4840
+ private async rehibernateAfterFailedResume(instance: StrandInstance, context: string): Promise<void> {
4841
+ try {
4842
+ await this.strandManager.quiesceStrand(instance.strandId);
4843
+ } catch (cleanupErr) {
4844
+ log('%s cleanup quiesce for strand %s failed: %o', context, instance.strandId, cleanupErr);
4845
+ instance.status = 'hibernating';
4846
+ }
4847
+ }
4848
+
4430
4849
  /**
4431
4850
  * Rebuild a quiesced strand's runtime, re-resolving the volatile cohort input
4432
4851
  * first: the discovery seed may have grown since the strand last ran. Shared by
4433
4852
  * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so both
4434
- * apply the same fresh resolution. `resumeStrand` is idempotent
4435
- * (returns the live instance unchanged) as a backstop against double-resume.
4853
+ * apply the same fresh resolution. `resumeStrand` returns a live instance unchanged
4854
+ * and joins a rebuild already in flight, so the two paths never build twice.
4436
4855
  */
4437
4856
  private async resumeStrandRuntime(strandId: string): Promise<void> {
4438
4857
  // Re-derive the transport peerId (deterministic and cheap — a quiesced
@@ -4451,6 +4870,7 @@ export class CadreNode implements SAppIdLookup {
4451
4870
  if (instance.libp2pNode) {
4452
4871
  await this.mergeStrandPeerAddrs(instance.libp2pNode, bootstrapNodes, strandId);
4453
4872
  }
4873
+ void this.recordReplicaSAppId(strandId);
4454
4874
  }
4455
4875
 
4456
4876
  /**
@@ -4485,32 +4905,38 @@ export class CadreNode implements SAppIdLookup {
4485
4905
  return;
4486
4906
  }
4487
4907
 
4908
+ // Before the resume, so a wake that joins this rebuild, or activity recorded while it
4909
+ // runs, counts: the window then leaves the strand up for it.
4910
+ const activityMark = instance.lastActivity;
4911
+
4912
+ // 1. Resume exactly as a wake does: re-resolve the (possibly grown) cohort
4913
+ // seed, then rebuild the runtime.
4914
+ log('Check-in: resuming strand %s to probe the cohort for pending activity', strandId);
4488
4915
  try {
4489
- // 1. Resume exactly as a wake does: re-resolve the (possibly grown) cohort
4490
- // seed, then rebuild the runtime.
4491
- log('Check-in: resuming strand %s to probe the cohort for pending activity', strandId);
4492
4916
  await this.resumeStrandRuntime(strandId);
4917
+ } catch (err) {
4918
+ // Every failure resolves: `HibernationManager.runCheckIn` decides wake-vs-escalate
4919
+ // from `instance.status` alone. A failed rebuild — on a flaky network, the very
4920
+ // scenario hibernation targets — leaves `resumeStrand`'s `error`, which reads as
4921
+ // "woke" and would STOP the chain with no runtime and no future check-in, so it goes
4922
+ // back to `hibernating` and the manager escalates the backoff and retries. A failure
4923
+ // before the rebuild (the seed read) built nothing: the strand is still `hibernating`
4924
+ // (the manager escalates), or a wake that began meanwhile is building or holding it,
4925
+ // and the manager hands the chain to that wake, whose outcome restores or ends it.
4926
+ await this.rehibernateIfResumeFailed(instance, 'Check-in', err);
4927
+ return;
4928
+ }
4493
4929
 
4494
- // 2-3. Bounded window for the strand network to connect + the app to act,
4495
- // then re-hibernate-if-idle. Shared with the on-demand serviceWake.
4496
- const windowMs = this.config.hibernation?.checkInWindowMs ?? DEFAULT_CHECKIN_WINDOW_MS;
4497
- await this.runWakeWindow(instance, windowMs);
4930
+ // 2-3. Bounded window for the strand network to connect + the app to act,
4931
+ // then re-hibernate-if-idle. Shared with the on-demand serviceWake.
4932
+ const windowMs = this.config.hibernation?.checkInWindowMs ?? DEFAULT_CHECKIN_WINDOW_MS;
4933
+ try {
4934
+ await this.runWakeWindow(instance, activityMark, windowMs);
4498
4935
  } catch (err) {
4499
- // A check-in that throws part-way — resume failing on a flaky network (the
4500
- // very scenario hibernation targets), the window rejecting, or quiesce
4501
- // throwing — must leave the strand HIBERNATING, not in the `error` status
4502
- // that `resumeStrand` sets on failure. `HibernationManager.runCheckIn`
4503
- // decides wake-vs-escalate purely from `instance.status` after this
4504
- // resolves: an `error` status reads as "woke", which STOPS the check-in
4505
- // chain and strands the strand with no runtime and no future check-in.
4506
- // Forcing it back to `hibernating` (after a best-effort quiesce to release
4507
- // any partially-rebuilt runtime) makes the manager escalate the backoff and
4508
- // retry on the next tick.
4509
- log('Check-in failed for strand %s; re-hibernating to retry on backoff: %o', strandId, err);
4510
- await this.strandManager.quiesceStrand(strandId).catch((cleanupErr) => {
4511
- log('Check-in cleanup quiesce for strand %s failed: %o', strandId, cleanupErr);
4512
- });
4513
- instance.status = 'hibernating';
4936
+ // The window's quiesce threw with the rebuilt runtime up: release it best-effort and
4937
+ // leave the strand `hibernating`, for the same reason as a failed rebuild above.
4938
+ log('Check-in window failed for strand %s; re-hibernating to retry on backoff: %o', strandId, err);
4939
+ await this.rehibernateAfterFailedResume(instance, 'Check-in');
4514
4940
  }
4515
4941
  }
4516
4942
 
@@ -4518,33 +4944,42 @@ export class CadreNode implements SAppIdLookup {
4518
4944
  * Window-then-decide for a just-resumed strand, shared by the check-in timer
4519
4945
  * path ({@link handleStrandCheckIn}) and the on-demand {@link serviceWake}:
4520
4946
  *
4521
- * 1. Capture the post-resume activity marker. `recordActivity` assigns a
4522
- * FRESH `Date`, so a changed reference after the window means real
4523
- * activity landed during it — not millisecond-resolution noise.
4524
- * 2. Hold the strand live for `windowMs` so its strand network reaches the
4947
+ * 1. Hold the strand live for `windowMs` so its strand network reaches the
4525
4948
  * cohort and the app can drive pull-on-read activity.
4526
- * 3. If activity landed, leave the strand `active` (return `true`); otherwise
4527
- * quiesce and mark it `hibernating` again (return `false`).
4949
+ * 2. If activity landed since `activityMark`, leave the strand `active` (return
4950
+ * `true`); otherwise quiesce it, which marks it `hibernating` again (return `false`).
4951
+ * A wake or activity that lands during that quiesce rebuilds the strand after it.
4528
4952
  *
4529
- * @returns whether activity was observed during the window (strand left active).
4953
+ * @param activityMark - `instance.lastActivity` as the caller read it BEFORE bringing the
4954
+ * strand up. The bring-up records none, and every writer assigns a FRESH `Date`, so a
4955
+ * changed reference means a wake or activity landed during the resume or the window —
4956
+ * not millisecond-resolution noise.
4957
+ * @returns whether activity was observed (strand left active).
4530
4958
  */
4531
- private async runWakeWindow(instance: StrandInstance, windowMs: number): Promise<boolean> {
4959
+ private async runWakeWindow(instance: StrandInstance, activityMark: Date, windowMs: number): Promise<boolean> {
4532
4960
  const strandId = instance.strandId;
4533
- const activityMark = instance.lastActivity;
4534
4961
 
4962
+ // NOTE: activity during the window arms the idle countdown, so a window longer than the
4963
+ // hint's idle + hibernate timeouts (only reachable with a custom `windowMs`; the shortest
4964
+ // default pair, archive's, is 40 s vs a 15 s window) can let the timer path quiesce the
4965
+ // strand mid-window, which this then marks live. If such windows become real, suspend the
4966
+ // idle countdown while a window holds the strand.
4535
4967
  await this.holdWakeWindow(instance, windowMs);
4536
4968
 
4537
4969
  const sawActivity = instance.lastActivity !== activityMark;
4538
4970
  if (sawActivity) {
4539
- instance.status = liveStrandStatus(instance);
4540
- this.emit('strand:waking', { strandId });
4541
- log('Wake window: strand %s saw activity during the window; staying active', strandId);
4971
+ // A force-hibernate during the window has released the runtime and marked the strand
4972
+ // `hibernating`, which stands; a wake queued behind it writes its own status.
4973
+ if (instance.libp2pNode || instance.database) {
4974
+ instance.status = liveStrandStatus(instance);
4975
+ this.emit('strand:waking', { strandId });
4976
+ }
4977
+ log('Wake window: strand %s saw activity during the window; not re-hibernating', strandId);
4542
4978
  return true;
4543
4979
  }
4544
4980
 
4545
4981
  log('Wake window: no activity for strand %s; re-hibernating', strandId);
4546
4982
  await this.strandManager.quiesceStrand(strandId);
4547
- instance.status = 'hibernating';
4548
4983
  return false;
4549
4984
  }
4550
4985
 
@@ -4594,6 +5029,11 @@ export class CadreNode implements SAppIdLookup {
4594
5029
  * published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
4595
5030
  * anyone else's row joins, without the caller needing to know which it is.
4596
5031
  *
5032
+ * A joined row (no `founder: true`) this party's control database does not name — a
5033
+ * strand joined from another party — is remembered in the node's joined-strand store, so it
5034
+ * is re-offered as `strand:discovered` after a restart, and published party-wide by an
5035
+ * owner machine's reconcile pass (see {@link forgetJoinedStrand}).
5036
+ *
4597
5037
  * A rejected call leaves nothing running but DOES leave the sApp config
4598
5038
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
4599
5039
  * automatic relaunch need it. A failed launch here hands the strand back to that
@@ -4628,6 +5068,13 @@ export class CadreNode implements SAppIdLookup {
4628
5068
  );
4629
5069
  }
4630
5070
 
5071
+ // A row this node offered itself came from the control table or is already a
5072
+ // remembered join, and a row this node founds is not a join, so only a joined row
5073
+ // from elsewhere can be a new join to remember. A founded row recorded here would
5074
+ // come back after a restart with `FounderOwnerKey: null` and relaunch as a joiner.
5075
+ // Captured before the backlog delete below.
5076
+ const rememberable = founder !== true && !this.discoveredStrands.has(strandRow.Id);
5077
+
4631
5078
  // Store sApp config for this strand. The strand is claimed now, so it leaves the
4632
5079
  // unclaimed backlog — a later `getDiscoveredStrands()` drain must not re-offer it.
4633
5080
  this.sAppConfigs.set(strandRow.Id, sAppConfig);
@@ -4638,6 +5085,12 @@ export class CadreNode implements SAppIdLookup {
4638
5085
  log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)',
4639
5086
  strandRow.Id, sAppConfig.id, founder ?? 'derived');
4640
5087
 
5088
+ // Before the launch, so a foreign strand whose launch fails, or whose app is killed
5089
+ // mid-launch, is still offered again: by the watcher's retry and by the next start.
5090
+ if (rememberable) {
5091
+ await this.rememberForeignStrand(strandRow);
5092
+ }
5093
+
4641
5094
  // An unset `founder` is DERIVED from the row inside launchStrand (this node founds
4642
5095
  // iff the row's FounderOwnerKey is its own owner key); an explicit flag wins — the
4643
5096
  // formation/responder flows pass one deliberately, since their consent-seated rows
@@ -4657,7 +5110,8 @@ export class CadreNode implements SAppIdLookup {
4657
5110
  // only delays the watcher's own first attempt by one poll interval, which is what
4658
5111
  // should happen right after an attempt that just failed.
4659
5112
  //
4660
- // NOTE: the retry relaunches from the CONTROL row plus the registered config, not
5113
+ // NOTE: the retry relaunches from the CONTROL row (or, for a strand joined from
5114
+ // another party, the remembered join) plus the registered config, not
4661
5115
  // from the row and arguments passed here — so a caller that enriched either (a
4662
5116
  // synthetic `MemberPrivateKey`, an explicit `founder` or `partyMemberPrivateKey`)
4663
5117
  // is retried with less than it asked for. Inert today: founder-ness re-derives from
@@ -4821,7 +5275,7 @@ export class CadreNode implements SAppIdLookup {
4821
5275
  } catch (error) {
4822
5276
  // Only the uniqueness collision is a candidate for the idempotent reading; every
4823
5277
  // other rejection (unauthorized signer, retired stamp) must keep surfacing.
4824
- if (!isStrandIdConflict(error)) {
5278
+ if (!isStrandIdConflict(error, 'Strand')) {
4825
5279
  throw error;
4826
5280
  }
4827
5281
  const landed = await this.controlDatabase!.queryStrand(trimmed);
@@ -4978,7 +5432,7 @@ export class CadreNode implements SAppIdLookup {
4978
5432
  * — and with it the consent re-seat foreclosure — does propagate; but the physical row
4979
5433
  * deletion cannot be replayed, and `queryStrands` reads raw (no retired-stamp filter),
4980
5434
  * so siblings that already hold the row keep running the strand until the collection
4981
- * itself converges (logged loudly; see the delete-while-alone durability note in
5435
+ * itself converges (logged loudly; see "Deletes made while alone" in
4982
5436
  * docs/architecture.md).
4983
5437
  *
4984
5438
  * A no-op (no throw, no tombstone) when the row is already absent — but a
@@ -5016,6 +5470,9 @@ export class CadreNode implements SAppIdLookup {
5016
5470
  // A refused pre-split launch left no tracked instance, so the stop below never
5017
5471
  // reaches detachStrand for it — clear the refusal here so a re-founded id starts clean.
5018
5472
  this.strandLaunchRefusals.delete(trimmed);
5473
+ // A party-wide removal: the strand's peers must not stay dial targets for a strand
5474
+ // that no longer exists (a re-published id is a fresh strand with fresh peers).
5475
+ this.forgetStrandPeers(trimmed, 'unpublishStrand');
5019
5476
  // Clear THIS machine's own MemberPeer binding while the strand runtime — and the
5020
5477
  // retained party key — is still live: the delete above already destroyed the
5021
5478
  // party's StrandPartyKey row, so once this process forgets the key nothing can
@@ -5129,10 +5586,10 @@ export class CadreNode implements SAppIdLookup {
5129
5586
  * NOTE: "founding machine" here is the ROW's provenance, not the launch's resolved
5130
5587
  * `founder` flag — so an explicit `founder: false` over a row this machine published
5131
5588
  * still mints, an owner-signed write a caller that said "I am not founding" did not ask
5132
- * for. Harmless (the party needs that identity for the strand either way, and the mint
5133
- * is insert-if-absent) and unreachable today: the one explicit `founder: false` caller
5134
- * passes a row with a null `FounderOwnerKey`. If a caller ever pairs `founder: false`
5135
- * with its own published row, gate the mint on the resolved flag too.
5589
+ * for. Reached by a storage replica of a row this machine published (always a joiner,
5590
+ * see {@link launchStrand}) and kept on purpose: the party needs that identity for the
5591
+ * strand either way, the mint is insert-if-absent, and a closed-strand replica runs its
5592
+ * membership reconciler — which seats its own `MemberPeer` binding — only with a party key.
5136
5593
  */
5137
5594
  private async resolveStrandPartyKey(strand: StrandRow, explicitKey?: string): Promise<string | undefined> {
5138
5595
  if (explicitKey !== undefined) {
@@ -5327,14 +5784,23 @@ export class CadreNode implements SAppIdLookup {
5327
5784
  * A founder launch refused as pre-split (`PreSplitStrandIdentityError`) is recorded in
5328
5785
  * {@link strandLaunchRefusals} for the formation arm, then rethrown; a founder launch
5329
5786
  * that succeeds clears the record (it ran the bootstrap's pre-split check and passed).
5787
+ *
5788
+ * An absent `sAppConfig` launches a storage replica ({@link CadreNodeConfig.hostUnclaimedStrands}),
5789
+ * which is ALWAYS a joiner whatever the row says: the founder bootstrap writes the sApp
5790
+ * into `Strand.Header`, and a replica has none. So a self-founded row whose watcher poll
5791
+ * wins the race against its app's `addStrand` after a restart comes up as a replica — and
5792
+ * so does a `foundStrand` whose publish a watcher poll saw before its attach. A claim
5793
+ * that finds a tracked replica upgrades it in place, over the same node and store
5794
+ * ({@link StrandInstanceManager.attachSApp}), BEFORE any founding: the founder bootstrap
5795
+ * needs the sApp the attach supplies.
5330
5796
  */
5331
5797
  private async launchStrand(
5332
5798
  strand: StrandRow,
5333
- sAppConfig: SAppConfig,
5799
+ sAppConfig: SAppConfig | undefined,
5334
5800
  founder?: boolean,
5335
5801
  explicitPartyKey?: string
5336
5802
  ): Promise<StrandInstance> {
5337
- const resolvedFounder = founder ?? this.isSelfFoundedRow(strand);
5803
+ const resolvedFounder = sAppConfig ? (founder ?? this.isSelfFoundedRow(strand)) : false;
5338
5804
  try {
5339
5805
  const instance = await this.startOrFoundStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey);
5340
5806
  if (resolvedFounder) {
@@ -5355,46 +5821,13 @@ export class CadreNode implements SAppIdLookup {
5355
5821
  */
5356
5822
  private async startOrFoundStrand(
5357
5823
  strand: StrandRow,
5358
- sAppConfig: SAppConfig,
5824
+ sAppConfig: SAppConfig | undefined,
5359
5825
  resolvedFounder: boolean,
5360
5826
  explicitPartyKey: string | undefined
5361
5827
  ): Promise<StrandInstance> {
5362
5828
  const timed = <T>(step: string, op: () => Promise<T>) => timedStep('startOrFoundStrand', strand.Id, step, op);
5363
- const existing = this.strandManager.getInstance(strand.Id);
5364
- if (existing) {
5365
- if (resolvedFounder) {
5366
- // The resolver runs only when the retained config lacks a party key for a
5367
- // closed strand (see foundExistingStrand), so the common watcher re-entry
5368
- // ('already-founder') still costs no control read.
5369
- const outcome = await timed('foundExistingStrand', () => this.strandManager.foundExistingStrand(strand.Id,
5370
- () => this.resolveStrandPartyKey(strand, explicitPartyKey)));
5371
- if (outcome === 'needs-resume') {
5372
- try {
5373
- // Quiesced instance: the retained config now founds, but founding promises
5374
- // the bootstrap has RUN by the time the caller resolves — wake through the
5375
- // hibernation manager (coalesced with any in-flight wake, timer-aware) so
5376
- // the rebuild executes it now rather than at some eventual wake.
5377
- await timed('wakeStrand', () => this.wakeStrand(strand.Id));
5378
- // The wake's rebuild founds — UNLESS a wake was already in flight when the
5379
- // config flipped, in which case it had already read the pre-flip config and
5380
- // rebuilt as a joiner, and `wakeStrand` merely coalesced onto it. Re-run the
5381
- // (insert-if-absent) bootstrap so founding never resolves headerless.
5382
- await timed('ensureFounderBootstrap', () => this.strandManager.ensureFounderBootstrap(strand.Id));
5383
- } catch (error) {
5384
- // The founding did not happen (e.g. the rebuild refused a pre-split strand and
5385
- // rolled back, leaving the instance tracked with no runtime): withdraw the flip
5386
- // so the next attempt re-runs the founding instead of resolving
5387
- // 'already-founder' over an instance that never founded.
5388
- this.strandManager.withdrawFounderRequest(strand.Id);
5389
- throw error;
5390
- }
5391
- }
5392
- log('launchStrand: strand %s already tracked — founder request honored (%s)',
5393
- strand.Id, outcome);
5394
- } else {
5395
- log('launchStrand: strand %s already tracked locally — skipping re-launch', strand.Id);
5396
- }
5397
- return existing;
5829
+ if (this.strandManager.getInstance(strand.Id)) {
5830
+ return this.claimTrackedStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey);
5398
5831
  }
5399
5832
 
5400
5833
  // A closed strand's launch carries the party's OWN membership identity key: the
@@ -5414,18 +5847,25 @@ export class CadreNode implements SAppIdLookup {
5414
5847
  // retained launch config carries this derived key, so hibernate → wake
5415
5848
  // (resumeStrand) reuses the same peerId.
5416
5849
  //
5850
+ // Without an identity key (a node configured with neither `keyStore` nor
5851
+ // `privateKey` — tests, and an embedder that opted out of a stable identity) the
5852
+ // strand node still runs under a key CADRE-CORE HOLDS: a fresh random Ed25519 key,
5853
+ // exactly what libp2p would generate internally if handed none, except that the
5854
+ // book swap can sign this node's own entry with it (`strand-peer-book-swap.ts`) and
5855
+ // the retained launch config keeps the same strand peer id across a hibernation
5856
+ // resume. Stability across RESTARTS still needs an identity key.
5857
+ //
5417
5858
  // NOTE: derivation requires an Ed25519 identity key, so a node configured
5418
5859
  // with some other key type now fails strand launch outright (surfaced as
5419
5860
  // `strand:error` / a rejected addStrand) where it previously started the
5420
5861
  // strand on that key. Ed25519 is already required for every control-DB
5421
5862
  // signing path, so nothing reachable today hits this; if a non-Ed25519
5422
- // identity is ever supported, fall back to `undefined` here — libp2p then
5423
- // generates a random per-strand key, which still avoids the collision but
5424
- // gives up peerId stability across restarts.
5863
+ // identity is ever supported, take the random-key branch below for it — it
5864
+ // still avoids the collision, and gives up only peerId stability across restarts.
5425
5865
  const identityKey = this.identityKey;
5426
5866
  const transportKey = identityKey
5427
5867
  ? await timed('strandTransportKey', () => strandTransportKey(identityKey, strand.Id))
5428
- : undefined;
5868
+ : await generateKeyPair('Ed25519');
5429
5869
 
5430
5870
  // Derived BEFORE seed resolution so the seed pass doubles as the delegate
5431
5871
  // announcement and every grant is recorded before `startStrand` runs
@@ -5434,6 +5874,15 @@ export class CadreNode implements SAppIdLookup {
5434
5874
  const delegatePeerId = transportKey ? peerIdFromPrivateKey(transportKey).toString() : undefined;
5435
5875
  const bootstrapNodes = await timed('resolveCohortSeed', () => this.resolveCohortSeed(strand.Id, delegatePeerId));
5436
5876
 
5877
+ // Checked again after the awaits above: another launch of this strand may have started
5878
+ // meanwhile (a replica's watcher launch racing an app's claim, in either order), and
5879
+ // `startStrand` would hand its instance back unchanged — a claim holding a replica with
5880
+ // no `App` tables, a founder request dropped. Nothing awaits between this check and
5881
+ // `startStrand`'s own, so no third launch can slip in.
5882
+ if (this.strandManager.getInstance(strand.Id)) {
5883
+ return this.claimTrackedStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey);
5884
+ }
5885
+
5437
5886
  const instance = await timed('strandManager.startStrand', () => this.strandManager.startStrand({
5438
5887
  strandRow: strand,
5439
5888
  sAppConfig,
@@ -5455,7 +5904,20 @@ export class CadreNode implements SAppIdLookup {
5455
5904
  backfill: this.config.strandBackfill,
5456
5905
  revocationEnforcement: this.config.strandRevocationEnforcement,
5457
5906
  membershipReconciliation: this.config.strandMembershipReconciliation,
5458
- onSelfRevoked: (revokedStrandId) => this.emit('strand:revoked', { strandId: revokedStrandId }),
5907
+ onSelfRevoked: (revokedStrandId) => {
5908
+ this.forgetRevokedJoin(revokedStrandId);
5909
+ this.emit('strand:revoked', { strandId: revokedStrandId });
5910
+ },
5911
+ // The strand peer book's observation writer: every strand peer the node
5912
+ // identifies is remembered with its dialable addresses, so a restart re-finds
5913
+ // it. Retained with the launch config, so a hibernation wake re-arms it.
5914
+ onStrandPeerIdentified: (observedStrandId, observation) =>
5915
+ this.observeStrandPeer(observedStrandId, observation),
5916
+ // The book's third writer, the signed swap between strand peers: the strand node
5917
+ // reads and writes the store directly (`strand-peer-book-swap.ts`). Retained with
5918
+ // the launch config too. `?? undefined`: the field is `null` before start(), and a
5919
+ // strand cannot launch before start(), so this is belt and braces.
5920
+ strandPeerBook: this.strandPeerBookStore ?? undefined,
5459
5921
  onRejoinBlocked: (blockedStrandId) => this.emit('strand:rejoin-blocked', { strandId: blockedStrandId }),
5460
5922
  // The joiner's first-sync write gate (strand-first-sync-gate.ts): a launch that
5461
5923
  // comes up `'syncing'` announces the moment its database is published.
@@ -5494,6 +5956,72 @@ export class CadreNode implements SAppIdLookup {
5494
5956
  return instance;
5495
5957
  }
5496
5958
 
5959
+ /**
5960
+ * {@link startOrFoundStrand} for a strand the manager already tracks: a claim of a
5961
+ * storage replica gives it the app's schema in place, then a founder request founds it,
5962
+ * waking a quiesced instance so the bootstrap has run before this resolves. Emits
5963
+ * nothing — `strand:started` fired when the instance launched.
5964
+ *
5965
+ * A launch of the strand still in flight (the watcher's launch racing an app's
5966
+ * `addStrand`, in either order) is waited out first: until its build settles the tracked
5967
+ * instance is `'starting'` with no database, and returning it would resolve `addStrand`
5968
+ * with a strand the app cannot use — and skip that call's first-sync wait, which only a
5969
+ * `'syncing'` instance gets. If that launch fails, this rejects too: its record is gone.
5970
+ */
5971
+ private async claimTrackedStrand(
5972
+ strand: StrandRow,
5973
+ sAppConfig: SAppConfig | undefined,
5974
+ resolvedFounder: boolean,
5975
+ explicitPartyKey: string | undefined
5976
+ ): Promise<StrandInstance> {
5977
+ const timed = <T>(step: string, op: () => Promise<T>) => timedStep('startOrFoundStrand', strand.Id, step, op);
5978
+ const existing = await this.strandManager.whenRuntimeBuilt(strand.Id);
5979
+ if (!existing) {
5980
+ throw new Error(`Strand ${strand.Id}: the launch this one waited on failed (reported by the call that started it)`);
5981
+ }
5982
+ // Before the founder branch: the founder bootstrap writes the sApp into Strand.Header,
5983
+ // so it must run against the attached config.
5984
+ if (sAppConfig && !existing.sAppInfo) {
5985
+ const attached = await timed('attachSApp', () => this.strandManager.attachSApp(strand.Id, sAppConfig,
5986
+ { requireSignedSchemas: this.config.requireSignedSchemas }));
5987
+ log('launchStrand: strand %s was running as a storage replica — sApp %s claimed it (%s)',
5988
+ strand.Id, sAppConfig.id, attached);
5989
+ }
5990
+ if (!resolvedFounder) {
5991
+ log('launchStrand: strand %s already tracked locally — skipping re-launch', strand.Id);
5992
+ return existing;
5993
+ }
5994
+ // The resolver runs only when the retained config lacks a party key for a
5995
+ // closed strand (see foundExistingStrand), so the common watcher re-entry
5996
+ // ('already-founder') still costs no control read.
5997
+ const outcome = await timed('foundExistingStrand', () => this.strandManager.foundExistingStrand(strand.Id,
5998
+ () => this.resolveStrandPartyKey(strand, explicitPartyKey)));
5999
+ if (outcome === 'needs-resume') {
6000
+ try {
6001
+ // Quiesced instance: the retained config now founds, but founding promises
6002
+ // the bootstrap has RUN by the time the caller resolves — wake through the
6003
+ // hibernation manager (coalesced with any in-flight wake, timer-aware) so
6004
+ // the rebuild executes it now rather than at some eventual wake.
6005
+ await timed('wakeStrand', () => this.wakeStrand(strand.Id));
6006
+ // The wake's rebuild founds — UNLESS a wake was already in flight when the
6007
+ // config flipped, in which case it had already read the pre-flip config and
6008
+ // rebuilt as a joiner, and `wakeStrand` merely coalesced onto it. Re-run the
6009
+ // (insert-if-absent) bootstrap so founding never resolves headerless.
6010
+ await timed('ensureFounderBootstrap', () => this.strandManager.ensureFounderBootstrap(strand.Id));
6011
+ } catch (error) {
6012
+ // The founding did not happen (e.g. the rebuild refused a pre-split strand and
6013
+ // rolled back, leaving the instance tracked with no runtime): withdraw the flip
6014
+ // so the next attempt re-runs the founding instead of resolving
6015
+ // 'already-founder' over an instance that never founded.
6016
+ this.strandManager.withdrawFounderRequest(strand.Id);
6017
+ throw error;
6018
+ }
6019
+ }
6020
+ log('launchStrand: strand %s already tracked — founder request honored (%s)',
6021
+ strand.Id, outcome);
6022
+ return existing;
6023
+ }
6024
+
5497
6025
  /**
5498
6026
  * Resolve a strand's discovery seed — the dialable strand-network multiaddr
5499
6027
  * strings for cohort siblings. Membership comes from the control network's
@@ -5516,17 +6044,38 @@ export class CadreNode implements SAppIdLookup {
5516
6044
  * relay reservation — the gate denial there is fatal, not degraded. A
5517
6045
  * party-member relay answers the RPC (its control node admits us as a
5518
6046
  * member); a dedicated ops/ relay does not speak the protocol and the
5519
- * per-peer failure folds to `[]` — harmless, it has no membership gate and
6047
+ * per-peer failure comes back `unreachable` — harmless, it has no membership gate and
5520
6048
  * needs no grant. The relay's direct addr rides along as the dial fallback
5521
6049
  * for a relay we are not yet connected to.
5522
6050
  */
5523
6051
  private async resolveCohortSeed(strandId: string, delegatePeerId?: string): Promise<string[]> {
5524
6052
  const siblings = await this.resolveSiblingSeed(strandId, delegatePeerId);
5525
- // Sibling answers FIRST: they were resolved just now, while a contact-map entry is as
5526
- // old as the formation that produced it. `unionAddrs` appends only what the siblings
6053
+ // Sibling answers FIRST: they were resolved just now, while a book entry is as old
6054
+ // as the last connection to that peer. `unionAddrs` appends only what the siblings
5527
6055
  // did not already name, so a fresher answer is never displaced and each source keeps
5528
- // its own signaling-first ordering.
5529
- return unionAddrs(siblings, this.crossPartyStrandAddrs.get(strandId) ?? []);
6056
+ // its own signaling-first ordering. `delegatePeerId` IS this strand node's own id.
6057
+ return unionAddrs(siblings, this.strandPeerBookAddrs(strandId, delegatePeerId));
6058
+ }
6059
+
6060
+ /**
6061
+ * The strand peer book's contribution to a strand's seed: every live entry's
6062
+ * addresses, freshest peer first, minus this node's own entry when `selfPeerId` is
6063
+ * known (the book swap files one under the strand transport id; a node must never
6064
+ * dial itself). Empty before {@link start} builds the store.
6065
+ */
6066
+ private strandPeerBookAddrs(strandId: string, selfPeerId?: string): string[] {
6067
+ const store = this.strandPeerBookStore;
6068
+ if (!store) {
6069
+ return [];
6070
+ }
6071
+ const addrs: string[] = [];
6072
+ for (const entry of store.entries(strandId)) {
6073
+ if (entry.peerId === selfPeerId) {
6074
+ continue;
6075
+ }
6076
+ addrs.push(...entry.addrs);
6077
+ }
6078
+ return addrs;
5530
6079
  }
5531
6080
 
5532
6081
  /**
@@ -5547,10 +6096,13 @@ export class CadreNode implements SAppIdLookup {
5547
6096
  }
5548
6097
  }
5549
6098
  const bootstrapNodes = targets.length
5550
- ? await collectStrandAddrs(this.controlNode, targets, strandId, { delegatePeerId })
6099
+ ? (await this.collectSiblingStrandAddrs(this.controlNode, targets, strandId, delegatePeerId)).addrs
5551
6100
  : [];
5552
6101
  // Throttle state for the RELAY targets only — refreshDelegateGrants never
5553
- // looks up a sibling key, so recording one would only be dead weight.
6102
+ // looks up a sibling key, so recording one would only be dead weight. The
6103
+ // siblings' refresh due times are deliberately left alone too: stamping here
6104
+ // would race the refresh pass's pruning while the strand node is still coming
6105
+ // up, and all it would save is one extra RPC per sibling on the first tick.
5554
6106
  this.recordDelegateAnnounces(relays.map((r) => r.relayPeerId), strandId);
5555
6107
  return bootstrapNodes;
5556
6108
  }
@@ -5635,9 +6187,10 @@ export class CadreNode implements SAppIdLookup {
5635
6187
  * Record the announce timestamps {@link refreshDelegateGrants} throttles on,
5636
6188
  * for every relay a delegate-carrying announce pass dialed.
5637
6189
  *
5638
- * Recorded OPTIMISTICALLY at announce time: `collectStrandAddrs` folds
5639
- * per-peer failure to `[]` and reports no per-peer success, and threading
5640
- * success out would change its API for little gain. A failed INITIAL announce
6190
+ * Recorded OPTIMISTICALLY at announce time, whatever `collectStrandAddrs`
6191
+ * reports per peer: a dedicated `ops/` relay never speaks the strand-addr
6192
+ * protocol, so recording only on success would re-announce to it on every
6193
+ * reconcile tick, one wasted protocol negotiation each. A failed INITIAL announce
5641
6194
  * costs the strand supervisor its first attempt only (the relay denies the
5642
6195
  * reservation; every re-drive re-announces first through
5643
6196
  * {@link announceDelegateToRelay}); a failed REFRESH retries within
@@ -5674,7 +6227,7 @@ export class CadreNode implements SAppIdLookup {
5674
6227
  running.set(strandId, instance.libp2pNode.peerId.toString());
5675
6228
  }
5676
6229
  }
5677
- pruneStoppedStrandAnnounces(this.delegateAnnounceAt, new Set(running.keys()));
6230
+ prunePeerStrandKeys(this.delegateAnnounceAt, new Set(running.keys()));
5678
6231
  if (running.size === 0) {
5679
6232
  return;
5680
6233
  }
@@ -5704,16 +6257,16 @@ export class CadreNode implements SAppIdLookup {
5704
6257
  * again right away.
5705
6258
  *
5706
6259
  * Against a dedicated ops relay (no strand-addr RPC) the request fails per-peer
5707
- * and `collectStrandAddrs` folds it to `[]`: one wasted protocol negotiation per
6260
+ * and `collectStrandAddrs` reports it `unreachable`: one wasted protocol negotiation per
5708
6261
  * re-drive attempt, bounded by the supervisor's backoff. Never throws on that
5709
6262
  * path; a relay addr that names no peer id is logged and skipped (the hook's
5710
6263
  * caller runs the drive regardless).
5711
6264
  *
5712
6265
  * NOTE: against a relay that is DOWN this hook costs up to two strand-addr
5713
- * timeouts (10 s each: dial by peer id, then by addr) before the reservation drive even
5714
- * starts, so one failed re-drive holds the supervisor `driving` for those 20 s plus the
5715
- * drive's own deadline — 34 s at the default declared link round trip, and longer on a host
5716
- * that declared a slower one (`link-budget.ts`).
6266
+ * timeouts (dial by peer id, then by addr; 23 s each at the default declared link round trip)
6267
+ * before the reservation drive even starts, so one failed re-drive holds the supervisor
6268
+ * `driving` for those 46 s plus the drive's own deadline — 64 s at the default, and longer on
6269
+ * a host that declared a slower one (`link-budget.ts`).
5717
6270
  * Bounded and harmless while the relay is unreachable anyway; if recovery
5718
6271
  * latency after a relay comes back ever matters, skip the announce when the
5719
6272
  * control node holds no connection to the relay (the drive's own dial fails
@@ -5729,11 +6282,11 @@ export class CadreNode implements SAppIdLookup {
5729
6282
  log('announceDelegateToRelay: relay addr %s names no peer id; strand %s not announced', relayAddr, strandId);
5730
6283
  return;
5731
6284
  }
5732
- await collectStrandAddrs(
6285
+ await this.collectSiblingStrandAddrs(
5733
6286
  controlNode,
5734
6287
  [{ peerId: relayPeerId, addrs: [multiaddr(relayAddr)] }],
5735
6288
  strandId,
5736
- { delegatePeerId }
6289
+ delegatePeerId
5737
6290
  );
5738
6291
  this.recordDelegateAnnounces([relayPeerId], strandId);
5739
6292
  }
@@ -5753,14 +6306,15 @@ export class CadreNode implements SAppIdLookup {
5753
6306
  if (due.length === 0) {
5754
6307
  return;
5755
6308
  }
5756
- await collectStrandAddrs(controlNode, due.map(relayStrandAddrPeer), strandId, { delegatePeerId });
6309
+ await this.collectSiblingStrandAddrs(controlNode, due.map(relayStrandAddrPeer), strandId, delegatePeerId);
5757
6310
  this.recordDelegateAnnounces(due.map((relay) => relay.relayPeerId), strandId, now);
5758
6311
  }
5759
6312
 
5760
6313
  /**
5761
- * Re-resolve each running strand's SIBLING addresses over the control mesh and
5762
- * merge them into that strand's own libp2p address book, throttled to once per
5763
- * {@link STRAND_PEER_ADDR_REFRESH_MS} per strand.
6314
+ * Keep each running strand's own libp2p address book warm: re-merge its strand
6315
+ * peer book on every pass, and re-ask each connected SIBLING for its strand
6316
+ * addresses over the control mesh when that (sibling, strand) is due
6317
+ * ({@link strandAddrAskDueAt}).
5764
6318
  *
5765
6319
  * Without this, a strand's address book is written exactly once — the
5766
6320
  * launch/resume seed — and everything below cadre-core that dials a strand peer
@@ -5770,6 +6324,12 @@ export class CadreNode implements SAppIdLookup {
5770
6324
  * re-resolved, and even the original seed addresses fall off at the peerStore's
5771
6325
  * one-hour expiry (see `peer-addr-book.ts`).
5772
6326
  *
6327
+ * Due times are per (sibling, strand) and set from each sibling's own outcome, so
6328
+ * a sibling that connects late — a phone joining after the party's always-on
6329
+ * machines — is asked on the next tick, and one that could not answer or refused
6330
+ * (its view of the membership may not include us yet) is retried within
6331
+ * {@link STRAND_PEER_ADDR_RETRY_MS} rather than {@link STRAND_PEER_ADDR_REFRESH_MS}.
6332
+ *
5773
6333
  * Distinct from {@link refreshDelegateGrants}, deliberately: that pass covers
5774
6334
  * RELAYS on a `DELEGATE_GRANT_TTL_MS / 2` throttle to keep circuit-relay
5775
6335
  * admission grants alive, this one covers SIBLINGS on an address-expiry
@@ -5793,57 +6353,67 @@ export class CadreNode implements SAppIdLookup {
5793
6353
  running.add(strandId);
5794
6354
  }
5795
6355
  }
5796
- // Drop stamps for strands no longer running: the map must not grow for the
5797
- // node's lifetime, and a resumed strand should refresh immediately rather
5798
- // than inherit the stamp its previous incarnation left.
5799
- for (const strandId of this.strandPeerAddrRefreshAt.keys()) {
5800
- if (!running.has(strandId)) {
5801
- this.strandPeerAddrRefreshAt.delete(strandId);
5802
- }
5803
- }
5804
- const refreshMs = this.config.network?.controlCohort?.strandAddrRefreshMs ?? STRAND_PEER_ADDR_REFRESH_MS;
5805
- const due = [...running].filter(
5806
- (strandId) => now - (this.strandPeerAddrRefreshAt.get(strandId) ?? 0) >= refreshMs
5807
- );
5808
- if (due.length === 0) {
6356
+ if (running.size === 0) {
6357
+ this.strandAddrAskDueAt.clear();
5809
6358
  return;
5810
6359
  }
5811
- // `connectedSiblingTargets`' membership read is unbounded, and with zero
5812
- // connections its answer is empty whatever the table holds, so decide it from the
5813
- // connection list instead of paying for the read once a tick. Note this only skips
5814
- // the RPC: a strand carrying cross-party contact addresses still refreshes below,
5815
- // because those never came from a sibling in the first place.
5816
- //
5817
- // NOTE: one strand-addr RPC per (due strand × connected sibling) per refresh
5818
- // interval — each a tiny request/response on an already-open control
5819
- // connection. If a node ever runs MANY strands at once, batch the RPC to
5820
- // carry several strand ids per request rather than one fan-out per strand.
5821
- const targets = this.controlNode.getConnections().length === 0
5822
- ? []
5823
- : await this.connectedSiblingTargets().catch((error): StrandAddrPeer[] => {
5824
- log('refreshStrandPeerAddrs: sibling enumeration failed (skipping pass): %o', error);
5825
- return [];
5826
- });
6360
+ const targets = await this.strandAddrRefreshTargets();
5827
6361
  // A shutdown landed mid-enumeration.
5828
6362
  if (!this._running || !this.controlNode) {
5829
6363
  return;
5830
6364
  }
5831
- await Promise.all(due.map((strandId) => this.refreshOneStrandPeerAddrs(strandId, targets, now)));
6365
+ // Drop due times for strands no longer running and siblings no longer connected:
6366
+ // the map must not grow for the node's lifetime, a resumed strand must not
6367
+ // inherit its previous incarnation's due times, and a sibling that reconnects (a
6368
+ // phone restart, a new relay reservation) must be asked on its next connected
6369
+ // tick. An enumeration failure prunes everything, which costs one extra round of
6370
+ // asks, never a missed one.
6371
+ prunePeerStrandKeys(this.strandAddrAskDueAt, running, new Set(targets.map((t) => t.peerId)));
6372
+ await Promise.all([...running].map((strandId) => this.refreshOneStrandPeerAddrs(strandId, targets, now)));
6373
+ }
6374
+
6375
+ /**
6376
+ * The siblings {@link refreshStrandPeerAddrs} may ask this pass: the connected
6377
+ * cohort, or none when enumeration fails (logged, never thrown).
6378
+ *
6379
+ * `connectedSiblingTargets`' membership read is unbounded, and with zero
6380
+ * connections its answer is empty whatever the table holds, so decide it from the
6381
+ * connection list instead of paying for the read once a tick.
6382
+ *
6383
+ * NOTE: otherwise the read runs on EVERY tick with a running strand, not only when
6384
+ * some (sibling, strand) is due: pruning a departed sibling needs the current
6385
+ * target set, and whether anyone is due cannot be told without it (a connected
6386
+ * non-member never gets a due time to compare). One more `CadrePeer` read per 15 s
6387
+ * tick, on top of the two `runReconcileControlCohort` already makes; if those reads
6388
+ * get costly, share one row-set across the pass (see the NOTE there) rather than
6389
+ * skipping ticks here.
6390
+ *
6391
+ * NOTE: one strand-addr RPC per (running strand × due sibling) — each a tiny
6392
+ * request/response on an already-open control connection. If a node ever runs
6393
+ * MANY strands at once, batch the RPC to carry several strand ids per request
6394
+ * rather than one fan-out per strand.
6395
+ */
6396
+ private async strandAddrRefreshTargets(): Promise<StrandAddrPeer[]> {
6397
+ if (this.controlNode?.getConnections().length === 0) {
6398
+ return [];
6399
+ }
6400
+ return this.connectedSiblingTargets().catch((error): StrandAddrPeer[] => {
6401
+ log('refreshStrandPeerAddrs: sibling enumeration failed (asking nobody this pass): %o', error);
6402
+ return [];
6403
+ });
5832
6404
  }
5833
6405
 
5834
6406
  /**
5835
- * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, union their
5836
- * answers with this strand's cross-party contact addresses, and merge the lot into the
5837
- * strand's address book. Errors are logged and swallowed so one strand's failure never
5838
- * costs the others their refresh.
6407
+ * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings that are
6408
+ * due, union their answers with this strand's peer-book addresses, and merge the
6409
+ * lot into the strand's address book. Errors are logged and swallowed so one
6410
+ * strand's failure never costs the others their refresh.
5839
6411
  *
5840
- * `targets` may be EMPTY — a solo node, or one whose only control peers are strangers.
5841
- * The cross-party addresses still have to be re-merged in that case: they were learned
5842
- * at formation and can never be re-resolved, so this periodic re-merge is the ONLY
5843
- * thing standing between them and the peerStore's one-hour expiry. A strand with
5844
- * neither a sibling to ask nor a contact entry does nothing and leaves its throttle
5845
- * unstamped, so the next reconcile tick retries rather than sitting out the whole
5846
- * refresh interval having done nothing.
6412
+ * The peer book merges on EVERY pass, whether or not any sibling is due or
6413
+ * connected: nothing re-resolves another party's addresses, so this re-merge is the
6414
+ * ONLY thing standing between them and the peerStore's one-hour expiry — and it is
6415
+ * also how a re-formation's freshly carried addresses reach a running strand on the
6416
+ * next tick. A strand with nobody due and no book entry does nothing.
5847
6417
  */
5848
6418
  private async refreshOneStrandPeerAddrs(
5849
6419
  strandId: string,
@@ -5855,33 +6425,66 @@ export class CadreNode implements SAppIdLookup {
5855
6425
  if (!controlNode || !strandNode) {
5856
6426
  return;
5857
6427
  }
5858
- const contactAddrs = this.crossPartyStrandAddrs.get(strandId) ?? [];
5859
- if (targets.length === 0 && contactAddrs.length === 0) {
5860
- return;
5861
- }
5862
6428
  try {
6429
+ // Inside the try: a strand node torn down mid-pass may throw on any read, and one
6430
+ // strand's failure must never cost the others their refresh.
6431
+ const strandPeerId = strandNode.peerId.toString();
6432
+ const contactAddrs = this.strandPeerBookAddrs(strandId, strandPeerId);
6433
+ const due = targets.filter(
6434
+ (target) => now >= (this.strandAddrAskDueAt.get(peerStrandKey(target.peerId, strandId)) ?? 0)
6435
+ );
5863
6436
  // The running strand node's own peerId is the delegate to announce — see
5864
6437
  // the relationship note on refreshStrandPeerAddrs.
5865
- const siblingAddrs = targets.length === 0
6438
+ const siblingAddrs = due.length === 0
5866
6439
  ? []
5867
- : await collectStrandAddrs(controlNode, [...targets], strandId, {
5868
- delegatePeerId: strandNode.peerId.toString()
5869
- });
5870
- // Stamped on the pass having happened, not on its answer: the fan-out is
5871
- // what the throttle exists to bound, and an all-empty round is a normal
5872
- // steady state for a strand no connected sibling currently runs.
5873
- this.strandPeerAddrRefreshAt.set(strandId, now);
6440
+ : await this.askSiblingsForStrandAddrs(controlNode, due, strandId, strandPeerId, now);
5874
6441
  // Re-read the instance after the await — a strand stopped mid-pass (or one
5875
6442
  // already restarted onto a new node) must never have its store written to.
5876
6443
  if (!this._running || this.strandManager.getInstance(strandId)?.libp2pNode !== strandNode) {
5877
6444
  return;
5878
6445
  }
5879
- await this.mergeStrandPeerAddrs(strandNode, unionAddrs(siblingAddrs, contactAddrs), strandId);
6446
+ const addrs = unionAddrs(siblingAddrs, contactAddrs);
6447
+ if (addrs.length === 0) {
6448
+ return;
6449
+ }
6450
+ // NOTE: with no sibling due this still merges the whole book — up to 16 peers ×
6451
+ // 16 addrs, so 16 `peerStore.merge` calls per running strand per 15 s tick. An
6452
+ // unchanged address set writes nothing in libp2p's persistent peer store, so it
6453
+ // is cheap today; if many strands run at once, merge only book entries changed
6454
+ // since the last tick plus those nearing the one-hour expiry.
6455
+ await this.mergeStrandPeerAddrs(strandNode, addrs, strandId);
5880
6456
  } catch (error) {
5881
6457
  log('refreshStrandPeerAddrs: strand %s refresh failed (continuing): %o', strandId, error);
5882
6458
  }
5883
6459
  }
5884
6460
 
6461
+ /**
6462
+ * RPC `due` siblings for `strandId` and set each one's next due time from its own
6463
+ * outcome: an answer, even an empty one, waits the full refresh interval; anything
6464
+ * else retries in {@link STRAND_PEER_ADDR_RETRY_MS} (or the refresh interval, if
6465
+ * configured shorter). Stamped before the caller
6466
+ * re-checks that the strand is still running, so a strand torn down mid-pass still
6467
+ * records who was asked.
6468
+ */
6469
+ private async askSiblingsForStrandAddrs(
6470
+ controlNode: Libp2p,
6471
+ due: readonly StrandAddrPeer[],
6472
+ strandId: string,
6473
+ delegatePeerId: string,
6474
+ now: number
6475
+ ): Promise<string[]> {
6476
+ const { addrs, outcomes } = await this.collectSiblingStrandAddrs(controlNode, [...due], strandId, delegatePeerId);
6477
+ const refreshMs = this.config.network?.controlCohort?.strandAddrRefreshMs ?? STRAND_PEER_ADDR_REFRESH_MS;
6478
+ // A configured refresh shorter than the retry must not leave a failing sibling
6479
+ // waiting longer than a healthy one.
6480
+ const retryMs = Math.min(refreshMs, STRAND_PEER_ADDR_RETRY_MS);
6481
+ for (const [peerId, outcome] of outcomes) {
6482
+ const waitMs = siblingAnswered(outcome) ? refreshMs : retryMs;
6483
+ this.strandAddrAskDueAt.set(peerStrandKey(peerId, strandId), now + waitMs);
6484
+ }
6485
+ return addrs;
6486
+ }
6487
+
5885
6488
  /**
5886
6489
  * Merge strand-network addresses into ONE strand node's libp2p address book,
5887
6490
  * attributed per peer. Best-effort throughout: an address-book write must never
@@ -5901,14 +6504,14 @@ export class CadreNode implements SAppIdLookup {
5901
6504
  * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
5902
6505
  * `backlog/strand-network-nat-relay-reachability`.
5903
6506
  *
5904
- * NOTE: one input does NOT age out — the cross-party addresses a formation
5905
- * carried back ({@link crossPartyStrandAddrs}). Nothing can re-resolve them, so
5906
- * {@link refreshStrandPeerAddrs} re-merges the same stored list every pass, which
5907
- * also means a junk entry from a responder survives for the node's lifetime rather
5908
- * than an hour. Bounded to {@link MAX_STRAND_ADDRS} per strand, from a party this
5909
- * node deliberately formed a strand with, and still authority-free — so the
5910
- * exposure is a handful of failed dials, not a trust hole. Evict on
5911
- * `unpublishStrand` if that ever stops being an acceptable price.
6507
+ * NOTE: one input ages out on a slower clock — the strand peer book's entries
6508
+ * ({@link strandPeerBookStore}). Nothing can re-resolve another party's addresses,
6509
+ * so {@link refreshStrandPeerAddrs} re-merges the book every pass, which means a
6510
+ * junk entry from a responder or a peer's identify survives until the book drops it
6511
+ * (14 days unrefreshed, or the peer's next connection replacing the entry) rather
6512
+ * than an hour. Bounded to 16 peers × 16 addrs per strand, forgotten on
6513
+ * `unpublishStrand` / `forgetJoinedStrand`, and still authority-free — so the
6514
+ * exposure is a handful of failed dials, not a trust hole.
5912
6515
  */
5913
6516
  private async mergeStrandPeerAddrs(strandNode: Libp2p, addrs: string[], strandId: string): Promise<void> {
5914
6517
  const counts: Record<MergeAddrsResult, number> = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
@@ -5977,6 +6580,138 @@ export class CadreNode implements SAppIdLookup {
5977
6580
  return orderSignalingFirst(addrs);
5978
6581
  }
5979
6582
 
6583
+ /**
6584
+ * Leave a strand joined from another party, for the whole party: remove its party-wide
6585
+ * `JoinedStrand` row (owner-signed, with a `Revocation` tombstone), then this machine's
6586
+ * unpublished record, its strand peer book entries and any session-kept entry, then
6587
+ * {@link stopStrand} it here. Every other machine's watcher then sees the row gone and
6588
+ * detaches the strand, a storage replica included; a machine offline at the time reaps the
6589
+ * row once the tombstone reaches it. The joiner's counterpart of {@link unpublishStrand},
6590
+ * which only works on a row the party's own `Strand` table holds. For one of those, or for
6591
+ * a join still local-only, there is no party-wide row and this is a local forget plus
6592
+ * {@link stopStrand}.
6593
+ *
6594
+ * The other members of the strand are not told, and this party's membership row in the
6595
+ * strand stays, so a later re-formation reuses the same identity.
6596
+ *
6597
+ * @throws when a party-wide row exists and this machine cannot sign its removal (not an
6598
+ * enrolled owner) — {@link stopStrand} stops the strand on this machine only — or when
6599
+ * the party-wide table cannot be read.
6600
+ */
6601
+ async forgetJoinedStrand(strandId: string): Promise<void> {
6602
+ if (!this._running) {
6603
+ throw new Error('CadreNode not running');
6604
+ }
6605
+ await this.joinedStrands!.leave(strandId);
6606
+ this.forgetStrandPeers(strandId, 'forgetJoinedStrand');
6607
+ await this.stopStrand(strandId);
6608
+ }
6609
+
6610
+ /**
6611
+ * Record `row` as a join from another party when this party's control database names it
6612
+ * neither as its own strand nor as a party-wide join — the one case nothing else would
6613
+ * re-offer after a restart (`JoinedStrandSession.rememberForeign`).
6614
+ *
6615
+ * Best-effort, for {@link addStrand}: a failure costs the strand its re-offer after the
6616
+ * next restart, not this attach, so it is reported and the attach goes on.
6617
+ * {@link formStrand}, whose join nothing else would name, fails loudly instead.
6618
+ *
6619
+ * NOTE: "no control row" also describes a row the party has just unpublished. A claim
6620
+ * of such a row the app kept from an earlier offer, made after the watcher already
6621
+ * withdrew it, is remembered as a join and survives the removal on this machine until
6622
+ * {@link forgetJoinedStrand}. Contrived today (a claim of a row the node itself is still
6623
+ * offering skips this method); if it shows up, check the strand's `Revocation`
6624
+ * tombstone here before recording.
6625
+ */
6626
+ private async rememberForeignStrand(row: StrandRow): Promise<void> {
6627
+ if (!this.joinedStrands || !this.controlDatabase) {
6628
+ return;
6629
+ }
6630
+ try {
6631
+ await this.joinedStrands.rememberForeign(row);
6632
+ } catch (error) {
6633
+ console.warn(`addStrand(${row.Id}): could not remember this strand as joined from another party, ` +
6634
+ 'so it will not be re-offered after a restart:', error);
6635
+ }
6636
+ }
6637
+
6638
+ /**
6639
+ * Self-revocation arm of the joined-strand records: a party removed from a strand must
6640
+ * not re-attach it on every launch of every machine. The strand keeps running this
6641
+ * session here — the `strand:revoked` contract is that nothing is torn down for the app —
6642
+ * and its party-wide row is queued for removal by the next connected owner reconcile pass
6643
+ * (`JoinedStrandSession.forgetAfterThisSession`). A no-op for this party's own strands,
6644
+ * which have neither record; the strand peer book is forgotten either way, since a
6645
+ * removed party must not keep dialing the strand's peers.
6646
+ *
6647
+ * NOTE: accepted tradeoff — a sibling machine that has not raised `strand:revoked` itself
6648
+ * detaches the strand (`strand:stopped`) when the party-wide removal reaches it, instead
6649
+ * of keeping it for the session, which weakens "nothing is stopped on the removed
6650
+ * machine's behalf" for siblings. Weighed against a party-wide row that brings a revoked
6651
+ * strand back on every start of every machine. Revisit if apps need the "you were
6652
+ * removed" screen on every device: the sibling could re-check its own revocation state
6653
+ * (`refreshRevocationEnforcement`) before detaching a vanished joined row.
6654
+ *
6655
+ * NOTE: a manager can re-admit a removed party directly (`addMemberByManager`), and the
6656
+ * membership loop then finishes the join on its own — but the records are gone by then,
6657
+ * so the next start does not re-attach the strand. Re-forming records it again. If direct
6658
+ * re-admission becomes a routine flow, re-record the join when the revoked-peer gate
6659
+ * clears for this node.
6660
+ */
6661
+ private forgetRevokedJoin(strandId: string): void {
6662
+ void this.joinedStrands?.forgetAfterThisSession(strandId).catch((error: unknown) => {
6663
+ log('forgetting revoked joined strand %s failed; it will be re-offered on the next start: %o',
6664
+ strandId, error);
6665
+ });
6666
+ // The strand's peers go with the join: a removed party must not keep dialing them.
6667
+ this.forgetStrandPeers(strandId, 'self-revocation');
6668
+ }
6669
+
6670
+ /**
6671
+ * Forget a strand's entries in the peer book (see {@link strandPeerBookStore}):
6672
+ * fire-and-log, like every book write — the removal is visible synchronously by the
6673
+ * store's contract, and a failed persist costs restart survival only.
6674
+ */
6675
+ private forgetStrandPeers(strandId: string, caller: string): void {
6676
+ void this.strandPeerBookStore?.forget(strandId).catch((error: unknown) => {
6677
+ log('%s: forgetting strand %s peers failed (continuing): %o', caller, strandId, error);
6678
+ });
6679
+ }
6680
+
6681
+ /**
6682
+ * The strand peer book's observation writer (`StartStrandConfig.onStrandPeerIdentified`):
6683
+ * a strand node identified a strand peer at `observation.addrs`, so remember it as an
6684
+ * unsigned entry seen just now. The observer never reports self, and the store binds
6685
+ * every address to the peer id before filing it.
6686
+ */
6687
+ private observeStrandPeer(strandId: string, observation: StrandPeerObservation): void {
6688
+ this.rememberStrandPeer(strandId, {
6689
+ peerId: observation.peerId,
6690
+ addrs: observation.addrs,
6691
+ issuedAt: 0,
6692
+ lastSeenAt: Date.now()
6693
+ }, 'observeStrandPeer');
6694
+ }
6695
+
6696
+ /**
6697
+ * Merge one entry into the peer book. Fire-and-log: the entry is visible
6698
+ * synchronously by the store's contract, and the promise tracks durability only. A
6699
+ * persist failure costs restart survival, never this session's seed — the same trade
6700
+ * {@link retainDialTarget} makes.
6701
+ */
6702
+ private rememberStrandPeer(strandId: string, entry: StrandPeerEntry, caller: string): void {
6703
+ const store = this.strandPeerBookStore;
6704
+ if (!store) {
6705
+ // Unreachable in production: start() builds the store before any strand can
6706
+ // launch or form. Logged rather than thrown, for the same reason as retainDialTarget.
6707
+ log('%s: no strand peer book yet — not remembering %s for strand %s', caller, entry.peerId, strandId);
6708
+ return;
6709
+ }
6710
+ void store.merge(strandId, entry).catch((error: unknown) => {
6711
+ log('%s: persisting strand %s peer %s failed (continuing): %o', caller, strandId, entry.peerId, error);
6712
+ });
6713
+ }
6714
+
5980
6715
  /**
5981
6716
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
5982
6717
  * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
@@ -5985,7 +6720,9 @@ export class CadreNode implements SAppIdLookup {
5985
6720
  * id in the watcher (`StrandWatcher.suppressStrand`) and drops it from
5986
6721
  * {@link getDiscoveredStrands}, so neither a later poll nor a drain can undo a
5987
6722
  * deliberate stop. Only an explicit {@link addStrand} does, which is the caller
5988
- * reversing its own decision. Party-wide removal is {@link unpublishStrand}.
6723
+ * reversing its own decision. Party-wide removal is {@link unpublishStrand}. A strand
6724
+ * joined from another party comes back the same way, from its remembered join;
6725
+ * {@link forgetJoinedStrand} is how to leave one for good.
5989
6726
  */
5990
6727
  async stopStrand(strandId: string): Promise<void> {
5991
6728
  if (!this._running) {
@@ -6068,10 +6805,19 @@ export class CadreNode implements SAppIdLookup {
6068
6805
  }
6069
6806
 
6070
6807
  /**
6071
- * Force wake a hibernating strand
6808
+ * Force wake a hibernating strand. A requested wake is activity, recorded before the
6809
+ * wake starts: a check-in holding the strand — or rebuilding it, a rebuild this wake then
6810
+ * joins — leaves it up instead of re-quiescing it at the end of its window. Once awake the
6811
+ * strand's idle countdown runs, so it hibernates again if nothing uses it.
6072
6812
  */
6073
6813
  async wakeStrand(strandId: string): Promise<void> {
6074
- await this.hibernationManager.wakeStrand(strandId);
6814
+ const instance = this.strandManager.getInstance(strandId);
6815
+ if (!instance) {
6816
+ log('wakeStrand: strand %s not found; nothing to wake', strandId);
6817
+ return;
6818
+ }
6819
+ instance.lastActivity = new Date();
6820
+ await this.hibernationManager.wakeStrand(instance);
6075
6821
  }
6076
6822
 
6077
6823
  // ============================================================================
@@ -6089,7 +6835,7 @@ export class CadreNode implements SAppIdLookup {
6089
6835
  * Routes through {@link HibernationManager.forceHibernate}, which cancels the
6090
6836
  * strand's pending idle/hibernate (and check-in) timers — so a stale timer
6091
6837
  * can't re-fire on or resurrect the strand — then runs the same `onHibernate`
6092
- * path as the timer (`quiesceStrand` + `status='hibernating'` +
6838
+ * path as the timer (`quiesceStrand`, which marks it `hibernating`, then
6093
6839
  * `strand:hibernating`). Unlike the timer path it does NOT re-arm check-ins:
6094
6840
  * the strand stays down until the caller drives a wake (e.g. {@link serviceWake}).
6095
6841
  */
@@ -6151,9 +6897,14 @@ export class CadreNode implements SAppIdLookup {
6151
6897
  * strand share one in-flight operation ({@link serviceWakePromises}), and the
6152
6898
  * underlying resume coalesces with a racing push-wake via
6153
6899
  * {@link HibernationManager}'s wake coalescing — one runtime build, one window,
6154
- * one re-hibernate decision. Returns `{ serviced: false }` (never throws) when
6155
- * the node is not running or the strand is unknown, and surfaces a resume
6156
- * failure as `{ serviced: true, hadActivity: false }` after re-hibernating.
6900
+ * one re-hibernate decision. A wake or activity from elsewhere that lands during
6901
+ * the resume or the window leaves the strand up; this call's own wake does not.
6902
+ * Afterwards a strand left up has its idle countdown running, and a re-hibernated one
6903
+ * gets back the check-in chain this call interrupted — it never gains one it lacked,
6904
+ * so a strand the mobile runner force-hibernated stays down until the next push.
6905
+ * Returns `{ serviced: false }` (never throws) when
6906
+ * the node is not running or the strand is unknown, and surfaces a wake or
6907
+ * window failure as `{ serviced: true, hadActivity: false }`.
6157
6908
  *
6158
6909
  * @param strandId - the strand a push said has pending activity.
6159
6910
  * @param opts.windowMs - override the live-window duration (defaults to the
@@ -6189,30 +6940,57 @@ export class CadreNode implements SAppIdLookup {
6189
6940
  return { strandId, serviced: false, hadActivity: false };
6190
6941
  }
6191
6942
 
6192
- // Already live (active or idle — both retain their runtime): servicing is a
6193
- // no-op success. Do NOT run a window that would re-hibernate a strand the app
6194
- // may be actively using, and do NOT rebuild a second runtime.
6195
- if (instance.libp2pNode || instance.database) {
6943
+ // Already live (active or idle — both retain their runtime) or coming up (a launch,
6944
+ // or a check-in with its own window): servicing is a no-op success. Do NOT run a
6945
+ // window that would re-hibernate a strand the app may be actively using, and do NOT
6946
+ // rebuild a second runtime. `'starting'` counts before any handle is attached. A strand
6947
+ // being quiesced does not count: the wake below rebuilds it after the quiesce.
6948
+ if (!this.strandManager.isQuiescing(strandId)
6949
+ && (instance.status === 'starting' || instance.libp2pNode || instance.database)) {
6196
6950
  log('serviceWake: strand %s already live; no-op success', strandId);
6197
6951
  return { strandId, serviced: true, hadActivity: true };
6198
6952
  }
6199
6953
 
6954
+ // Before the resume, as the check-in takes it (see handleStrandCheckIn).
6955
+ // NOTE: a `wakeStrand` already resuming when this runs stamped its activity before this
6956
+ // mark, so the probe joins it without counting it and the window can re-hibernate a strand
6957
+ // that wake asked to keep up; if foreground wakes and serviceWake overlap in practice, mark
6958
+ // before the joined wake's stamp instead.
6959
+ const activityMark = instance.lastActivity;
6960
+ const windowMs = opts?.windowMs ?? this.config.hibernation?.checkInWindowMs ?? DEFAULT_CHECKIN_WINDOW_MS;
6200
6961
  try {
6201
- // Coalesced resume: routes through wakeStrand → HibernationManager.beginWake
6202
- // so a racing push-wake shares this single runtime build.
6203
- await this.wakeStrand(strandId);
6204
- const windowMs = opts?.windowMs ?? this.config.hibernation?.checkInWindowMs ?? DEFAULT_CHECKIN_WINDOW_MS;
6205
- const hadActivity = await this.runWakeWindow(instance, windowMs);
6962
+ return await this.wakeAndHoldForService(instance, activityMark, windowMs);
6963
+ } finally {
6964
+ // After the window (or a failure) decided the state: the idle countdown if the strand
6965
+ // stayed up, else the check-in chain this probe interrupted, if any.
6966
+ this.hibernationManager.endProbe(instance);
6967
+ }
6968
+ }
6969
+
6970
+ /**
6971
+ * The wake and window of {@link runServiceWake}. A failure in either — the network
6972
+ * unreachable inside a Doze grant, say — must not throw out of a background task, so it
6973
+ * reports no activity. Each stage cleans up only what it owns.
6974
+ */
6975
+ private async wakeAndHoldForService(instance: StrandInstance, activityMark: Date, windowMs: number): Promise<ServiceWakeResult> {
6976
+ const { strandId } = instance;
6977
+ try {
6978
+ // Coalesced resume: HibernationManager.beginWake, so a racing push-wake shares this
6979
+ // single runtime build. Not through `wakeStrand`, which records the wake as activity:
6980
+ // this probe's own wake must not count as a reason to stay up.
6981
+ await this.hibernationManager.probeWake(instance);
6982
+ } catch (error) {
6983
+ // handleStrandWake has already re-hibernated a failed rebuild, and left alone a strand
6984
+ // it failed before building, which may be a check-in's.
6985
+ log('serviceWake: wake of strand %s failed: %o', strandId, error);
6986
+ return { strandId, serviced: true, hadActivity: false };
6987
+ }
6988
+ try {
6989
+ const hadActivity = await this.runWakeWindow(instance, activityMark, windowMs);
6206
6990
  return { strandId, serviced: true, hadActivity };
6207
6991
  } catch (error) {
6208
- // Resume failing mid-window (network unreachable inside a Doze grant, etc.)
6209
- // must not throw out of a background task: re-hibernate and report no
6210
- // activity, mirroring handleStrandCheckIn's re-hibernate-on-error.
6211
6992
  log('serviceWake: strand %s failed during wake window; re-hibernating: %o', strandId, error);
6212
- await this.strandManager.quiesceStrand(strandId).catch((cleanupErr) => {
6213
- log('serviceWake cleanup quiesce for strand %s failed: %o', strandId, cleanupErr);
6214
- });
6215
- instance.status = 'hibernating';
6993
+ await this.rehibernateAfterFailedResume(instance, 'serviceWake');
6216
6994
  return { strandId, serviced: true, hadActivity: false };
6217
6995
  }
6218
6996
  }
@@ -6247,8 +7025,8 @@ export class CadreNode implements SAppIdLookup {
6247
7025
  *
6248
7026
  * BUDGET: the supervisor's first attempt is what `start()` waits on — deliberately the drive's
6249
7027
  * ordinary deadline rather than a boot-specific one. That deadline is now COUNTED, four link
6250
- * round trips at the declared `network.linkRoundTripMs` (14 s at its default, where it was a
6251
- * fixed 10 s): a healthy dial-plus-reserve is sub-second even over a WAN, so this is slack for
7028
+ * round trips at the declared `network.linkRoundTripMs` plus the relay's two admission
7029
+ * decisions (18 s at its default, where it was a fixed 10 s): a healthy dial-plus-reserve is sub-second even over a WAN, so this is slack for
6252
7030
  * a slow link, while going much longer would make a dead relay indistinguishable from a hung
6253
7031
  * start and much shorter would fail nodes on links that were merely slow. A host that declares
6254
7032
  * a slower link lengthens it without touching this path — `link-budget.ts`. The retries carry
@@ -6404,7 +7182,7 @@ export class CadreNode implements SAppIdLookup {
6404
7182
  *
6405
7183
  * @param ownerPrivateKey - The owner's private key (base64url encoded)
6406
7184
  */
6407
- initializeSeedBootstrap(ownerPrivateKey: string): void {
7185
+ async initializeSeedBootstrap(ownerPrivateKey: string): Promise<void> {
6408
7186
  if (!this.controlNode || !this.controlDatabase) {
6409
7187
  throw new Error('CadreNode must be started before initializing seed bootstrap');
6410
7188
  }
@@ -6430,20 +7208,16 @@ export class CadreNode implements SAppIdLookup {
6430
7208
  .catch((error) => log('Trusted-owner genesis anchor persist failed: %o', error));
6431
7209
  }
6432
7210
 
6433
- this.seedBootstrapService = new SeedBootstrapService({
7211
+ await this.installSeedBootstrapService(new SeedBootstrapService({
6434
7212
  partyId: this.config.controlNetwork.partyId,
6435
7213
  ownerPrivateKey,
6436
7214
  inviteAddressResolver: () => this.resolveInviteAddresses(),
6437
- dialBudget: this.controlDialBudget(),
7215
+ ...this.seedServiceBudgets(),
6438
7216
  trustPolicy: this.config.seedTrustPolicy,
6439
7217
  // Seed trust anchors on the node-local store (seeded just above with this
6440
7218
  // node's own genesis key), never on the replicated OwnerKey table.
6441
7219
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
6442
- });
6443
-
6444
- this.seedBootstrapService.setEventCallbacks(this.seedEventCallbacks());
6445
-
6446
- this.seedBootstrapService.initialize(this.controlNode, this.controlDatabase);
7220
+ }), this.controlNode, this.controlDatabase);
6447
7221
  log('Seed bootstrap service initialized');
6448
7222
  }
6449
7223
 
@@ -6648,7 +7422,8 @@ export class CadreNode implements SAppIdLookup {
6648
7422
  * is reachable through its circuit-relay address), dials `WAKE_PROTOCOL`, sends
6649
7423
  * the {@link WakeRequest}, and returns the peer's {@link WakeAck}. The receiver
6650
7424
  * gates the request on cadre membership and only resumes a strand it already
6651
- * participates in.
7425
+ * participates in; it acks once it has decided, before the strand is up. The
7426
+ * dial deadlines derive from this node's `network.linkRoundTripMs`.
6652
7427
  *
6653
7428
  * @param targetPeerId - The hibernating cadre peer to wake.
6654
7429
  * @param strandId - The strand the caller knows has pending activity.
@@ -6664,7 +7439,7 @@ export class CadreNode implements SAppIdLookup {
6664
7439
  throw new Error(`No dialable control-network address for peer ${targetPeerId}`);
6665
7440
  }
6666
7441
  const request: WakeRequest = { strandId, reason };
6667
- return await dialWake(this.controlNode, addrs, request);
7442
+ return await dialWake(this.controlNode, addrs, request, { linkRoundTripMs: this.config.network?.linkRoundTripMs });
6668
7443
  }
6669
7444
 
6670
7445
  /**
@@ -6672,7 +7447,7 @@ export class CadreNode implements SAppIdLookup {
6672
7447
  * This is for drone nodes that need to receive seeds without being an owner.
6673
7448
  * Does not require an owner key.
6674
7449
  */
6675
- enableSeedListener(): void {
7450
+ async enableSeedListener(): Promise<void> {
6676
7451
  if (!this.controlNode || !this.controlDatabase) {
6677
7452
  throw new Error('CadreNode must be started before enabling seed listener');
6678
7453
  }
@@ -6683,21 +7458,17 @@ export class CadreNode implements SAppIdLookup {
6683
7458
  return;
6684
7459
  }
6685
7460
 
6686
- this.seedBootstrapService = new SeedBootstrapService({
7461
+ await this.installSeedBootstrapService(new SeedBootstrapService({
6687
7462
  partyId: this.config.controlNetwork.partyId,
6688
7463
  // No owner key - this node only receives seeds
6689
7464
  inviteAddressResolver: () => this.resolveInviteAddresses(),
6690
- dialBudget: this.controlDialBudget(),
7465
+ ...this.seedServiceBudgets(),
6691
7466
  trustPolicy: this.config.seedTrustPolicy,
6692
7467
  // A listener-only node accepts a wire-delivered seed solely against this
6693
7468
  // anchor (there is no per-call override on the inbound handler): with no
6694
7469
  // genesis/invite/operator pin it authorizes nobody, which is the point.
6695
7470
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
6696
- });
6697
-
6698
- this.seedBootstrapService.setEventCallbacks(this.seedEventCallbacks());
6699
-
6700
- this.seedBootstrapService.initialize(this.controlNode, this.controlDatabase);
7471
+ }), this.controlNode, this.controlDatabase);
6701
7472
  log('Seed listener enabled');
6702
7473
  }
6703
7474
 
@@ -6708,6 +7479,33 @@ export class CadreNode implements SAppIdLookup {
6708
7479
  return this.seedBootstrapService;
6709
7480
  }
6710
7481
 
7482
+ /**
7483
+ * Make `service` this node's seed service and register its inbound seed handler.
7484
+ * The field is set before the registration is awaited, so a concurrent
7485
+ * {@link enableSeedListener} finds it and does not register a second handler; a
7486
+ * failed registration puts the previous service back and rethrows.
7487
+ *
7488
+ * NOTE: libp2p's registrar stores the handler before its peer-store merge, so a
7489
+ * failed merge leaves SEED_PROTOCOL registered with no service owning it and a retry
7490
+ * here rejects as a duplicate; if peer-store writes can fail in practice, unhandle on
7491
+ * a non-duplicate failure (never on a duplicate — that handler belongs to someone else).
7492
+ */
7493
+ private async installSeedBootstrapService(
7494
+ service: SeedBootstrapService,
7495
+ controlNode: Libp2p,
7496
+ controlDatabase: ControlDatabase
7497
+ ): Promise<void> {
7498
+ service.setEventCallbacks(this.seedEventCallbacks());
7499
+ const previous = this.seedBootstrapService;
7500
+ this.seedBootstrapService = service;
7501
+ try {
7502
+ await service.initialize(controlNode, controlDatabase);
7503
+ } catch (error) {
7504
+ if (this.seedBootstrapService === service) this.seedBootstrapService = previous;
7505
+ throw error;
7506
+ }
7507
+ }
7508
+
6711
7509
  /**
6712
7510
  * The event callbacks every {@link SeedBootstrapService} this node owns is
6713
7511
  * wired with — shared by the owner-capable service ({@link initializeSeedBootstrap})
@@ -6930,7 +7728,7 @@ export class CadreNode implements SAppIdLookup {
6930
7728
  const tempService = new SeedBootstrapService({
6931
7729
  partyId: seed.partyId,
6932
7730
  trustPolicy: this.config.seedTrustPolicy,
6933
- dialBudget: this.controlDialBudget(),
7731
+ ...this.seedServiceBudgets(),
6934
7732
  // The anchor is node-scoped, not service-scoped: a throwaway service
6935
7733
  // must consult (and persist an accepted signer into) the SAME store the
6936
7734
  // persistent one would, or a cold-start enrollment via this path would
@@ -6938,7 +7736,7 @@ export class CadreNode implements SAppIdLookup {
6938
7736
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
6939
7737
  });
6940
7738
  if (this.controlNode && this.controlDatabase) {
6941
- tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
7739
+ await tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
6942
7740
  }
6943
7741
  const tempResult = await tempService.applySeed(seed, options);
6944
7742
  this.noteAppliedSeed(tempResult, seed);
@@ -7125,10 +7923,10 @@ export class CadreNode implements SAppIdLookup {
7125
7923
  const tempService = new SeedBootstrapService({
7126
7924
  partyId: invite.partyId,
7127
7925
  trustPolicy: this.config.seedTrustPolicy,
7128
- dialBudget: this.controlDialBudget(),
7926
+ ...this.seedServiceBudgets(),
7129
7927
  });
7130
7928
  if (this.controlNode && this.controlDatabase) {
7131
- tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
7929
+ await tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
7132
7930
  }
7133
7931
  await tempService.dialInvite(invite);
7134
7932
  return;
@@ -7146,15 +7944,21 @@ export class CadreNode implements SAppIdLookup {
7146
7944
  *
7147
7945
  * @param options - Configuration for the solicitation service
7148
7946
  */
7149
- initializeStrandSolicitation(options?: StrandSolicitationServiceOptions): void {
7947
+ async initializeStrandSolicitation(options?: StrandSolicitationServiceOptions): Promise<void> {
7150
7948
  if (!this.controlNode) {
7151
7949
  throw new Error('CadreNode must be started before initializing strand solicitation');
7152
7950
  }
7153
7951
 
7154
- this.strandSolicitationService = new StrandSolicitationService({
7952
+ const service = new StrandSolicitationService({
7155
7953
  ...options,
7156
7954
  partyId: this.config.controlNetwork.partyId,
7157
7955
  cadrePeerAddrs: this.getMultiaddrs(),
7956
+ // Every formation deadline derives from this node's declared link, like every other
7957
+ // dial budget here; a caller's own declaration on the formation config still wins.
7958
+ formationConfig: {
7959
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
7960
+ ...options?.formationConfig
7961
+ },
7158
7962
  // Overrides any caller-supplied hook, exactly like partyId/cadrePeerAddrs: only
7159
7963
  // this node can say which strand-network addresses it is actually listening on,
7160
7964
  // and a wrong answer here seeds a joiner's mesh with addresses that reach nobody.
@@ -7162,11 +7966,23 @@ export class CadreNode implements SAppIdLookup {
7162
7966
  // Overridden on the same grounds: only this node holds the running strand
7163
7967
  // instance's database and this party's `StrandPartyKey` identity, and a wrong
7164
7968
  // issuer here would admit joiners under someone else's authority.
7165
- issueMembershipInvite: (strandId: string) => this.issueStrandMembershipInvite(strandId)
7969
+ issueMembershipInvite: (strandId: string, signal?: AbortSignal) =>
7970
+ this.issueStrandMembershipInvite(strandId, signal)
7166
7971
  });
7167
7972
 
7168
- // Register as responder on the control node
7169
- this.strandSolicitationService.registerResponder(this.controlNode);
7973
+ // Set before the registration is awaited, so a concurrent createOpenInvitation /
7974
+ // formStrand uses this service instead of building a second one whose handler
7975
+ // would collide; a failed registration puts the previous service back.
7976
+ // NOTE: same stray-handler caveat as installSeedBootstrapService — a failed
7977
+ // peer-store merge leaves the formation handler registered, so a retry is a duplicate.
7978
+ const previous = this.strandSolicitationService;
7979
+ this.strandSolicitationService = service;
7980
+ try {
7981
+ await service.registerResponder(this.controlNode);
7982
+ } catch (error) {
7983
+ if (this.strandSolicitationService === service) this.strandSolicitationService = previous;
7984
+ throw error;
7985
+ }
7170
7986
  log('Strand solicitation service initialized');
7171
7987
  }
7172
7988
 
@@ -7190,7 +8006,7 @@ export class CadreNode implements SAppIdLookup {
7190
8006
  ): Promise<OpenInvitation> {
7191
8007
  if (!this.strandSolicitationService) {
7192
8008
  // Create a temporary service for creating invitations
7193
- this.initializeStrandSolicitation();
8009
+ await this.initializeStrandSolicitation();
7194
8010
  }
7195
8011
 
7196
8012
  const bootstrap = this.getMultiaddrs();
@@ -7221,7 +8037,7 @@ export class CadreNode implements SAppIdLookup {
7221
8037
  }
7222
8038
 
7223
8039
  if (!this.strandSolicitationService) {
7224
- this.initializeStrandSolicitation();
8040
+ await this.initializeStrandSolicitation();
7225
8041
  }
7226
8042
 
7227
8043
  const result = await this.strandSolicitationService!.formStrand(
@@ -7229,7 +8045,7 @@ export class CadreNode implements SAppIdLookup {
7229
8045
  disclosure,
7230
8046
  this.controlNode
7231
8047
  );
7232
- this.recordCrossPartyStrandAddrs(result.strandId, result.strandAddrs);
8048
+ this.recordFormationStrandPeers(result.strandId, result.strandAddrs);
7233
8049
  // A closed host strand's approval carries the joiner's own membership invitation —
7234
8050
  // persist this party's identity key and stage the invitation for bring-up to redeem.
7235
8051
  // Runs AFTER the addr recording so a persistence failure (which throws — see the
@@ -7237,9 +8053,40 @@ export class CadreNode implements SAppIdLookup {
7237
8053
  if (result.membershipInvite) {
7238
8054
  await this.adoptFormationMembershipInvite(result.strandId, result.membershipInvite);
7239
8055
  }
8056
+ await this.rememberFormedStrand(result);
7240
8057
  return result;
7241
8058
  }
7242
8059
 
8060
+ /**
8061
+ * Remember the strand a formation just joined (see {@link joinedStrandStore}), so it
8062
+ * is re-offered after a restart even when the app is killed before its `addStrand`, and
8063
+ * published party-wide by the next connected owner reconcile pass. A re-join also
8064
+ * cancels a party-wide removal a self-revocation queued for the strand.
8065
+ * Last in {@link formStrand}, after the membership adoption, so a failure here leaves
8066
+ * the party key and staged invitation in place for the re-formation it asks for.
8067
+ *
8068
+ * Throws on failure, like {@link adoptFormationMembershipInvite}: the formation's
8069
+ * token is spent, and a join no store names would vanish at the next restart.
8070
+ */
8071
+ private async rememberFormedStrand(result: FormStrandResult): Promise<void> {
8072
+ try {
8073
+ await this.joinedStrands!.remember({
8074
+ Id: result.strandId,
8075
+ Type: result.memberPrivateKey ? 'c' : 'o',
8076
+ MemberPrivateKey: result.memberPrivateKey ?? null,
8077
+ joinedAt: Date.now()
8078
+ });
8079
+ } catch (error) {
8080
+ throw new Error(
8081
+ `Formation for strand ${result.strandId} was approved (its one-time token is spent), but ` +
8082
+ 'remembering the join in this node\'s joined-strand store failed, so the strand would not ' +
8083
+ 'come back after a restart. Fix the store (usually the configured keyStore), then redeem a ' +
8084
+ 'fresh invitation.',
8085
+ { cause: error }
8086
+ );
8087
+ }
8088
+ }
8089
+
7243
8090
  /**
7244
8091
  * Adopt an approved closed-strand formation's membership half on the JOINER:
7245
8092
  *
@@ -7322,9 +8169,15 @@ export class CadreNode implements SAppIdLookup {
7322
8169
  * founder's publish/launch paths mint it, so this is a not-yet-converged sibling.)
7323
8170
  * The manager maps the throw to a clean retryable rejection BEFORE the formation
7324
8171
  * token is spent.
7325
- * - Closed host strand with no running local instance/database → throw, same mapping:
7326
- * a joiner admitted without an invitation would look joined and never become a
7327
- * member, and a responder not running the strand cannot serve its sync anyway.
8172
+ * - Closed host strand whose runtime is HIBERNATING or being quiesced → woken first
8173
+ * ({@link wakeHostStrandForFormation}, bounded by `signal`), then issued as below. In
8174
+ * every state the redemption counts as activity, so the host stays up for the
8175
+ * joiner's first sync.
8176
+ * - Closed host strand with no running local instance/database (never launched, still
8177
+ * starting, or a hibernating one whose wake failed or outran `signal`) →
8178
+ * throw, same mapping: a joiner admitted without an invitation would look joined and
8179
+ * never become a member, and a responder not running the strand cannot serve its sync
8180
+ * anyway.
7328
8181
  * - Closed host strand whose LIVE rows carry the pre-split fingerprint
7329
8182
  * (`assertNotPreSplitStrand`) → throw `PreSplitStrandIdentityError`, same mapping as
7330
8183
  * the recorded refusal. Covers the responders that never ran a refused founder launch:
@@ -7334,7 +8187,12 @@ export class CadreNode implements SAppIdLookup {
7334
8187
  * permanent diagnosis. Identity is checked BEFORE the runtime: it is the cheaper read
7335
8188
  * and the more actionable diagnosis when both are missing (a missing runtime is
7336
8189
  * transient, a missing identity is not), and it keeps the branch reachable without
7337
- * standing a strand runtime up.
8190
+ * standing a strand runtime up. For the same reason every control-database check runs
8191
+ * before a wake: a strand that cannot issue anyway is not woken.
8192
+ *
8193
+ * `signal` is the formation's provisioning budget. Once it has aborted nothing is
8194
+ * issued — the joiner has already been told to retry, and an invitation written now
8195
+ * would only sit in the strand until it expires.
7338
8196
  *
7339
8197
  * The invitation expires `MEMBERSHIP_INVITE_TTL_MS` from now — see that constant for
7340
8198
  * the slow-joiner / lost-result tradeoff.
@@ -7349,7 +8207,10 @@ export class CadreNode implements SAppIdLookup {
7349
8207
  * `MEMBERSHIP_INVITE_UNAVAILABLE_REASON` forever; that flow needs manager delegation,
7350
8208
  * not a retry.
7351
8209
  */
7352
- private async issueStrandMembershipInvite(strandId: string): Promise<StrandMembershipInvite | null> {
8210
+ private async issueStrandMembershipInvite(
8211
+ strandId: string,
8212
+ signal?: AbortSignal
8213
+ ): Promise<StrandMembershipInvite | null> {
7353
8214
  if (!this.controlDatabase) {
7354
8215
  throw new Error(`Cannot issue a membership invitation for strand ${strandId}: control database unavailable`);
7355
8216
  }
@@ -7373,16 +8234,23 @@ export class CadreNode implements SAppIdLookup {
7373
8234
  'no StrandPartyKey row for it (identity not yet converged from the machine that published it)'
7374
8235
  );
7375
8236
  }
8237
+ await this.wakeHostStrandForFormation(strandId, signal);
7376
8238
  const db = this.strandManager.getInstance(strandId)?.database?.getDatabase();
7377
8239
  if (!db) {
7378
8240
  throw new Error(
7379
8241
  `Cannot issue a membership invitation for closed strand ${strandId}: its runtime is ` +
7380
- 'not live on this responder (not launched, hibernating, or quiescing)'
8242
+ 'not live on this responder (not launched, or still starting)'
7381
8243
  );
7382
8244
  }
7383
8245
  if (row.MemberPrivateKey) {
7384
8246
  await assertNotPreSplitStrand(db, strandId, strandMemberKeyPair(row.MemberPrivateKey).publicKeyB64);
7385
8247
  }
8248
+ if (signal?.aborted) {
8249
+ throw new Error(
8250
+ `Cannot issue a membership invitation for closed strand ${strandId}: the formation ` +
8251
+ 'provisioning budget expired before issuance'
8252
+ );
8253
+ }
7386
8254
  return await issueInvite(db, {
7387
8255
  managerKeyPair: strandMemberKeyPair(partyKey),
7388
8256
  expiration: Date.now() + MEMBERSHIP_INVITE_TTL_MS
@@ -7390,36 +8258,86 @@ export class CadreNode implements SAppIdLookup {
7390
8258
  }
7391
8259
 
7392
8260
  /**
7393
- * Remember the responder's strand-network addresses for a strand this node just
7394
- * formed, so the strand's discovery seed has something to work with when the app
7395
- * launches it (see {@link crossPartyStrandAddrs}).
8261
+ * Count a bound closed-strand redemption as activity on the host strand, and wake the
8262
+ * strand when it is HIBERNATING or being quiesced (its database is closing) so the
8263
+ * membership invitation can be issued. Only reached after the formation manager has
8264
+ * authorized the redemption (token, disclosure, outside approval, seat pre-check), so only
8265
+ * a caller already entitled to the strand's member key can cause a wake. No other state is
8266
+ * woken — never launched or still starting is not something this node recovers from on
8267
+ * demand; the caller's live-database check refuses those.
8268
+ *
8269
+ * The activity is recorded in every state, through the hibernation manager rather than
8270
+ * {@link recordStrandActivity}, whose push fan-out would wake this party's phones for
8271
+ * nothing. It keeps the host up for the joiner's first sync: a live strand's idle timer
8272
+ * restarts, a check-in window that happens to have the strand live sees activity and
8273
+ * leaves it up instead of re-quiescing it, and a hibernating strand has its idle →
8274
+ * hibernate timers re-armed once the wake leaves it `active`. The explicit
8275
+ * {@link wakeStrand} coalesces onto the wake `recordActivity` began, and still wakes when
8276
+ * `recordActivity` is a no-op (hibernation disabled but the strand force-hibernated, or
8277
+ * the manager stopped).
7396
8278
  *
7397
- * Scoped strictly to `strandId`: these addresses reach ONE strand node of ONE other
7398
- * party and must never seed another strand's mesh or the control peerStore. An empty
7399
- * list is recorded as no entry at all rather than an empty one, so a later formation
7400
- * against the same strand that DOES disclose addresses is not shadowed.
8279
+ * Bounded by `signal` (the formation's provisioning budget): when it aborts first this
8280
+ * throws — a retryable rejection, token unspent — and leaves the wake running, so the
8281
+ * joiner's retry finds the strand live.
7401
8282
  */
7402
- private recordCrossPartyStrandAddrs(strandId: string, strandAddrs: readonly string[]): void {
8283
+ private async wakeHostStrandForFormation(strandId: string, signal?: AbortSignal): Promise<void> {
8284
+ const instance = this.strandManager.getInstance(strandId);
8285
+ if (!instance) {
8286
+ return;
8287
+ }
8288
+ this.hibernationManager.recordActivity(instance);
8289
+ if (!this.strandManager.isQuiescing(strandId) && (instance.status !== 'hibernating' || instance.database)) {
8290
+ return;
8291
+ }
8292
+ log('wakeHostStrandForFormation: waking host strand %s (%s) for an authorized formation', strandId, instance.status);
8293
+ const wake = this.wakeStrand(strandId);
8294
+ if (await resolvesBeforeAbort(wake, signal)) {
8295
+ return;
8296
+ }
8297
+ void wake.catch((error: unknown) =>
8298
+ log('wakeHostStrandForFormation: background wake of host strand %s failed: %o', strandId, error));
8299
+ throw new Error(
8300
+ `Cannot issue a membership invitation for closed strand ${strandId}: its hibernating ` +
8301
+ 'runtime did not wake within the formation provisioning budget (still waking)'
8302
+ );
8303
+ }
8304
+
8305
+ /**
8306
+ * Remember the responder's strand-network addresses for a strand this node just
8307
+ * formed in the strand peer book (see {@link strandPeerBookStore}), so the strand's
8308
+ * discovery seed has something to work with when the app launches it — and again
8309
+ * after every restart.
8310
+ *
8311
+ * The carried list is peer-agnostic (`sanitizeStrandAddrs` bounds and parses it,
8312
+ * nothing more), so it is attributed per peer here (`groupAddrsByPeerId`, the same
8313
+ * rule the address-book merge applies) and each peer becomes one unsigned entry seen
8314
+ * just now: the responder disclosed the addresses live a moment ago. An entry naming
8315
+ * no destination peer is dropped there. Scoped strictly to `strandId`: these
8316
+ * addresses reach ONE strand node of ONE other party and must never seed another
8317
+ * strand's mesh or the control peerStore. An empty list records nothing, so a later
8318
+ * formation against the same strand that DOES disclose addresses is not shadowed.
8319
+ */
8320
+ private recordFormationStrandPeers(strandId: string, strandAddrs: readonly string[]): void {
7403
8321
  if (strandAddrs.length === 0) {
7404
8322
  log('formStrand: responder disclosed no strand addrs for %s — cross-party seed stays empty', strandId);
7405
8323
  return;
7406
8324
  }
7407
- // Capped at the same `MAX_STRAND_ADDRS` one arriving list is, because this
7408
- // ACCUMULATES: re-forming against the same strand (the documented recovery for a
7409
- // responder whose relay reservation rotated) unions a fresh list onto the old one,
7410
- // and nothing here can tell a dead entry from a live one. Without the cap, the seed
7411
- // — and the peerStore rows every refresh pass re-merges from it — would grow by up
7412
- // to 16 entries per redemption for the node's lifetime. Oldest entries fall off the
7413
- // end, which is the right end to lose: `unionAddrs` keeps the freshest list first.
7414
- const merged = unionAddrs(strandAddrs, this.crossPartyStrandAddrs.get(strandId) ?? [])
7415
- .slice(0, MAX_STRAND_ADDRS);
7416
- this.crossPartyStrandAddrs.set(strandId, merged);
7417
- // A strand already running when this lands (a re-formation) would otherwise wait out
7418
- // the rest of its ~10-minute refresh throttle before the new addresses reached its
7419
- // address book. Clearing the stamp makes the next 15 s reconcile tick merge them,
7420
- // which matters precisely because re-forming is the recovery path for a dead entry.
7421
- this.strandPeerAddrRefreshAt.delete(strandId);
7422
- log('formStrand: recorded %d cross-party strand addr(s) for %s', merged.length, strandId);
8325
+ const now = Date.now();
8326
+ let peers = 0;
8327
+ for (const [peerId, addrs] of groupAddrsByPeerId([...strandAddrs])) {
8328
+ this.rememberStrandPeer(strandId, {
8329
+ peerId,
8330
+ addrs: addrs.map((ma) => ma.toString()),
8331
+ issuedAt: 0,
8332
+ lastSeenAt: now
8333
+ }, 'formStrand');
8334
+ peers++;
8335
+ }
8336
+ // A strand already running when this lands (a re-formation) needs nothing more: the
8337
+ // refresh pass merges the book into its address book on every reconcile tick, so the
8338
+ // new addresses land within 15 s — which matters because re-forming is the recovery
8339
+ // path for a dead entry.
8340
+ log('formStrand: recorded %d cross-party strand addr(s) for %s under %d peer(s)', strandAddrs.length, strandId, peers);
7423
8341
  }
7424
8342
 
7425
8343
  /**