@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
@@ -1,6 +1,7 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
3
  import { peerIdFromString, peerIdFromPrivateKey } from '@libp2p/peer-id';
4
+ import { generateKeyPair } from '@libp2p/crypto/keys';
4
5
  import { createLibp2pNode } from '@optimystic/db-p2p';
5
6
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
6
7
  import { multiaddr } from '@multiformats/multiaddr';
@@ -10,13 +11,15 @@ import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519Pu
10
11
  import { strandTransportKey } from './strand-transport-key.js';
11
12
  import { assertScopeKeyCharset, assertStrandScopeKey, controlStorageScope, InvalidStrandIdError, isValidStrandScopeKey } from './storage-scope.js';
12
13
  import { generateStrandMemberKey, strandMemberKeyPair } from './strand-member-key.js';
13
- import { assertNotPreSplitStrand, issueInvite, PreSplitStrandIdentityError } from './strand-membership-writer.js';
14
+ import { assertNotPreSplitStrand, issueInvite, PreSplitStrandIdentityError, readStrandHeaderSAppId } from './strand-membership-writer.js';
14
15
  import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
15
16
  import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
16
17
  import { loadOrCreateIdentityKey } from './identity-key.js';
17
18
  import { MemoryTrustedOwnerStore } from './trusted-owner-store.js';
18
19
  import { MemoryBootstrapPeerStore } from './bootstrap-peer-store.js';
20
+ import { MemoryStrandPeerBookStore } from './strand-peer-book.js';
19
21
  import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
22
+ import { JoinedStrandSession, KeyStoreJoinedStrandStore, MemoryJoinedStrandStore } from './joined-strand-store.js';
20
23
  import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
21
24
  import { verifyCadrePeerVoucher } from './peer-authorization.js';
22
25
  import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
@@ -27,19 +30,19 @@ import { StrandInstanceManager, liveStrandStatus } from './strand-instance-manag
27
30
  import { PeerJoinBackfill } from './peer-join-backfill.js';
28
31
  import { deriveCohortMembers } from './strand-cohort.js';
29
32
  import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
30
- import { dialPeerAddrs, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
31
- import { connectionManagerTimeouts, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
33
+ import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
34
+ import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
32
35
  import { EnrollmentService } from './enrollment.js';
33
36
  import { HibernationManager } from './hibernation-manager.js';
34
37
  import { ControlDatabase, isStrandIdConflict } from './control-database.js';
35
38
  import { SeedBootstrapService } from './seed-bootstrap.js';
36
39
  import { StrandSolicitationService } from './strand-solicitation.js';
37
- import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
38
40
  import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS, UnauthorizedReservationBudget } from './membership-connection-gater.js';
39
41
  import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
40
42
  import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
41
- import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, pruneStoppedStrandAnnounces, peerStrandKey } from './delegate-admission.js';
43
+ import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, prunePeerStrandKeys, peerStrandKey } from './delegate-admission.js';
42
44
  import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
45
+ import { resolveRelayServer } from './relay-server.js';
43
46
  import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
44
47
  import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
45
48
  import { PushFanoutService } from './push-fanout.js';
@@ -55,10 +58,10 @@ const timing = debug('sereus:cadre:timing');
55
58
  */
56
59
  const TURN_CONSUME_WINDOW_MS = 1000;
57
60
  /**
58
- * How often a running strand re-resolves its siblings' strand-network addresses
59
- * and re-merges them into its own libp2p address book
60
- * ({@link CadreNode.refreshStrandPeerAddrs}). Overridable per node via
61
- * `network.controlCohort.strandAddrRefreshMs`.
61
+ * How often a running strand re-asks each connected sibling that last ANSWERED
62
+ * (even with nothing) for its strand-network addresses, and re-merges them into
63
+ * its own libp2p address book ({@link CadreNode.refreshStrandPeerAddrs}).
64
+ * Overridable per node via `network.controlCohort.strandAddrRefreshMs`.
62
65
  *
63
66
  * Ten minutes sits comfortably inside the peerStore's one-hour address expiry
64
67
  * (`MAX_ADDRESS_AGE`, see `peer-addr-book.ts`) with headroom for several missed
@@ -66,6 +69,16 @@ const TURN_CONSUME_WINDOW_MS = 1000;
66
69
  * strand-addr RPC fan-out stays cheap.
67
70
  */
68
71
  export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
72
+ /**
73
+ * How soon {@link CadreNode.refreshStrandPeerAddrs} re-asks a sibling that did NOT
74
+ * answer — unreachable, `unavailable`, or `refused`. A refusal matters most: a
75
+ * phone asking before its `CadrePeer` row has replicated to the sibling is refused
76
+ * and its delegate grant goes unrecorded, and waiting the full
77
+ * {@link STRAND_PEER_ADDR_REFRESH_MS} for the next try is the delay this bounds.
78
+ * Four reconcile ticks, so a sibling that keeps failing costs one timed-out RPC a
79
+ * minute rather than one a tick.
80
+ */
81
+ export const STRAND_PEER_ADDR_RETRY_MS = 60 * 1000;
69
82
  /**
70
83
  * A circuit relay as a strand-addr RPC target: dialed by peerId, with its
71
84
  * direct addr riding along as the fallback for a relay we hold no connection to.
@@ -73,12 +86,20 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
73
86
  function relayStrandAddrPeer(relay) {
74
87
  return { peerId: relay.relayPeerId, addrs: [multiaddr(relay.relayAddr)] };
75
88
  }
89
+ /**
90
+ * Whether a sibling actually answered a strand-addr RPC — with addresses or
91
+ * without — as opposed to being unreachable, unavailable, or refusing us. Decides
92
+ * which of the refresh and retry intervals it waits before the next ask.
93
+ */
94
+ function siblingAnswered(outcome) {
95
+ return outcome === 'answered' || outcome === 'empty';
96
+ }
76
97
  /**
77
98
  * `primary` followed by the entries of `extra` it does not already contain, de-duplicated
78
99
  * and order-preserving.
79
100
  *
80
101
  * Used wherever a strand's discovery seed unions two sources of address strings — the
81
- * freshly-RPC'd sibling answers and the cross-party addresses learned at formation. The
102
+ * freshly-RPC'd sibling answers and the strand peer book's last-known addresses. The
82
103
  * caller passes the FRESHER source as `primary`, so a stale entry can only ever be
83
104
  * appended, never promoted ahead of a current one, and each source keeps whatever
84
105
  * signaling-first ordering it arrived with.
@@ -167,6 +188,34 @@ function requireMatchingStrandRow(live, desired, situation) {
167
188
  }
168
189
  return live;
169
190
  }
191
+ /**
192
+ * Await `work` unless `signal` aborts first: `true` once `work` resolves, `false` when the
193
+ * signal aborted before it did (already at the call, or during the wait). A rejection of
194
+ * `work` that lands before the abort propagates. `work` is NOT cancelled — a caller that
195
+ * gets `false` owns its late settlement. The abort listener is detached on both endings, so
196
+ * a long-lived signal does not collect listeners.
197
+ */
198
+ async function resolvesBeforeAbort(work, signal) {
199
+ if (!signal) {
200
+ await work;
201
+ return true;
202
+ }
203
+ if (signal.aborted) {
204
+ return false;
205
+ }
206
+ let detach = () => { };
207
+ const abortedFirst = new Promise((resolve) => {
208
+ const onAbort = () => resolve(false);
209
+ signal.addEventListener('abort', onAbort, { once: true });
210
+ detach = () => signal.removeEventListener('abort', onAbort);
211
+ });
212
+ try {
213
+ return await Promise.race([work.then(() => true), abortedFirst]);
214
+ }
215
+ finally {
216
+ detach();
217
+ }
218
+ }
170
219
  /**
171
220
  * CadreNode is the main entry point for a cadre member.
172
221
  * It manages:
@@ -287,6 +336,19 @@ export class CadreNode {
287
336
  * {@link getDiscoveredStrands}.
288
337
  */
289
338
  this.discoveredStrands = new Map();
339
+ /**
340
+ * The sApp id each storage replica read from its own `Strand.Header` — {@link getSAppId}'s
341
+ * answer for a strand no local config claims, so an `sAppId` strand filter can reject a
342
+ * replica of some other app instead of admitting it provisionally forever.
343
+ *
344
+ * Cleared by {@link cleanup} only, deliberately NOT by {@link detachStrand}: the watcher's
345
+ * filter rejection stops a replica through that very method, and forgetting the id there
346
+ * would have the next poll find it unknown again, re-admit the strand provisionally and
347
+ * relaunch the replica — once every other poll, forever. A strand id's sApp never changes,
348
+ * and the map is bounded by the strands this party has published, like
349
+ * {@link discoveredStrands}.
350
+ */
351
+ this.replicaSAppIds = new Map();
290
352
  /**
291
353
  * Most-recently pushed invite addresses (see {@link setInviteAddresses}).
292
354
  * When non-null these take priority over `libp2pNode.getMultiaddrs()` when
@@ -363,47 +425,49 @@ export class CadreNode {
363
425
  */
364
426
  this.delegateAnnounceAt = new Map();
365
427
  /**
366
- * When each running strand last had its siblings' strand addresses re-resolved
367
- * into its own address book, keyed by strandId. Throttles
368
- * {@link refreshStrandPeerAddrs} to once per {@link STRAND_PEER_ADDR_REFRESH_MS}
369
- * per strand so the 15 s reconcile tick never becomes per-tick RPC chatter.
370
- * Entries for strands that are no longer running are pruned on each pass, so a
371
- * hibernated-then-resumed strand refreshes immediately rather than inheriting a
372
- * stale stamp.
428
+ * When each connected sibling is next due a strand-addr RPC for each running
429
+ * strand, keyed `peerStrandKey(siblingControlPeerId, strandId)` → epoch ms; a
430
+ * missing key is due. Bounds {@link refreshStrandPeerAddrs}'s fan-out per
431
+ * (sibling, strand) rather than per strand, so a sibling that connects after a
432
+ * pass is asked on the next 15 s tick instead of waiting out a stamp another
433
+ * sibling's answer set. An answer (even an empty one) makes the sibling due again
434
+ * in {@link STRAND_PEER_ADDR_REFRESH_MS}; no answer, in
435
+ * {@link STRAND_PEER_ADDR_RETRY_MS}. Keys whose strand stopped running or whose
436
+ * sibling is no longer connected are pruned every pass, so a resumed strand and a
437
+ * reconnected sibling are both asked at once.
373
438
  */
374
- this.strandPeerAddrRefreshAt = new Map();
439
+ this.strandAddrAskDueAt = new Map();
375
440
  /**
376
- * CROSS-PARTY strand-network addresses learned at formation, keyed by strandId — the
377
- * responder's live strand addrs, carried back on the formation result
378
- * (`FormationResultMessage.strandAddrs`) and recorded by {@link formStrand}.
441
+ * Node-local strand peer book (see `strand-peer-book.ts`): per strand, the strand
442
+ * peers this node has met and their last-known strand-network addresses. Three
443
+ * writers: {@link recordFormationStrandPeers} files the responder's live strand
444
+ * addrs a formation result carried back (`FormationResultMessage.strandAddrs`),
445
+ * {@link observeStrandPeer} files every strand peer a running strand node
446
+ * identifies, and the per-strand book swap (`strand-peer-book-swap.ts`, handed the
447
+ * store as `StartStrandConfig.strandPeerBook`) files the node's own signed entry
448
+ * plus every signed entry a strand peer sends it — the only writer whose entries
449
+ * carry a signature, and the only one that can refresh a peer's addresses without
450
+ * meeting that peer. Read by {@link resolveCohortSeed} (launch + hibernation resume) and
451
+ * re-merged by every {@link refreshStrandPeerAddrs} pass, which is what keeps the
452
+ * entries alive past the peerStore's one-hour address expiry.
379
453
  *
380
454
  * This is the ONLY cross-party discovery input there is. The strand-addr RPC that
381
455
  * resolves a strand's addresses is membership-gated and answers own-party siblings
382
- * only, so without this map a joiner's cohort seed for a two-party strand is empty and
383
- * the mesh never forms.
384
- *
385
- * Read by {@link resolveCohortSeed} (launch + hibernation resume) and re-merged by
386
- * every {@link refreshStrandPeerAddrs} pass — the re-merge is what keeps the entries
387
- * alive past the peerStore's one-hour address expiry, since nothing can re-resolve
388
- * them.
456
+ * only, so without the book a joiner's cohort seed for a two-party strand is empty
457
+ * and the mesh never forms — and, with an in-memory book, never RE-forms after a
458
+ * restart (gotchoices/sereus#18). A STORE rather than a map so an embedder can make
459
+ * it outlive the process; constructed (or adopted from `config.strandPeers.store`)
460
+ * by {@link initializeStrandPeerBookStore} during {@link start}, deliberately NOT
461
+ * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same node
462
+ * instance — same lifecycle as {@link bootstrapPeerStore}.
389
463
  *
390
- * IN-MEMORY and ONE-SHOT, deliberately (see `docs/strands.md`): the addresses die with
391
- * the process, and a restarted initiator with no sibling running the strand is back to
392
- * an empty seed until the durability work lands
393
- * (`backlog/feat-cross-party-strand-addr-durability`). They are also never refreshed,
394
- * so a responder whose relay reservation rotates before the initiator dials leaves a
395
- * dead entry behind; recovery today is a fresh invitation.
396
- *
397
- * NOTE: entries are kept for the node's lifetime, including across a
398
- * {@link stopStrand} — a stopped strand may be claimed again with {@link addStrand}
399
- * (the watcher itself will not re-offer it: the stop suppresses the id for the rest of
400
- * the session) and must still get its seed. Each strand's list is capped at
401
- * `MAX_STRAND_ADDRS`, but the
402
- * number of KEYS is bounded only by the strands this node has ever formed as an
403
- * initiator (not by time); if a node ever forms strands at scale, evict on
404
- * `unpublishStrand` or cap the map.
464
+ * Entries survive a {@link stopStrand} (a stopped strand may be claimed again with
465
+ * {@link addStrand}, and is rediscovered on restart) and are forgotten by
466
+ * {@link unpublishStrand}, {@link forgetJoinedStrand} and self-revocation; the store
467
+ * ages the rest out after 14 days and caps each strand at 16 peers, so the book is
468
+ * bounded by time and by the strands this node runs rather than by its history.
405
469
  */
406
- this.crossPartyStrandAddrs = new Map();
470
+ this.strandPeerBookStore = null;
407
471
  /**
408
472
  * PENDING strand membership invitations learned at formation, keyed by strandId — the
409
473
  * single-use `Strand.Invite` credential a closed-strand formation result carried back
@@ -411,10 +475,9 @@ export class CadreNode {
411
475
  * to redeem (`consumeInvite` seats the `Strand.Member` row under this party's own key
412
476
  * — the `strand-node-binds-member-peer` half of the party-identity chain).
413
477
  *
414
- * IN-MEMORY, deliberately, like {@link crossPartyStrandAddrs}: a restarted joiner
415
- * re-forms from scratch anyway (durability is
416
- * `backlog/feat-cross-party-strand-addr-durability`'s problem), and re-forming issues
417
- * a fresh invitation. A re-formation against the same strand REPLACES the entry — the
478
+ * IN-MEMORY, deliberately: an invitation is single-use and short-lived, a restarted
479
+ * joiner that never redeemed it re-forms from scratch, and re-forming issues a fresh
480
+ * one. A re-formation against the same strand REPLACES the entry — the
418
481
  * fresh invitation supersedes one that may have expired. The party key the invitation
419
482
  * admits, by contrast, IS persisted (`StrandPartyKey`, seated by
420
483
  * {@link adoptFormationMembershipInvite} before the entry lands here), so a lost
@@ -430,8 +493,9 @@ export class CadreNode {
430
493
  * just waits; once launched, staging notifies the manager, which re-arms a finished loop.
431
494
  *
432
495
  * NOTE: entries live for the node's lifetime (one small pair per formed closed
433
- * strand), the same unbounded-keys tripwire {@link crossPartyStrandAddrs} documents —
434
- * if a node ever forms strands at scale, evict alongside that map.
496
+ * strand); the number of keys is bounded only by the strands this node has ever
497
+ * formed, not by time — if a node ever forms strands at scale, evict on
498
+ * `unpublishStrand` / `forgetJoinedStrand` as the strand peer book does.
435
499
  */
436
500
  this.pendingMembershipInvites = new Map();
437
501
  /**
@@ -502,6 +566,23 @@ export class CadreNode {
502
566
  * so it survives a stop()→start() cycle of the same node instance.
503
567
  */
504
568
  this.enrolledMachineStore = null;
569
+ /**
570
+ * Node-local record of the strands this node joined from ANOTHER party and has not yet
571
+ * published party-wide (see `joined-strand-store.ts`). Written by {@link formStrand}
572
+ * and by an {@link addStrand} of a foreign row; drained once the party-wide
573
+ * `JoinedStrand` row is visible. Adopted from `config.joinedStrands.store`, else built over
574
+ * `config.keyStore`, else in-memory, by {@link initializeJoinedStrandStore}. Like
575
+ * {@link bootstrapPeerStore} it is deliberately NOT cleared by {@link cleanup}.
576
+ */
577
+ this.joinedStrandStore = null;
578
+ /**
579
+ * This session's joined strands: {@link joinedStrandStore} and the party-wide
580
+ * `JoinedStrand` table, unioned with the control rows each watcher poll, published and
581
+ * removed by the reconcile pass. Per SESSION, unlike the store — rebuilt at every
582
+ * {@link start}, which is what ends a revoked join's "keep offering until this session
583
+ * ends" (see `JoinedStrandSession.forgetAfterThisSession`).
584
+ */
585
+ this.joinedStrands = null;
505
586
  /** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
506
587
  this.selfRegistrationTimer = null;
507
588
  /** TTL heartbeat that re-publishes the self record before it goes stale. */
@@ -626,24 +707,28 @@ export class CadreNode {
626
707
  /** `connection:close` listener clearing a peer's TURN flag (teardown in {@link stopRecordRefresh}). */
627
708
  this.turnConnectionCloseHandler = null;
628
709
  this.config = config;
710
+ this.hostUnclaimedStrands = config.hostUnclaimedStrands ?? config.profile === 'storage';
629
711
  this.strandManager = new StrandInstanceManager();
630
712
  this.enrollmentService = new EnrollmentService();
631
- this.unauthorizedRelayReservations = new UnauthorizedReservationBudget(config.network?.unauthorizedRelayReservationCap);
713
+ this.relayServer = resolveRelayServer(config.network, config.profile);
714
+ this.unauthorizedRelayReservations = new UnauthorizedReservationBudget(config.network?.unauthorizedRelayReservationCap, this.relayServer.init.reservations.reservationTtl);
632
715
  // Create hibernation manager with callbacks
633
716
  const hibernationCallbacks = {
634
717
  onIdle: async (strandId) => this.handleStrandIdle(strandId),
635
718
  onHibernate: async (strandId) => this.handleStrandHibernate(strandId),
636
719
  onWake: async (strandId) => this.handleStrandWake(strandId),
637
- onCheckIn: async (strandId) => this.handleStrandCheckIn(strandId)
720
+ onCheckIn: async (strandId) => this.handleStrandCheckIn(strandId),
721
+ isQuiescing: (strandId) => this.strandManager.isQuiescing(strandId)
638
722
  };
639
723
  this.hibernationManager = new HibernationManager(config.hibernation ?? { enabled: false }, hibernationCallbacks);
640
724
  log('CadreNode created for party: %s', config.controlNetwork.partyId);
641
725
  }
642
726
  /**
643
- * SAppIdLookup implementation - get sAppId for a strand
727
+ * SAppIdLookup implementation - get sAppId for a strand: the claiming config's, else
728
+ * the one a storage replica read from the strand's own `Strand.Header`.
644
729
  */
645
730
  getSAppId(strandId) {
646
- return this.sAppConfigs.get(strandId)?.id;
731
+ return this.sAppConfigs.get(strandId)?.id ?? this.replicaSAppIds.get(strandId);
647
732
  }
648
733
  /**
649
734
  * Get the peer ID of this node (available after start)
@@ -759,6 +844,7 @@ export class CadreNode {
759
844
  return;
760
845
  }
761
846
  log('Starting CadreNode for party: %s', this.config.controlNetwork.partyId);
847
+ this.logRelayServerSettings();
762
848
  try {
763
849
  const tTotal = performance.now();
764
850
  // Config pre-flight, inside the guarded region: resolving the listen addrs it reads
@@ -788,6 +874,12 @@ export class CadreNode {
788
874
  // closed before any network bring-up, and the retained dial targets are
789
875
  // loaded before the first reconcile pass could consult them.
790
876
  this.initializeBootstrapPeerStore();
877
+ // The remembered cross-party joins, which the strand watcher built below polls
878
+ // beside the control rows. Before network bring-up, so a mis-scoped injected store
879
+ // fails closed like the two above.
880
+ this.initializeJoinedStrandStore();
881
+ // The strand peer book those joins are dialed from. Same placement, same reason.
882
+ this.initializeStrandPeerBookStore();
791
883
  // Read the party's enrolled-machine count out of its node-local record and
792
884
  // capture it for buildControlNodeOptions below. This MUST precede
793
885
  // createControlNode: Optimystic freezes the cluster policy when the node is
@@ -883,7 +975,7 @@ export class CadreNode {
883
975
  getStrand: (strandId) => this.strandManager.getInstance(strandId),
884
976
  wake: (strandId) => this.wakeStrand(strandId),
885
977
  });
886
- this.strandWakeService.initialize(this.controlNode);
978
+ await this.strandWakeService.initialize(this.controlNode);
887
979
  // Register the control-network strand-address responder: a same-cadre peer
888
980
  // resolving a strand's bootstrap seed asks us for our live strand-network
889
981
  // multiaddrs (its CadrePeer row only knows our *control* address). Gated on
@@ -897,7 +989,7 @@ export class CadreNode {
897
989
  getStrandMultiaddrs: (strandId) => this.getStrandMultiaddrs(strandId),
898
990
  onDelegateAnnounce: (announcer, strandId, delegate) => this.grantDelegateAdmission(announcer, strandId, delegate),
899
991
  });
900
- this.strandAddrService.initialize(this.controlNode);
992
+ await this.strandAddrService.initialize(this.controlNode);
901
993
  // Server-side push-wake fan-out: only when push is configured.
902
994
  if (this.config.push) {
903
995
  this.pushFanoutService = this.buildPushFanout(this.config.push);
@@ -1135,6 +1227,48 @@ export class CadreNode {
1135
1227
  this.declaredEnrolledMachines = store.count();
1136
1228
  log('control repair yardstick will be declared from %o enrolled machine(s)', this.declaredEnrolledMachines);
1137
1229
  }
1230
+ /**
1231
+ * Construct (or adopt) the node-local joined-strand store (see
1232
+ * {@link joinedStrandStore}) and this session's {@link joinedStrands} over it.
1233
+ *
1234
+ * The store instance is kept across stop()→start(); the view is rebuilt. A store
1235
+ * scoped to a different party is a configuration error, fail closed: its joins
1236
+ * would be offered to this party's app as if this party had made them.
1237
+ */
1238
+ initializeJoinedStrandStore() {
1239
+ const partyId = this.config.controlNetwork.partyId;
1240
+ const { keyStore } = this.config;
1241
+ const store = this.joinedStrandStore
1242
+ ?? this.config.joinedStrands?.store
1243
+ ?? (keyStore ? new KeyStoreJoinedStrandStore(keyStore, partyId) : new MemoryJoinedStrandStore(partyId));
1244
+ if (store.partyId !== partyId) {
1245
+ throw new Error(`CadreNodeConfig: joinedStrands.store is scoped to party ${store.partyId}, ` +
1246
+ `but this node serves party ${partyId} — refusing to offer another party's joined strands`);
1247
+ }
1248
+ this.joinedStrandStore = store;
1249
+ this.joinedStrands = new JoinedStrandSession(store, this.createPartyJoinedStrandLedger());
1250
+ }
1251
+ /**
1252
+ * Construct (or adopt) the node-local strand peer book (see
1253
+ * {@link strandPeerBookStore}). Synchronous for the reason
1254
+ * {@link initializeBootstrapPeerStore} is: an injected store has already loaded its
1255
+ * persisted entries by the time it is handed in, and the in-memory fallback has
1256
+ * nothing to load. The instance is kept across stop()→start(). A store scoped to a
1257
+ * different party is a configuration error, fail closed: another party's strand
1258
+ * peers must never enter this node's dial loop.
1259
+ */
1260
+ initializeStrandPeerBookStore() {
1261
+ const partyId = this.config.controlNetwork.partyId;
1262
+ if (this.strandPeerBookStore) {
1263
+ return;
1264
+ }
1265
+ const store = this.config.strandPeers?.store ?? new MemoryStrandPeerBookStore(partyId);
1266
+ if (store.partyId !== partyId) {
1267
+ throw new Error(`CadreNodeConfig: strandPeers.store is scoped to party ${store.partyId}, ` +
1268
+ `but this node serves party ${partyId} — refusing to mix strand peer books`);
1269
+ }
1270
+ this.strandPeerBookStore = store;
1271
+ }
1138
1272
  /**
1139
1273
  * The node-local enrolled-machine store (null before {@link start}) — what this
1140
1274
  * node last knew about its party's size. Exposed for diagnostics and for a host
@@ -1152,6 +1286,15 @@ export class CadreNode {
1152
1286
  getBootstrapPeerStore() {
1153
1287
  return this.bootstrapPeerStore;
1154
1288
  }
1289
+ /**
1290
+ * The node-local strand peer book (null before {@link start}) — per strand, the
1291
+ * strand peers this node dials first on launch, resume and refresh. Exposed for
1292
+ * diagnostics and for a host that wants to show "whom would this strand re-find
1293
+ * after a restart?".
1294
+ */
1295
+ getStrandPeerBookStore() {
1296
+ return this.strandPeerBookStore;
1297
+ }
1155
1298
  /**
1156
1299
  * The node-local trusted-owner anchor (null before {@link start}). This is
1157
1300
  * the set the authorized-membership predicate and seed-trust anchor consult —
@@ -1225,10 +1368,10 @@ export class CadreNode {
1225
1368
  * needs no work of its own: `createLibp2pNode` registers the block-transfer
1226
1369
  * handler on every node it builds, control node included, and this node's
1227
1370
  * per-stream gate (`authorizeInboundControlStream`) covers the inbound
1228
- * direction. A denied peer is retried on its next `connection:open`, and —
1229
- * because the production join order is connect-then-authorize — on every
1230
- * committed membership change via {@link refreshAuthorizedControlPeers}'s
1231
- * `scheduleConnectedPeers()` call.
1371
+ * direction. A denied peer is retried on its next `peer:identify` (a
1372
+ * reconnect re-runs identify), and — because the production join order is
1373
+ * connect-then-authorize — on every committed membership change via
1374
+ * {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
1232
1375
  *
1233
1376
  * No-ops (logged) when the embedder configured no control storage or the
1234
1377
  * node exposes no key network — the backfill would have nothing to read or
@@ -1316,7 +1459,7 @@ export class CadreNode {
1316
1459
  const scope = controlStorageScope(this.config.controlNetwork.partyId);
1317
1460
  // The second seam that hands a scope key to an embedder's provider; the first
1318
1461
  // (`StrandInstanceManager.startStrand`) asserts the stricter strand rule. Holds by
1319
- // construction today — base64url is inside the charset — so this guards a future
1462
+ // construction today — lowercase hex is inside the charset — so this guards a future
1320
1463
  // edit to `controlStorageScope`, not a reachable input.
1321
1464
  assertScopeKeyCharset(scope);
1322
1465
  const resolved = typeof provider === 'function' ? provider(scope) : provider;
@@ -1383,7 +1526,6 @@ export class CadreNode {
1383
1526
  // nothing); anything outside {tcp, ws/wss, p2p-circuit} throws here rather than
1384
1527
  // being silently dropped at bring-up. See `relay-addrs.ts`.
1385
1528
  const transportOptions = resolveTransportOptions(network, listenAddrs);
1386
- const enableRelay = this.relayServerEnabled();
1387
1529
  // The control node's own storage, resolved once per runtime (see
1388
1530
  // {@link resolveControlStorage}) rather than per call.
1389
1531
  const controlStorageProvider = this.resolveControlStorage();
@@ -1393,7 +1535,10 @@ export class CadreNode {
1393
1535
  networkName: this.controlNetworkName(),
1394
1536
  storage: controlStorageProvider,
1395
1537
  fretProfile: profile === 'storage' ? 'core' : 'edge',
1396
- relay: enableRelay,
1538
+ relay: this.relayServer.enabled,
1539
+ // Merged party-run defaults: no per-connection data/duration cap, a store sized for
1540
+ // one party's NAT'd machines. Strand nodes get the same init (`relay-server.ts`).
1541
+ ...(this.relayServer.enabled && { relayServerInit: this.relayServer.init }),
1397
1542
  // Fixed, and deliberately above any party's node count: every member reads the
1398
1543
  // whole control database, so a cohort that excludes a member leaves it dependent
1399
1544
  // on read repair — which cannot converge at a two-member cohort. Not a knob;
@@ -1409,12 +1554,14 @@ export class CadreNode {
1409
1554
  // itself, so the unknown case is provably byte-for-byte the old behaviour.
1410
1555
  // The yardstick moves ALONE: `assumedClusterSize` stays pinned at 2, because a
1411
1556
  // party of phones cannot promise three quarters of its machines are awake.
1412
- // With neither declared this is still the frozen constant by identity, which is the
1413
- // production path. A degenerate deadline is Optimystic's to refuse, in
1414
- // createControlNode below.
1557
+ // The read deadline is the host's own, else two link round trips at the declared
1558
+ // `linkRoundTripMs`, else nothing — the frozen constant already carries the same
1559
+ // derivation at the default declaration (`link-budget.ts`). With neither declared this
1560
+ // is still the frozen constant by identity, which is the production path. A degenerate
1561
+ // deadline is Optimystic's to refuse, in createControlNode below.
1415
1562
  clusterPolicy: controlClusterPolicy({
1416
1563
  enrolledMachines: this.declaredEnrolledMachines,
1417
- cohortQueryTimeoutMs: network?.cohortQueryTimeoutMs
1564
+ cohortQueryTimeoutMs: declaredCohortReadDeadlineMs(network)
1418
1565
  }),
1419
1566
  arachnode: { enableRingZulu: profile === 'storage' },
1420
1567
  ...(identityKey && { privateKey: identityKey }),
@@ -1425,10 +1572,12 @@ export class CadreNode {
1425
1572
  // whose event loop is saturated by pure-JS Noise crypto. A configured value
1426
1573
  // replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
1427
1574
  connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
1428
- // libp2p's own dial and inbound-upgrade limits, from the same declared link as every
1429
- // cadre dial budget. Left to db-p2p, both stay at 10 s, which cannot open a relayed
1430
- // connection above a 2.5 s round trip — and the listener's side of that fails silently.
1431
- connectionManager: connectionManagerTimeouts(network?.linkRoundTripMs),
1575
+ // The declared link every cadre budget is derived from, stated to Optimystic too so it
1576
+ // derives its own deadlines from the same number: its request dials and responses, its
1577
+ // block pushes, and libp2p's `dialTimeout` and `inboundUpgradeTimeout`. Always stated,
1578
+ // default included: undeclared, Optimystic keeps LAN deadlines that cannot open a
1579
+ // relayed connection at the link sereus supports (`link-budget.ts`).
1580
+ linkRoundTripMs: resolveLinkRoundTripMs(network?.linkRoundTripMs),
1432
1581
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1433
1582
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1434
1583
  // then. Spread NEXT to `transports` because the two answer the same question.
@@ -1451,7 +1600,7 @@ export class CadreNode {
1451
1600
  admitInbound: (remotePeerId) => this.admitInboundControlConnection(remotePeerId),
1452
1601
  admitRelayReservation: (remotePeerId) => this.admitControlRelayReservation(remotePeerId),
1453
1602
  bringUpInFlight: () => this.controlBringUpInFlight
1454
- }, network?.connectionGater),
1603
+ }, network?.connectionGater, ADMISSION_DECISION_TIMEOUT_MS, relayAdmissionReserveDeadlineMs(network?.linkRoundTripMs)),
1455
1604
  // Fail-closed per-stream authorization for the four Optimystic control-DB
1456
1605
  // protocols — the members-only layer the connection gater's stranger
1457
1606
  // carve-outs cannot express (see authorizeInboundControlStream). Control
@@ -1461,15 +1610,17 @@ export class CadreNode {
1461
1610
  return nodeOptions;
1462
1611
  }
1463
1612
  /**
1464
- * Does this node's CONTROL libp2p run the circuit-relay server? An explicit
1465
- * `network.enableRelay` wins; otherwise storage-profile nodes default to on
1466
- * (better connectivity/uptime). Shared by {@link buildControlNodeOptions}
1467
- * (which configures the server from it) and
1468
- * {@link admitInboundControlConnection} (whose deny/admit-for-relay branch
1469
- * must agree with whether a reservation is even servable here).
1613
+ * One debug line naming what this machine's relay server forwards and holds —
1614
+ * the same fields the dedicated relay container prints at boot
1615
+ * (`ops/docker/libp2p-infra/src/main.ts`). Once per start, not per strand:
1616
+ * strand nodes resolve the same settings from the same config.
1470
1617
  */
1471
- relayServerEnabled() {
1472
- return this.config.network?.enableRelay ?? (this.config.profile === 'storage');
1618
+ logRelayServerSettings() {
1619
+ if (!this.relayServer.enabled) {
1620
+ return;
1621
+ }
1622
+ const { applyDefaultLimit, maxReservations, reservationTtl } = this.relayServer.init.reservations;
1623
+ log('Relay server on: applyDefaultLimit=%s maxReservations=%d reservationTtl=%dms unauthorizedCap=%d', applyDefaultLimit, maxReservations, reservationTtl, this.unauthorizedRelayReservations.cap);
1473
1624
  }
1474
1625
  /**
1475
1626
  * Decide whether an inbound CONTROL-network connection from `remotePeerId`
@@ -1518,7 +1669,7 @@ export class CadreNode {
1518
1669
  * accepted it, exactly like the unreplicated-membership-row case below.
1519
1670
  *
1520
1671
  * When every check falls through, the verdict depends on whether this node
1521
- * runs the circuit-relay server ({@link relayServerEnabled}): without one,
1672
+ * runs the circuit-relay server ({@link relayServer}): without one,
1522
1673
  * `'deny'`; with one, `'admit-for-relay'` — a circuit-relay reservation is
1523
1674
  * established by the reserving peer DIALING the relay, so a connection deny
1524
1675
  * here kills the reservation, and that deny is NOT self-healing: an outbound
@@ -1568,7 +1719,7 @@ export class CadreNode {
1568
1719
  log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
1569
1720
  return 'admit';
1570
1721
  }
1571
- if (this.relayServerEnabled()) {
1722
+ if (this.relayServer.enabled) {
1572
1723
  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);
1573
1724
  return 'admit-for-relay';
1574
1725
  }
@@ -1772,7 +1923,9 @@ export class CadreNode {
1772
1923
  // the count of machines serving the network — the quantity
1773
1924
  // `resolveRepairYardstick` asks for. It is NOT that quantity for a strand, which
1774
1925
  // launches only on machines whose embedder registered its sApp config (see
1775
- // {@link addStrand}), so this number must never be routed to a strand node:
1926
+ // {@link addStrand}) plus storage-profile machines hosting it as a storage replica
1927
+ // ({@link CadreNodeConfig.hostUnclaimedStrands}) whose filter admits it — phones
1928
+ // serve only what their app claims — so this number must never be routed to a strand node:
1776
1929
  // over-declaring pins Optimystic's repair corroboration floor at two peers and a
1777
1930
  // strand that can field only one can then never repair. `launchStrand` therefore
1778
1931
  // declares nothing; see the NOTE there.
@@ -1823,7 +1976,7 @@ export class CadreNode {
1823
1976
  // ever holds many such connections, or refreshes get frequent, skip
1824
1977
  // re-arming peers whose last pass was denied under the SAME membership
1825
1978
  // snapshot instead of re-arming unconditionally.
1826
- this.controlBackfill?.scheduleConnectedPeers();
1979
+ void this.controlBackfill?.scheduleConnectedPeers();
1827
1980
  }
1828
1981
  catch (error) {
1829
1982
  log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
@@ -1859,6 +2012,11 @@ export class CadreNode {
1859
2012
  }
1860
2013
  return this.bootstrapPeerIds;
1861
2014
  }
2015
+ /**
2016
+ * What the strand watcher polls: this party's control rows plus a row per cross-party
2017
+ * join (see {@link joinedStrands}), so a joined strand is offered and relaunched on
2018
+ * the same path as the party's own.
2019
+ */
1862
2020
  createStrandQueryable() {
1863
2021
  return {
1864
2022
  queryStrands: async () => {
@@ -1867,10 +2025,74 @@ export class CadreNode {
1867
2025
  return [];
1868
2026
  }
1869
2027
  log('Querying strands from control database');
1870
- return await this.controlDatabase.queryStrands();
2028
+ const control = await this.controlDatabase.queryStrands();
2029
+ return this.joinedStrands ? await this.joinedStrands.withControlRows(control) : control;
1871
2030
  }
1872
2031
  };
1873
2032
  }
2033
+ /**
2034
+ * The party-wide `JoinedStrand` table as {@link joinedStrands} reads and writes it.
2035
+ * Reads {@link controlDatabase} at call time, so start-up order does not matter.
2036
+ */
2037
+ createPartyJoinedStrandLedger() {
2038
+ const database = () => {
2039
+ if (!this.controlDatabase) {
2040
+ throw new Error('CadreNode must be started before using the party-wide joined-strand table');
2041
+ }
2042
+ return this.controlDatabase;
2043
+ };
2044
+ return {
2045
+ // NOTE: one more control read per watcher poll (5 s), and for a party that never
2046
+ // joined anything it reads a never-written block, which consults the cohort. If poll
2047
+ // cost shows up in a device profile, cache the list and re-read it on the reconcile
2048
+ // cadence.
2049
+ list: () => database().queryJoinedStrands(),
2050
+ names: async (strandId) => (await database().queryStrand(strandId)) !== null
2051
+ || (await database().queryJoinedStrand(strandId)) !== null,
2052
+ canSign: async () => (await this.enrolledOwnerSigningKey()) !== null,
2053
+ publish: async (record) => {
2054
+ const signingKey = await this.enrolledOwnerSigningKey();
2055
+ if (!signingKey) {
2056
+ throw new Error(`Cannot publish joined strand ${record.Id}: this machine is not an enrolled owner`);
2057
+ }
2058
+ try {
2059
+ await database().insertJoinedStrand(record, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
2060
+ }
2061
+ catch (error) {
2062
+ // Another machine of the party published this join first; its row wins.
2063
+ if (!isStrandIdConflict(error, 'JoinedStrand')) {
2064
+ throw error;
2065
+ }
2066
+ }
2067
+ },
2068
+ remove: async (strandId) => {
2069
+ const signingKey = await this.enrolledOwnerSigningKey();
2070
+ if (signingKey) {
2071
+ return database().deleteJoinedStrand(strandId, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
2072
+ }
2073
+ if (await database().queryJoinedStrand(strandId)) {
2074
+ throw new Error(`Cannot leave strand ${strandId} for the whole party: the join is recorded party-wide, and ` +
2075
+ `removing it takes an owner machine, which this one is not. stopStrand('${strandId}') stops ` +
2076
+ 'it on this machine only.');
2077
+ }
2078
+ return false;
2079
+ }
2080
+ };
2081
+ }
2082
+ /**
2083
+ * This machine's owner signing key when it can make owner-gated control writes: its seed
2084
+ * bootstrap holds an owner key (`SeedBootstrapService.canAuthorize`) and the party's
2085
+ * `OwnerKey` table enrolls it. A phone that ran self-genesis after another machine
2086
+ * founded the party passes the first test and not the second (`runOwnerGenesis` in the
2087
+ * reference apps), and a write it signed would only be refused.
2088
+ */
2089
+ async enrolledOwnerSigningKey() {
2090
+ const signingKey = this.seedBootstrapService?.canAuthorize() ? this.getSelfSigningKey() : null;
2091
+ if (!signingKey || !this.controlDatabase) {
2092
+ return null;
2093
+ }
2094
+ return (await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64) ? signingKey : null;
2095
+ }
1874
2096
  /**
1875
2097
  * Schedule the node's initial self-record publish + ongoing refresh shortly
1876
2098
  * after start (non-blocking). {@link registerSelf} is idempotent and safely
@@ -2488,14 +2710,18 @@ export class CadreNode {
2488
2710
  // Ride this pass's cadence to refresh the per-stream gate's materialized
2489
2711
  // authorized set — membership changes that ARRIVED BY REPLICATION (rather
2490
2712
  // than a local write) are picked up here, bounding the snapshot's staleness
2491
- // to the reconcile interval.
2713
+ // to the time between pass starts: the reconcile interval, or the previous
2714
+ // pass's length when that is longer (passes are single-flight, and one
2715
+ // unreachable sibling's dial alone can take `controlDialBudget().totalMs`).
2492
2716
  // NOTE: this refresh and the sibling enumeration below each run their own
2493
2717
  // CadrePeer query (two reads per pass), plus a third from
2494
- // `refreshStrandPeerAddrs` on the passes where a strand is due AND this node
2495
- // holds a control connection (one read for the whole pass, not one per
2496
- // strand). Each also reads Revocation first. Before the Revocation ledger
2497
- // marker exists (filed below, once connected) that block is missing, so every
2498
- // one of those reads consults the cohort about it; once the marker exists every
2718
+ // `refreshStrandPeerAddrs` on every pass where a strand is running AND this
2719
+ // node holds a control connection (one read for the whole pass, not one per
2720
+ // strand; it runs even when no sibling is due, because pruning departed
2721
+ // siblings needs the current target set). Each also reads Revocation first.
2722
+ // Before the Revocation ledger marker exists (filed below, once connected)
2723
+ // that block is missing, so every one of those reads consults the cohort
2724
+ // about it; once the marker exists every
2499
2725
  // block they touch is held and none of them does (both states pinned in
2500
2726
  // control-founding-consult-budget.spec.ts). If those reads ever get costly,
2501
2727
  // share one row-set across all three.
@@ -2511,8 +2737,8 @@ export class CadreNode {
2511
2737
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2512
2738
  return { dialed: [] };
2513
2739
  }
2514
- // Then re-warm each running strand's own address book from its siblings, on
2515
- // its own (much longer) throttle. Same reasoning as warmSiblingAddrBook
2740
+ // Then re-warm each running strand's own address book from its peer book and
2741
+ // from whichever siblings are due an ask. Same reasoning as warmSiblingAddrBook
2516
2742
  // below, one layer down: replication runs on the STRAND network, and every
2517
2743
  // layer under cadre-core dials strand peers by bare peer id.
2518
2744
  await this.refreshStrandPeerAddrs();
@@ -2581,6 +2807,19 @@ export class CadreNode {
2581
2807
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2582
2808
  return { dialed: [] };
2583
2809
  }
2810
+ // Publish this machine's unpublished cross-party joins party-wide and remove the rows a
2811
+ // self-revocation queued (JoinedStrandSession.syncWithParty). Connected-only for the
2812
+ // reap's reason above; owner-only by the session's own signer check. Nothing is urgent:
2813
+ // a join reaches the party within one pass of this machine being connected.
2814
+ try {
2815
+ await this.joinedStrands?.syncWithParty();
2816
+ }
2817
+ catch (error) {
2818
+ log('reconcileControlCohort: joined-strand sync failed (retrying next pass): %o', error);
2819
+ }
2820
+ if (!this._running || !this.controlNode || !this.controlDatabase) {
2821
+ return { dialed: [] };
2822
+ }
2584
2823
  }
2585
2824
  // 1. Enumerate known siblings. This membership read is itself a pull-on-read
2586
2825
  // that helps the CadrePeer table converge — a reader-only node converges
@@ -2794,6 +3033,27 @@ export class CadreNode {
2794
3033
  totalMs: cohort?.dialTimeoutMs ?? CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS * perAddressMs,
2795
3034
  };
2796
3035
  }
3036
+ /**
3037
+ * The link-derived limits shared by every {@link SeedBootstrapService} this node builds: its
3038
+ * owner and invite dials ({@link controlDialBudget}) and its seed delivery deadline, derived
3039
+ * from `network.linkRoundTripMs`. One helper so the four construction sites cannot drift.
3040
+ */
3041
+ seedServiceBudgets() {
3042
+ return {
3043
+ dialBudget: this.controlDialBudget(),
3044
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
3045
+ };
3046
+ }
3047
+ /**
3048
+ * {@link collectStrandAddrs} at this node's declared `network.linkRoundTripMs`, so each ask's
3049
+ * deadline derives from it. One helper so the four call sites cannot drift.
3050
+ */
3051
+ async collectSiblingStrandAddrs(controlNode, peers, strandId, delegatePeerId) {
3052
+ return await collectStrandAddrs(controlNode, peers, strandId, {
3053
+ delegatePeerId,
3054
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
3055
+ });
3056
+ }
2797
3057
  /**
2798
3058
  * Dial one sibling from its already-resolved addresses, best-effort. Returns
2799
3059
  * whether the dial resolved (false when no address resolves or the dial fails).
@@ -2802,7 +3062,9 @@ export class CadreNode {
2802
3062
  * the {@link controlDialBudget} expiring) is logged and swallowed so one
2803
3063
  * unreachable sibling never aborts the pass — exactly like
2804
3064
  * {@link SeedBootstrapService.applySeed}'s owner-dial loop. A failed dial is
2805
- * simply retried on the next pass.
3065
+ * simply retried on the next pass. A sibling whose every address relays
3066
+ * through this node ({@link SelfRelayOnlyError}) is not dialed at all and gets
3067
+ * a one-line log instead: only the sibling can reconnect.
2806
3068
  */
2807
3069
  async dialControlSibling(sibling, resolved) {
2808
3070
  const controlNode = this.controlNode;
@@ -2820,7 +3082,12 @@ export class CadreNode {
2820
3082
  return true;
2821
3083
  }
2822
3084
  catch (error) {
2823
- log('reconcileControlCohort: dial of sibling %s failed (continuing): %o', sibling.peerId, error);
3085
+ if (error instanceof SelfRelayOnlyError) {
3086
+ log('reconcileControlCohort: sibling %s is reachable only by relaying through this node; waiting for it to reconnect', sibling.peerId);
3087
+ }
3088
+ else {
3089
+ log('reconcileControlCohort: dial of sibling %s failed (continuing): %o', sibling.peerId, error);
3090
+ }
2824
3091
  return false;
2825
3092
  }
2826
3093
  }
@@ -3064,7 +3331,12 @@ export class CadreNode {
3064
3331
  return true;
3065
3332
  }
3066
3333
  catch (error) {
3067
- log('reconcileControlCohort(cold-start): dial of bootstrap peer %s failed (continuing): %o', peerId, error);
3334
+ if (error instanceof SelfRelayOnlyError) {
3335
+ log('reconcileControlCohort(cold-start): bootstrap peer %s is reachable only by relaying through this node; waiting for it to reconnect', peerId);
3336
+ }
3337
+ else {
3338
+ log('reconcileControlCohort(cold-start): dial of bootstrap peer %s failed (continuing): %o', peerId, error);
3339
+ }
3068
3340
  return false;
3069
3341
  }
3070
3342
  }
@@ -3081,7 +3353,7 @@ export class CadreNode {
3081
3353
  // local-only). The remedy: detect "wrote while alone" (0 connections is a sound
3082
3354
  // lower bound), queue the affected row, and RE-ISSUE the write once the cohort
3083
3355
  // grows (0→≥1 connection), as an idempotent monotonic update that now broadcasts.
3084
- // See docs/architecture.md (Control Network → write-while-alone durability).
3356
+ // See docs/architecture.md (Control Network Convergence → "Writes made while alone").
3085
3357
  // ============================================================================
3086
3358
  /**
3087
3359
  * Whether a control write happening now would commit local-only. A sound lower
@@ -3738,26 +4010,20 @@ export class CadreNode {
3738
4010
  // Check if we have sApp config for this strand
3739
4011
  const sAppConfig = this.sAppConfigs.get(strand.Id);
3740
4012
  if (!sAppConfig) {
3741
- log('No sAppConfig registered for strand %s - emitting strand:discovered', strand.Id);
3742
- // Strand created by another member and not yet configured locally. Surface
3743
- // it as a discovery event so the hosting app can decide whether to join
3744
- // (register a config + addStrand); the strand-agnostic seam keeps this
3745
- // class free of any app's join policy. A self-configured strand (config
3746
- // already present) keeps auto-starting below, unchanged.
3747
- //
3748
- // Recorded BEFORE the emit so a handler that synchronously drains
3749
- // `getDiscoveredStrands()` sees this strand too. The watcher does not offer the
3750
- // same strand twice — its `knownStrands` retains the id, and the one thing that
3751
- // un-retains it (a failed `addStrand`, via `StrandWatcher.forgetStrand`) leaves the
3752
- // sApp config registered, so the retry takes the auto-launch branch below rather
3753
- // than this one. So this map — not the event — is what a late subscriber reads.
3754
- // See the `strand:discovered` doc in types.ts.
3755
- this.discoveredStrands.set(strand.Id, strand);
3756
- this.emit('strand:discovered', { strandId: strand.Id, strand });
3757
- return;
4013
+ this.announceDiscoveredStrand(strand);
4014
+ // Announce-only unless this node hosts storage replicas. Also skipped when a
4015
+ // listener claimed the strand synchronously (an `addStrand` call registers its
4016
+ // config before its first await): that claim is launching it with the app's schema,
4017
+ // and a replica launch here would only race it.
4018
+ if (!this.hostUnclaimedStrands || this.sAppConfigs.has(strand.Id)) {
4019
+ return;
4020
+ }
3758
4021
  }
3759
4022
  try {
3760
- await this.launchStrand(strand, sAppConfig);
4023
+ const instance = await this.launchStrand(strand, sAppConfig);
4024
+ if (!sAppConfig) {
4025
+ void this.recordReplicaSAppId(instance.strandId);
4026
+ }
3761
4027
  }
3762
4028
  catch (error) {
3763
4029
  log('Error starting strand %s: %o', strand.Id, error);
@@ -3774,6 +4040,59 @@ export class CadreNode {
3774
4040
  throw error;
3775
4041
  }
3776
4042
  }
4043
+ /**
4044
+ * Offer a strand no local config claims to the hosting app as `strand:discovered`, so
4045
+ * it can decide whether to join (register a config + addStrand); the strand-agnostic
4046
+ * seam keeps this class free of any app's join policy.
4047
+ *
4048
+ * Once per strand: a storage-replica launch that fails is retried by the watcher
4049
+ * (`StrandWatcher.forgetStrand` + backoff), and each retry comes back through
4050
+ * {@link handleStrandAdded} with no config — the app must still see one announcement.
4051
+ * The other thing that un-retains a strand in the watcher (a failed `addStrand`) leaves
4052
+ * the sApp config registered, so that retry takes the claimed branch instead.
4053
+ *
4054
+ * Recorded BEFORE the emit so a handler that synchronously drains
4055
+ * `getDiscoveredStrands()` sees this strand too. So this map — not the event — is what
4056
+ * a late subscriber reads. See the `strand:discovered` doc in types.ts.
4057
+ *
4058
+ * NOTE: a replica launch that keeps failing leaves the watcher not tracking the strand,
4059
+ * so if its control row is removed during the backoff no `handleStrandRemoved` arrives
4060
+ * and this entry outlives the row. Needs a persistently failing launch AND a removal
4061
+ * inside the backoff; if it is ever seen, have the watcher report removal of rows it
4062
+ * forgot, since `detachStrand` is already a no-op for an untracked instance.
4063
+ */
4064
+ announceDiscoveredStrand(strand) {
4065
+ if (this.discoveredStrands.has(strand.Id)) {
4066
+ return;
4067
+ }
4068
+ log('No sAppConfig registered for strand %s - emitting strand:discovered', strand.Id);
4069
+ this.discoveredStrands.set(strand.Id, strand);
4070
+ this.emit('strand:discovered', { strandId: strand.Id, strand });
4071
+ }
4072
+ /**
4073
+ * Record the sApp id a storage replica's `Strand.Header` names, for {@link getSAppId}.
4074
+ * Runs whenever a replica's database may have just been published (launch, the
4075
+ * first-sync gate opening, a wake); a no-op for a claimed strand, an unpublished
4076
+ * database, or an id already recorded. Never throws: a failed read logs and leaves the
4077
+ * id unknown, so an `sAppId` filter keeps the strand provisionally admitted — a
4078
+ * syncing replica, which is harmless.
4079
+ */
4080
+ async recordReplicaSAppId(strandId) {
4081
+ const database = this.strandManager.getInstance(strandId)?.database;
4082
+ if (!database || this.sAppConfigs.has(strandId) || this.replicaSAppIds.has(strandId)) {
4083
+ return;
4084
+ }
4085
+ try {
4086
+ const sAppId = await readStrandHeaderSAppId(database.getDatabase());
4087
+ if (sAppId !== undefined) {
4088
+ this.replicaSAppIds.set(strandId, sAppId);
4089
+ log('Storage replica %s serves sApp %s', strandId, sAppId);
4090
+ }
4091
+ }
4092
+ catch (error) {
4093
+ log('Could not read the sApp id of storage replica %s (left unknown): %o', strandId, error);
4094
+ }
4095
+ }
3777
4096
  async handleStrandRemoved(strandId) {
3778
4097
  log('Handling strand removed: %s', strandId);
3779
4098
  try {
@@ -3848,7 +4167,7 @@ export class CadreNode {
3848
4167
  }
3849
4168
  // Unregister strand solicitation service
3850
4169
  if (this.strandSolicitationService && this.controlNode) {
3851
- this.strandSolicitationService.unregisterResponder(this.controlNode);
4170
+ await this.strandSolicitationService.unregisterResponder(this.controlNode);
3852
4171
  this.strandSolicitationService = null;
3853
4172
  }
3854
4173
  // Stop strand watcher
@@ -3862,6 +4181,7 @@ export class CadreNode {
3862
4181
  this.sAppConfigs.clear();
3863
4182
  this.strandLaunchRefusals.clear();
3864
4183
  this.discoveredStrands.clear();
4184
+ this.replicaSAppIds.clear();
3865
4185
  // Drop delegate-admission state: the grants are scoped to the session that
3866
4186
  // recorded them, so a stop()/start() cycle on this object must not keep
3867
4187
  // admitting delegates announced under the previous one, nor carry stale
@@ -3922,6 +4242,7 @@ export class CadreNode {
3922
4242
  if (instance) {
3923
4243
  this.hibernationManager.recordActivity(instance);
3924
4244
  }
4245
+ void this.recordReplicaSAppId(strandId);
3925
4246
  this.emit('strand:writable', { strandId });
3926
4247
  }
3927
4248
  // Hibernation callbacks
@@ -3935,27 +4256,18 @@ export class CadreNode {
3935
4256
  }
3936
4257
  /**
3937
4258
  * Hibernate a strand: release its strand-network resources via the strand
3938
- * manager (stop the libp2p node, close the StrandDatabase) and mark it
4259
+ * manager (stop the libp2p node, close the StrandDatabase), which marks it
3939
4260
  * `hibernating`. A quiesced strand holds no open strand-network connections,
3940
- * transports, or DB handles. No-ops if the strand is missing; if already
3941
- * quiesced (defensive), just marks status and emits.
4261
+ * transports, or DB handles. No-ops if the strand is missing; one already quiesced
4262
+ * is only marked, and still emits.
3942
4263
  */
3943
4264
  async handleStrandHibernate(strandId) {
3944
- const instance = this.strandManager.getInstance(strandId);
3945
- if (!instance) {
4265
+ if (!this.strandManager.getInstance(strandId)) {
3946
4266
  log('handleStrandHibernate: strand %s not found', strandId);
3947
4267
  return;
3948
4268
  }
3949
- if (!instance.libp2pNode && !instance.database) {
3950
- // Already quiesced — only the status flag needs updating.
3951
- instance.status = 'hibernating';
3952
- log('Strand %s already quiesced; marked hibernating', strandId);
3953
- this.emit('strand:hibernating', { strandId });
3954
- return;
3955
- }
3956
4269
  log('Hibernating strand %s — releasing strand-network resources', strandId);
3957
4270
  await this.strandManager.quiesceStrand(strandId);
3958
- instance.status = 'hibernating';
3959
4271
  this.emit('strand:hibernating', { strandId });
3960
4272
  log('Strand %s hibernating (resources released)', strandId);
3961
4273
  }
@@ -3965,7 +4277,13 @@ export class CadreNode {
3965
4277
  * its runtime via the strand manager. If it is still live (e.g. waking an idle
3966
4278
  * strand, which retains its resources), just flip the status. Overlapping wake
3967
4279
  * triggers are coalesced upstream by `HibernationManager`, so this runs once
3968
- * per wake; `resumeStrand` is itself idempotent as a backstop.
4280
+ * per wake; a wake racing a check-in joins the check-in's rebuild in `resumeStrand`.
4281
+ *
4282
+ * A failed rebuild re-hibernates the strand (see {@link rehibernateIfResumeFailed});
4283
+ * every failure rethrows, so the waker still sees the error.
4284
+ *
4285
+ * Records no activity itself: whoever asked for the wake did ({@link wakeStrand},
4286
+ * `HibernationManager.recordActivity`), and {@link serviceWake}'s own probe must not.
3969
4287
  */
3970
4288
  async handleStrandWake(strandId) {
3971
4289
  const instance = this.strandManager.getInstance(strandId);
@@ -3974,10 +4292,14 @@ export class CadreNode {
3974
4292
  return;
3975
4293
  }
3976
4294
  // Still live (idle wake, or defensive double-wake): no rebuild needed. A joiner
3977
- // still behind its first-sync gate wakes back to `'syncing'`, not `'active'`.
3978
- if (instance.libp2pNode || instance.database) {
4295
+ // still behind its first-sync gate wakes back to `'syncing'`, not `'active'`. A
4296
+ // `'starting'` strand is mid-build (a check-in's resume, or its launch) with the node
4297
+ // attached before the database: not live yet, so it falls through and joins that build.
4298
+ // A strand being quiesced still holds its handles but is on its way down: it falls
4299
+ // through too, and its resume runs after the quiesce.
4300
+ if (instance.status !== 'starting' && (instance.libp2pNode || instance.database)
4301
+ && !this.strandManager.isQuiescing(strandId)) {
3979
4302
  instance.status = liveStrandStatus(instance);
3980
- instance.lastActivity = new Date();
3981
4303
  log('Strand %s woke (already live)', strandId);
3982
4304
  this.emit('strand:waking', { strandId });
3983
4305
  return;
@@ -3985,17 +4307,58 @@ export class CadreNode {
3985
4307
  // Quiesced: re-resolve the volatile cohort input (the seed may have grown)
3986
4308
  // and rebuild the runtime.
3987
4309
  log('Waking strand %s — rebuilding strand-network resources', strandId);
3988
- await this.resumeStrandRuntime(strandId);
3989
- instance.lastActivity = new Date();
4310
+ try {
4311
+ await this.resumeStrandRuntime(strandId);
4312
+ }
4313
+ catch (error) {
4314
+ await this.rehibernateIfResumeFailed(instance, 'Wake', error);
4315
+ throw error;
4316
+ }
3990
4317
  this.emit('strand:waking', { strandId });
3991
4318
  log('Strand %s awake (resources rebuilt)', strandId);
3992
4319
  }
4320
+ /**
4321
+ * Undo a failed {@link resumeStrandRuntime}, but only when the rebuild itself failed — this
4322
+ * caller's, or the one it joined in `resumeStrand` — which leaves the instance `'error'`. A
4323
+ * failure before the rebuild (the cohort seed read) built nothing, and may find the strand
4324
+ * mid-build or just brought up by a concurrent wake or check-in; that runtime belongs to
4325
+ * whoever is building or holding it, so quiescing it here would stop a node still in use.
4326
+ */
4327
+ async rehibernateIfResumeFailed(instance, context, error) {
4328
+ if (instance.status !== 'error') {
4329
+ log('%s of strand %s failed before its rebuild (status=%s); leaving the strand as it is: %o', context, instance.strandId, instance.status, error);
4330
+ return;
4331
+ }
4332
+ log('%s of strand %s failed; re-hibernating so a later wake or check-in retries: %o', context, instance.strandId, error);
4333
+ await this.rehibernateAfterFailedResume(instance, context);
4334
+ }
4335
+ /**
4336
+ * Put a strand whose rebuild or wake window failed back to `'hibernating'`, through a
4337
+ * best-effort quiesce that releases any runtime left up. `resumeStrand` leaves a failed
4338
+ * strand `'error'`, which nothing retries: `HibernationManager` wakes only
4339
+ * `idle`/`hibernating` strands, and reads any other status after a check-in as "woke",
4340
+ * ending its chain. Safe to run twice for one failure (a wake that joined a failed
4341
+ * check-in's rebuild): quiescing a quiesced strand only marks it.
4342
+ *
4343
+ * The quiesce writes the status itself, ordered before any resume requested meanwhile;
4344
+ * this writes it only when the quiesce failed, since a failed release leaves the status
4345
+ * alone.
4346
+ */
4347
+ async rehibernateAfterFailedResume(instance, context) {
4348
+ try {
4349
+ await this.strandManager.quiesceStrand(instance.strandId);
4350
+ }
4351
+ catch (cleanupErr) {
4352
+ log('%s cleanup quiesce for strand %s failed: %o', context, instance.strandId, cleanupErr);
4353
+ instance.status = 'hibernating';
4354
+ }
4355
+ }
3993
4356
  /**
3994
4357
  * Rebuild a quiesced strand's runtime, re-resolving the volatile cohort input
3995
4358
  * first: the discovery seed may have grown since the strand last ran. Shared by
3996
4359
  * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so both
3997
- * apply the same fresh resolution. `resumeStrand` is idempotent
3998
- * (returns the live instance unchanged) as a backstop against double-resume.
4360
+ * apply the same fresh resolution. `resumeStrand` returns a live instance unchanged
4361
+ * and joins a rebuild already in flight, so the two paths never build twice.
3999
4362
  */
4000
4363
  async resumeStrandRuntime(strandId) {
4001
4364
  // Re-derive the transport peerId (deterministic and cheap — a quiesced
@@ -4014,6 +4377,7 @@ export class CadreNode {
4014
4377
  if (instance.libp2pNode) {
4015
4378
  await this.mergeStrandPeerAddrs(instance.libp2pNode, bootstrapNodes, strandId);
4016
4379
  }
4380
+ void this.recordReplicaSAppId(strandId);
4017
4381
  }
4018
4382
  /**
4019
4383
  * Real cohort check-in for a hibernating strand (the `onCheckIn` callback).
@@ -4046,62 +4410,77 @@ export class CadreNode {
4046
4410
  log('handleStrandCheckIn: strand %s not hibernating (status=%s); skipping', strandId, instance.status);
4047
4411
  return;
4048
4412
  }
4413
+ // Before the resume, so a wake that joins this rebuild, or activity recorded while it
4414
+ // runs, counts: the window then leaves the strand up for it.
4415
+ const activityMark = instance.lastActivity;
4416
+ // 1. Resume exactly as a wake does: re-resolve the (possibly grown) cohort
4417
+ // seed, then rebuild the runtime.
4418
+ log('Check-in: resuming strand %s to probe the cohort for pending activity', strandId);
4049
4419
  try {
4050
- // 1. Resume exactly as a wake does: re-resolve the (possibly grown) cohort
4051
- // seed, then rebuild the runtime.
4052
- log('Check-in: resuming strand %s to probe the cohort for pending activity', strandId);
4053
4420
  await this.resumeStrandRuntime(strandId);
4054
- // 2-3. Bounded window for the strand network to connect + the app to act,
4055
- // then re-hibernate-if-idle. Shared with the on-demand serviceWake.
4056
- const windowMs = this.config.hibernation?.checkInWindowMs ?? DEFAULT_CHECKIN_WINDOW_MS;
4057
- await this.runWakeWindow(instance, windowMs);
4058
4421
  }
4059
4422
  catch (err) {
4060
- // A check-in that throws part-way — resume failing on a flaky network (the
4061
- // very scenario hibernation targets), the window rejecting, or quiesce
4062
- // throwing — must leave the strand HIBERNATING, not in the `error` status
4063
- // that `resumeStrand` sets on failure. `HibernationManager.runCheckIn`
4064
- // decides wake-vs-escalate purely from `instance.status` after this
4065
- // resolves: an `error` status reads as "woke", which STOPS the check-in
4066
- // chain and strands the strand with no runtime and no future check-in.
4067
- // Forcing it back to `hibernating` (after a best-effort quiesce to release
4068
- // any partially-rebuilt runtime) makes the manager escalate the backoff and
4069
- // retry on the next tick.
4070
- log('Check-in failed for strand %s; re-hibernating to retry on backoff: %o', strandId, err);
4071
- await this.strandManager.quiesceStrand(strandId).catch((cleanupErr) => {
4072
- log('Check-in cleanup quiesce for strand %s failed: %o', strandId, cleanupErr);
4073
- });
4074
- instance.status = 'hibernating';
4423
+ // Every failure resolves: `HibernationManager.runCheckIn` decides wake-vs-escalate
4424
+ // from `instance.status` alone. A failed rebuild — on a flaky network, the very
4425
+ // scenario hibernation targets — leaves `resumeStrand`'s `error`, which reads as
4426
+ // "woke" and would STOP the chain with no runtime and no future check-in, so it goes
4427
+ // back to `hibernating` and the manager escalates the backoff and retries. A failure
4428
+ // before the rebuild (the seed read) built nothing: the strand is still `hibernating`
4429
+ // (the manager escalates), or a wake that began meanwhile is building or holding it,
4430
+ // and the manager hands the chain to that wake, whose outcome restores or ends it.
4431
+ await this.rehibernateIfResumeFailed(instance, 'Check-in', err);
4432
+ return;
4433
+ }
4434
+ // 2-3. Bounded window for the strand network to connect + the app to act,
4435
+ // then re-hibernate-if-idle. Shared with the on-demand serviceWake.
4436
+ const windowMs = this.config.hibernation?.checkInWindowMs ?? DEFAULT_CHECKIN_WINDOW_MS;
4437
+ try {
4438
+ await this.runWakeWindow(instance, activityMark, windowMs);
4439
+ }
4440
+ catch (err) {
4441
+ // The window's quiesce threw with the rebuilt runtime up: release it best-effort and
4442
+ // leave the strand `hibernating`, for the same reason as a failed rebuild above.
4443
+ log('Check-in window failed for strand %s; re-hibernating to retry on backoff: %o', strandId, err);
4444
+ await this.rehibernateAfterFailedResume(instance, 'Check-in');
4075
4445
  }
4076
4446
  }
4077
4447
  /**
4078
4448
  * Window-then-decide for a just-resumed strand, shared by the check-in timer
4079
4449
  * path ({@link handleStrandCheckIn}) and the on-demand {@link serviceWake}:
4080
4450
  *
4081
- * 1. Capture the post-resume activity marker. `recordActivity` assigns a
4082
- * FRESH `Date`, so a changed reference after the window means real
4083
- * activity landed during it — not millisecond-resolution noise.
4084
- * 2. Hold the strand live for `windowMs` so its strand network reaches the
4451
+ * 1. Hold the strand live for `windowMs` so its strand network reaches the
4085
4452
  * cohort and the app can drive pull-on-read activity.
4086
- * 3. If activity landed, leave the strand `active` (return `true`); otherwise
4087
- * quiesce and mark it `hibernating` again (return `false`).
4453
+ * 2. If activity landed since `activityMark`, leave the strand `active` (return
4454
+ * `true`); otherwise quiesce it, which marks it `hibernating` again (return `false`).
4455
+ * A wake or activity that lands during that quiesce rebuilds the strand after it.
4088
4456
  *
4089
- * @returns whether activity was observed during the window (strand left active).
4457
+ * @param activityMark - `instance.lastActivity` as the caller read it BEFORE bringing the
4458
+ * strand up. The bring-up records none, and every writer assigns a FRESH `Date`, so a
4459
+ * changed reference means a wake or activity landed during the resume or the window —
4460
+ * not millisecond-resolution noise.
4461
+ * @returns whether activity was observed (strand left active).
4090
4462
  */
4091
- async runWakeWindow(instance, windowMs) {
4463
+ async runWakeWindow(instance, activityMark, windowMs) {
4092
4464
  const strandId = instance.strandId;
4093
- const activityMark = instance.lastActivity;
4465
+ // NOTE: activity during the window arms the idle countdown, so a window longer than the
4466
+ // hint's idle + hibernate timeouts (only reachable with a custom `windowMs`; the shortest
4467
+ // default pair, archive's, is 40 s vs a 15 s window) can let the timer path quiesce the
4468
+ // strand mid-window, which this then marks live. If such windows become real, suspend the
4469
+ // idle countdown while a window holds the strand.
4094
4470
  await this.holdWakeWindow(instance, windowMs);
4095
4471
  const sawActivity = instance.lastActivity !== activityMark;
4096
4472
  if (sawActivity) {
4097
- instance.status = liveStrandStatus(instance);
4098
- this.emit('strand:waking', { strandId });
4099
- log('Wake window: strand %s saw activity during the window; staying active', strandId);
4473
+ // A force-hibernate during the window has released the runtime and marked the strand
4474
+ // `hibernating`, which stands; a wake queued behind it writes its own status.
4475
+ if (instance.libp2pNode || instance.database) {
4476
+ instance.status = liveStrandStatus(instance);
4477
+ this.emit('strand:waking', { strandId });
4478
+ }
4479
+ log('Wake window: strand %s saw activity during the window; not re-hibernating', strandId);
4100
4480
  return true;
4101
4481
  }
4102
4482
  log('Wake window: no activity for strand %s; re-hibernating', strandId);
4103
4483
  await this.strandManager.quiesceStrand(strandId);
4104
- instance.status = 'hibernating';
4105
4484
  return false;
4106
4485
  }
4107
4486
  /**
@@ -4149,6 +4528,11 @@ export class CadreNode {
4149
4528
  * published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
4150
4529
  * anyone else's row joins, without the caller needing to know which it is.
4151
4530
  *
4531
+ * A joined row (no `founder: true`) this party's control database does not name — a
4532
+ * strand joined from another party — is remembered in the node's joined-strand store, so it
4533
+ * is re-offered as `strand:discovered` after a restart, and published party-wide by an
4534
+ * owner machine's reconcile pass (see {@link forgetJoinedStrand}).
4535
+ *
4152
4536
  * A rejected call leaves nothing running but DOES leave the sApp config
4153
4537
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
4154
4538
  * automatic relaunch need it. A failed launch here hands the strand back to that
@@ -4179,6 +4563,12 @@ export class CadreNode {
4179
4563
  `(Type 'c'), but this row is Type '${strandRow.Type}' — an open strand seats no ` +
4180
4564
  'Member/Manager, so the key would be ignored.');
4181
4565
  }
4566
+ // A row this node offered itself came from the control table or is already a
4567
+ // remembered join, and a row this node founds is not a join, so only a joined row
4568
+ // from elsewhere can be a new join to remember. A founded row recorded here would
4569
+ // come back after a restart with `FounderOwnerKey: null` and relaunch as a joiner.
4570
+ // Captured before the backlog delete below.
4571
+ const rememberable = founder !== true && !this.discoveredStrands.has(strandRow.Id);
4182
4572
  // Store sApp config for this strand. The strand is claimed now, so it leaves the
4183
4573
  // unclaimed backlog — a later `getDiscoveredStrands()` drain must not re-offer it.
4184
4574
  this.sAppConfigs.set(strandRow.Id, sAppConfig);
@@ -4187,6 +4577,11 @@ export class CadreNode {
4187
4577
  // the strand again, or a party-wide removal would never stop it here.
4188
4578
  this.strandWatcher?.unsuppressStrand(strandRow.Id);
4189
4579
  log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ?? 'derived');
4580
+ // Before the launch, so a foreign strand whose launch fails, or whose app is killed
4581
+ // mid-launch, is still offered again: by the watcher's retry and by the next start.
4582
+ if (rememberable) {
4583
+ await this.rememberForeignStrand(strandRow);
4584
+ }
4190
4585
  // An unset `founder` is DERIVED from the row inside launchStrand (this node founds
4191
4586
  // iff the row's FounderOwnerKey is its own owner key); an explicit flag wins — the
4192
4587
  // formation/responder flows pass one deliberately, since their consent-seated rows
@@ -4207,7 +4602,8 @@ export class CadreNode {
4207
4602
  // only delays the watcher's own first attempt by one poll interval, which is what
4208
4603
  // should happen right after an attempt that just failed.
4209
4604
  //
4210
- // NOTE: the retry relaunches from the CONTROL row plus the registered config, not
4605
+ // NOTE: the retry relaunches from the CONTROL row (or, for a strand joined from
4606
+ // another party, the remembered join) plus the registered config, not
4211
4607
  // from the row and arguments passed here — so a caller that enriched either (a
4212
4608
  // synthetic `MemberPrivateKey`, an explicit `founder` or `partyMemberPrivateKey`)
4213
4609
  // is retried with less than it asked for. Inert today: founder-ness re-derives from
@@ -4361,7 +4757,7 @@ export class CadreNode {
4361
4757
  catch (error) {
4362
4758
  // Only the uniqueness collision is a candidate for the idempotent reading; every
4363
4759
  // other rejection (unauthorized signer, retired stamp) must keep surfacing.
4364
- if (!isStrandIdConflict(error)) {
4760
+ if (!isStrandIdConflict(error, 'Strand')) {
4365
4761
  throw error;
4366
4762
  }
4367
4763
  const landed = await this.controlDatabase.queryStrand(trimmed);
@@ -4509,7 +4905,7 @@ export class CadreNode {
4509
4905
  * — and with it the consent re-seat foreclosure — does propagate; but the physical row
4510
4906
  * deletion cannot be replayed, and `queryStrands` reads raw (no retired-stamp filter),
4511
4907
  * so siblings that already hold the row keep running the strand until the collection
4512
- * itself converges (logged loudly; see the delete-while-alone durability note in
4908
+ * itself converges (logged loudly; see "Deletes made while alone" in
4513
4909
  * docs/architecture.md).
4514
4910
  *
4515
4911
  * A no-op (no throw, no tombstone) when the row is already absent — but a
@@ -4543,6 +4939,9 @@ export class CadreNode {
4543
4939
  // A refused pre-split launch left no tracked instance, so the stop below never
4544
4940
  // reaches detachStrand for it — clear the refusal here so a re-founded id starts clean.
4545
4941
  this.strandLaunchRefusals.delete(trimmed);
4942
+ // A party-wide removal: the strand's peers must not stay dial targets for a strand
4943
+ // that no longer exists (a re-published id is a fresh strand with fresh peers).
4944
+ this.forgetStrandPeers(trimmed, 'unpublishStrand');
4546
4945
  // Clear THIS machine's own MemberPeer binding while the strand runtime — and the
4547
4946
  // retained party key — is still live: the delete above already destroyed the
4548
4947
  // party's StrandPartyKey row, so once this process forgets the key nothing can
@@ -4652,10 +5051,10 @@ export class CadreNode {
4652
5051
  * NOTE: "founding machine" here is the ROW's provenance, not the launch's resolved
4653
5052
  * `founder` flag — so an explicit `founder: false` over a row this machine published
4654
5053
  * still mints, an owner-signed write a caller that said "I am not founding" did not ask
4655
- * for. Harmless (the party needs that identity for the strand either way, and the mint
4656
- * is insert-if-absent) and unreachable today: the one explicit `founder: false` caller
4657
- * passes a row with a null `FounderOwnerKey`. If a caller ever pairs `founder: false`
4658
- * with its own published row, gate the mint on the resolved flag too.
5054
+ * for. Reached by a storage replica of a row this machine published (always a joiner,
5055
+ * see {@link launchStrand}) and kept on purpose: the party needs that identity for the
5056
+ * strand either way, the mint is insert-if-absent, and a closed-strand replica runs its
5057
+ * membership reconciler — which seats its own `MemberPeer` binding — only with a party key.
4659
5058
  */
4660
5059
  async resolveStrandPartyKey(strand, explicitKey) {
4661
5060
  if (explicitKey !== undefined) {
@@ -4821,9 +5220,18 @@ export class CadreNode {
4821
5220
  * A founder launch refused as pre-split (`PreSplitStrandIdentityError`) is recorded in
4822
5221
  * {@link strandLaunchRefusals} for the formation arm, then rethrown; a founder launch
4823
5222
  * that succeeds clears the record (it ran the bootstrap's pre-split check and passed).
5223
+ *
5224
+ * An absent `sAppConfig` launches a storage replica ({@link CadreNodeConfig.hostUnclaimedStrands}),
5225
+ * which is ALWAYS a joiner whatever the row says: the founder bootstrap writes the sApp
5226
+ * into `Strand.Header`, and a replica has none. So a self-founded row whose watcher poll
5227
+ * wins the race against its app's `addStrand` after a restart comes up as a replica — and
5228
+ * so does a `foundStrand` whose publish a watcher poll saw before its attach. A claim
5229
+ * that finds a tracked replica upgrades it in place, over the same node and store
5230
+ * ({@link StrandInstanceManager.attachSApp}), BEFORE any founding: the founder bootstrap
5231
+ * needs the sApp the attach supplies.
4824
5232
  */
4825
5233
  async launchStrand(strand, sAppConfig, founder, explicitPartyKey) {
4826
- const resolvedFounder = founder ?? this.isSelfFoundedRow(strand);
5234
+ const resolvedFounder = sAppConfig ? (founder ?? this.isSelfFoundedRow(strand)) : false;
4827
5235
  try {
4828
5236
  const instance = await this.startOrFoundStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey);
4829
5237
  if (resolvedFounder) {
@@ -4844,41 +5252,8 @@ export class CadreNode {
4844
5252
  */
4845
5253
  async startOrFoundStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey) {
4846
5254
  const timed = (step, op) => timedStep('startOrFoundStrand', strand.Id, step, op);
4847
- const existing = this.strandManager.getInstance(strand.Id);
4848
- if (existing) {
4849
- if (resolvedFounder) {
4850
- // The resolver runs only when the retained config lacks a party key for a
4851
- // closed strand (see foundExistingStrand), so the common watcher re-entry
4852
- // ('already-founder') still costs no control read.
4853
- const outcome = await timed('foundExistingStrand', () => this.strandManager.foundExistingStrand(strand.Id, () => this.resolveStrandPartyKey(strand, explicitPartyKey)));
4854
- if (outcome === 'needs-resume') {
4855
- try {
4856
- // Quiesced instance: the retained config now founds, but founding promises
4857
- // the bootstrap has RUN by the time the caller resolves — wake through the
4858
- // hibernation manager (coalesced with any in-flight wake, timer-aware) so
4859
- // the rebuild executes it now rather than at some eventual wake.
4860
- await timed('wakeStrand', () => this.wakeStrand(strand.Id));
4861
- // The wake's rebuild founds — UNLESS a wake was already in flight when the
4862
- // config flipped, in which case it had already read the pre-flip config and
4863
- // rebuilt as a joiner, and `wakeStrand` merely coalesced onto it. Re-run the
4864
- // (insert-if-absent) bootstrap so founding never resolves headerless.
4865
- await timed('ensureFounderBootstrap', () => this.strandManager.ensureFounderBootstrap(strand.Id));
4866
- }
4867
- catch (error) {
4868
- // The founding did not happen (e.g. the rebuild refused a pre-split strand and
4869
- // rolled back, leaving the instance tracked with no runtime): withdraw the flip
4870
- // so the next attempt re-runs the founding instead of resolving
4871
- // 'already-founder' over an instance that never founded.
4872
- this.strandManager.withdrawFounderRequest(strand.Id);
4873
- throw error;
4874
- }
4875
- }
4876
- log('launchStrand: strand %s already tracked — founder request honored (%s)', strand.Id, outcome);
4877
- }
4878
- else {
4879
- log('launchStrand: strand %s already tracked locally — skipping re-launch', strand.Id);
4880
- }
4881
- return existing;
5255
+ if (this.strandManager.getInstance(strand.Id)) {
5256
+ return this.claimTrackedStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey);
4882
5257
  }
4883
5258
  // A closed strand's launch carries the party's OWN membership identity key: the
4884
5259
  // explicit attach-time key, else the control-layer StrandPartyKey row — minted here
@@ -4896,24 +5271,39 @@ export class CadreNode {
4896
5271
  // retained launch config carries this derived key, so hibernate → wake
4897
5272
  // (resumeStrand) reuses the same peerId.
4898
5273
  //
5274
+ // Without an identity key (a node configured with neither `keyStore` nor
5275
+ // `privateKey` — tests, and an embedder that opted out of a stable identity) the
5276
+ // strand node still runs under a key CADRE-CORE HOLDS: a fresh random Ed25519 key,
5277
+ // exactly what libp2p would generate internally if handed none, except that the
5278
+ // book swap can sign this node's own entry with it (`strand-peer-book-swap.ts`) and
5279
+ // the retained launch config keeps the same strand peer id across a hibernation
5280
+ // resume. Stability across RESTARTS still needs an identity key.
5281
+ //
4899
5282
  // NOTE: derivation requires an Ed25519 identity key, so a node configured
4900
5283
  // with some other key type now fails strand launch outright (surfaced as
4901
5284
  // `strand:error` / a rejected addStrand) where it previously started the
4902
5285
  // strand on that key. Ed25519 is already required for every control-DB
4903
5286
  // signing path, so nothing reachable today hits this; if a non-Ed25519
4904
- // identity is ever supported, fall back to `undefined` here — libp2p then
4905
- // generates a random per-strand key, which still avoids the collision but
4906
- // gives up peerId stability across restarts.
5287
+ // identity is ever supported, take the random-key branch below for it — it
5288
+ // still avoids the collision, and gives up only peerId stability across restarts.
4907
5289
  const identityKey = this.identityKey;
4908
5290
  const transportKey = identityKey
4909
5291
  ? await timed('strandTransportKey', () => strandTransportKey(identityKey, strand.Id))
4910
- : undefined;
5292
+ : await generateKeyPair('Ed25519');
4911
5293
  // Derived BEFORE seed resolution so the seed pass doubles as the delegate
4912
5294
  // announcement and every grant is recorded before `startStrand` runs
4913
5295
  // `libp2p.start()` (the responder records the grant before replying; the
4914
5296
  // client awaits the replies).
4915
5297
  const delegatePeerId = transportKey ? peerIdFromPrivateKey(transportKey).toString() : undefined;
4916
5298
  const bootstrapNodes = await timed('resolveCohortSeed', () => this.resolveCohortSeed(strand.Id, delegatePeerId));
5299
+ // Checked again after the awaits above: another launch of this strand may have started
5300
+ // meanwhile (a replica's watcher launch racing an app's claim, in either order), and
5301
+ // `startStrand` would hand its instance back unchanged — a claim holding a replica with
5302
+ // no `App` tables, a founder request dropped. Nothing awaits between this check and
5303
+ // `startStrand`'s own, so no third launch can slip in.
5304
+ if (this.strandManager.getInstance(strand.Id)) {
5305
+ return this.claimTrackedStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey);
5306
+ }
4917
5307
  const instance = await timed('strandManager.startStrand', () => this.strandManager.startStrand({
4918
5308
  strandRow: strand,
4919
5309
  sAppConfig,
@@ -4935,7 +5325,19 @@ export class CadreNode {
4935
5325
  backfill: this.config.strandBackfill,
4936
5326
  revocationEnforcement: this.config.strandRevocationEnforcement,
4937
5327
  membershipReconciliation: this.config.strandMembershipReconciliation,
4938
- onSelfRevoked: (revokedStrandId) => this.emit('strand:revoked', { strandId: revokedStrandId }),
5328
+ onSelfRevoked: (revokedStrandId) => {
5329
+ this.forgetRevokedJoin(revokedStrandId);
5330
+ this.emit('strand:revoked', { strandId: revokedStrandId });
5331
+ },
5332
+ // The strand peer book's observation writer: every strand peer the node
5333
+ // identifies is remembered with its dialable addresses, so a restart re-finds
5334
+ // it. Retained with the launch config, so a hibernation wake re-arms it.
5335
+ onStrandPeerIdentified: (observedStrandId, observation) => this.observeStrandPeer(observedStrandId, observation),
5336
+ // The book's third writer, the signed swap between strand peers: the strand node
5337
+ // reads and writes the store directly (`strand-peer-book-swap.ts`). Retained with
5338
+ // the launch config too. `?? undefined`: the field is `null` before start(), and a
5339
+ // strand cannot launch before start(), so this is belt and braces.
5340
+ strandPeerBook: this.strandPeerBookStore ?? undefined,
4939
5341
  onRejoinBlocked: (blockedStrandId) => this.emit('strand:rejoin-blocked', { strandId: blockedStrandId }),
4940
5342
  // The joiner's first-sync write gate (strand-first-sync-gate.ts): a launch that
4941
5343
  // comes up `'syncing'` announces the moment its database is published.
@@ -4970,6 +5372,63 @@ export class CadreNode {
4970
5372
  this.emit('strand:started', { strandId: strand.Id });
4971
5373
  return instance;
4972
5374
  }
5375
+ /**
5376
+ * {@link startOrFoundStrand} for a strand the manager already tracks: a claim of a
5377
+ * storage replica gives it the app's schema in place, then a founder request founds it,
5378
+ * waking a quiesced instance so the bootstrap has run before this resolves. Emits
5379
+ * nothing — `strand:started` fired when the instance launched.
5380
+ *
5381
+ * A launch of the strand still in flight (the watcher's launch racing an app's
5382
+ * `addStrand`, in either order) is waited out first: until its build settles the tracked
5383
+ * instance is `'starting'` with no database, and returning it would resolve `addStrand`
5384
+ * with a strand the app cannot use — and skip that call's first-sync wait, which only a
5385
+ * `'syncing'` instance gets. If that launch fails, this rejects too: its record is gone.
5386
+ */
5387
+ async claimTrackedStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey) {
5388
+ const timed = (step, op) => timedStep('startOrFoundStrand', strand.Id, step, op);
5389
+ const existing = await this.strandManager.whenRuntimeBuilt(strand.Id);
5390
+ if (!existing) {
5391
+ throw new Error(`Strand ${strand.Id}: the launch this one waited on failed (reported by the call that started it)`);
5392
+ }
5393
+ // Before the founder branch: the founder bootstrap writes the sApp into Strand.Header,
5394
+ // so it must run against the attached config.
5395
+ if (sAppConfig && !existing.sAppInfo) {
5396
+ const attached = await timed('attachSApp', () => this.strandManager.attachSApp(strand.Id, sAppConfig, { requireSignedSchemas: this.config.requireSignedSchemas }));
5397
+ log('launchStrand: strand %s was running as a storage replica — sApp %s claimed it (%s)', strand.Id, sAppConfig.id, attached);
5398
+ }
5399
+ if (!resolvedFounder) {
5400
+ log('launchStrand: strand %s already tracked locally — skipping re-launch', strand.Id);
5401
+ return existing;
5402
+ }
5403
+ // The resolver runs only when the retained config lacks a party key for a
5404
+ // closed strand (see foundExistingStrand), so the common watcher re-entry
5405
+ // ('already-founder') still costs no control read.
5406
+ const outcome = await timed('foundExistingStrand', () => this.strandManager.foundExistingStrand(strand.Id, () => this.resolveStrandPartyKey(strand, explicitPartyKey)));
5407
+ if (outcome === 'needs-resume') {
5408
+ try {
5409
+ // Quiesced instance: the retained config now founds, but founding promises
5410
+ // the bootstrap has RUN by the time the caller resolves — wake through the
5411
+ // hibernation manager (coalesced with any in-flight wake, timer-aware) so
5412
+ // the rebuild executes it now rather than at some eventual wake.
5413
+ await timed('wakeStrand', () => this.wakeStrand(strand.Id));
5414
+ // The wake's rebuild founds — UNLESS a wake was already in flight when the
5415
+ // config flipped, in which case it had already read the pre-flip config and
5416
+ // rebuilt as a joiner, and `wakeStrand` merely coalesced onto it. Re-run the
5417
+ // (insert-if-absent) bootstrap so founding never resolves headerless.
5418
+ await timed('ensureFounderBootstrap', () => this.strandManager.ensureFounderBootstrap(strand.Id));
5419
+ }
5420
+ catch (error) {
5421
+ // The founding did not happen (e.g. the rebuild refused a pre-split strand and
5422
+ // rolled back, leaving the instance tracked with no runtime): withdraw the flip
5423
+ // so the next attempt re-runs the founding instead of resolving
5424
+ // 'already-founder' over an instance that never founded.
5425
+ this.strandManager.withdrawFounderRequest(strand.Id);
5426
+ throw error;
5427
+ }
5428
+ }
5429
+ log('launchStrand: strand %s already tracked — founder request honored (%s)', strand.Id, outcome);
5430
+ return existing;
5431
+ }
4973
5432
  /**
4974
5433
  * Resolve a strand's discovery seed — the dialable strand-network multiaddr
4975
5434
  * strings for cohort siblings. Membership comes from the control network's
@@ -4992,17 +5451,37 @@ export class CadreNode {
4992
5451
  * relay reservation — the gate denial there is fatal, not degraded. A
4993
5452
  * party-member relay answers the RPC (its control node admits us as a
4994
5453
  * member); a dedicated ops/ relay does not speak the protocol and the
4995
- * per-peer failure folds to `[]` — harmless, it has no membership gate and
5454
+ * per-peer failure comes back `unreachable` — harmless, it has no membership gate and
4996
5455
  * needs no grant. The relay's direct addr rides along as the dial fallback
4997
5456
  * for a relay we are not yet connected to.
4998
5457
  */
4999
5458
  async resolveCohortSeed(strandId, delegatePeerId) {
5000
5459
  const siblings = await this.resolveSiblingSeed(strandId, delegatePeerId);
5001
- // Sibling answers FIRST: they were resolved just now, while a contact-map entry is as
5002
- // old as the formation that produced it. `unionAddrs` appends only what the siblings
5460
+ // Sibling answers FIRST: they were resolved just now, while a book entry is as old
5461
+ // as the last connection to that peer. `unionAddrs` appends only what the siblings
5003
5462
  // did not already name, so a fresher answer is never displaced and each source keeps
5004
- // its own signaling-first ordering.
5005
- return unionAddrs(siblings, this.crossPartyStrandAddrs.get(strandId) ?? []);
5463
+ // its own signaling-first ordering. `delegatePeerId` IS this strand node's own id.
5464
+ return unionAddrs(siblings, this.strandPeerBookAddrs(strandId, delegatePeerId));
5465
+ }
5466
+ /**
5467
+ * The strand peer book's contribution to a strand's seed: every live entry's
5468
+ * addresses, freshest peer first, minus this node's own entry when `selfPeerId` is
5469
+ * known (the book swap files one under the strand transport id; a node must never
5470
+ * dial itself). Empty before {@link start} builds the store.
5471
+ */
5472
+ strandPeerBookAddrs(strandId, selfPeerId) {
5473
+ const store = this.strandPeerBookStore;
5474
+ if (!store) {
5475
+ return [];
5476
+ }
5477
+ const addrs = [];
5478
+ for (const entry of store.entries(strandId)) {
5479
+ if (entry.peerId === selfPeerId) {
5480
+ continue;
5481
+ }
5482
+ addrs.push(...entry.addrs);
5483
+ }
5484
+ return addrs;
5006
5485
  }
5007
5486
  /**
5008
5487
  * The own-party half of {@link resolveCohortSeed}: strand-network addresses resolved
@@ -5022,10 +5501,13 @@ export class CadreNode {
5022
5501
  }
5023
5502
  }
5024
5503
  const bootstrapNodes = targets.length
5025
- ? await collectStrandAddrs(this.controlNode, targets, strandId, { delegatePeerId })
5504
+ ? (await this.collectSiblingStrandAddrs(this.controlNode, targets, strandId, delegatePeerId)).addrs
5026
5505
  : [];
5027
5506
  // Throttle state for the RELAY targets only — refreshDelegateGrants never
5028
- // looks up a sibling key, so recording one would only be dead weight.
5507
+ // looks up a sibling key, so recording one would only be dead weight. The
5508
+ // siblings' refresh due times are deliberately left alone too: stamping here
5509
+ // would race the refresh pass's pruning while the strand node is still coming
5510
+ // up, and all it would save is one extra RPC per sibling on the first tick.
5029
5511
  this.recordDelegateAnnounces(relays.map((r) => r.relayPeerId), strandId);
5030
5512
  return bootstrapNodes;
5031
5513
  }
@@ -5107,9 +5589,10 @@ export class CadreNode {
5107
5589
  * Record the announce timestamps {@link refreshDelegateGrants} throttles on,
5108
5590
  * for every relay a delegate-carrying announce pass dialed.
5109
5591
  *
5110
- * Recorded OPTIMISTICALLY at announce time: `collectStrandAddrs` folds
5111
- * per-peer failure to `[]` and reports no per-peer success, and threading
5112
- * success out would change its API for little gain. A failed INITIAL announce
5592
+ * Recorded OPTIMISTICALLY at announce time, whatever `collectStrandAddrs`
5593
+ * reports per peer: a dedicated `ops/` relay never speaks the strand-addr
5594
+ * protocol, so recording only on success would re-announce to it on every
5595
+ * reconcile tick, one wasted protocol negotiation each. A failed INITIAL announce
5113
5596
  * costs the strand supervisor its first attempt only (the relay denies the
5114
5597
  * reservation; every re-drive re-announces first through
5115
5598
  * {@link announceDelegateToRelay}); a failed REFRESH retries within
@@ -5145,7 +5628,7 @@ export class CadreNode {
5145
5628
  running.set(strandId, instance.libp2pNode.peerId.toString());
5146
5629
  }
5147
5630
  }
5148
- pruneStoppedStrandAnnounces(this.delegateAnnounceAt, new Set(running.keys()));
5631
+ prunePeerStrandKeys(this.delegateAnnounceAt, new Set(running.keys()));
5149
5632
  if (running.size === 0) {
5150
5633
  return;
5151
5634
  }
@@ -5172,16 +5655,16 @@ export class CadreNode {
5172
5655
  * again right away.
5173
5656
  *
5174
5657
  * Against a dedicated ops relay (no strand-addr RPC) the request fails per-peer
5175
- * and `collectStrandAddrs` folds it to `[]`: one wasted protocol negotiation per
5658
+ * and `collectStrandAddrs` reports it `unreachable`: one wasted protocol negotiation per
5176
5659
  * re-drive attempt, bounded by the supervisor's backoff. Never throws on that
5177
5660
  * path; a relay addr that names no peer id is logged and skipped (the hook's
5178
5661
  * caller runs the drive regardless).
5179
5662
  *
5180
5663
  * NOTE: against a relay that is DOWN this hook costs up to two strand-addr
5181
- * timeouts (10 s each: dial by peer id, then by addr) before the reservation drive even
5182
- * starts, so one failed re-drive holds the supervisor `driving` for those 20 s plus the
5183
- * drive's own deadline — 34 s at the default declared link round trip, and longer on a host
5184
- * that declared a slower one (`link-budget.ts`).
5664
+ * timeouts (dial by peer id, then by addr; 23 s each at the default declared link round trip)
5665
+ * before the reservation drive even starts, so one failed re-drive holds the supervisor
5666
+ * `driving` for those 46 s plus the drive's own deadline — 64 s at the default, and longer on
5667
+ * a host that declared a slower one (`link-budget.ts`).
5185
5668
  * Bounded and harmless while the relay is unreachable anyway; if recovery
5186
5669
  * latency after a relay comes back ever matters, skip the announce when the
5187
5670
  * control node holds no connection to the relay (the drive's own dial fails
@@ -5197,7 +5680,7 @@ export class CadreNode {
5197
5680
  log('announceDelegateToRelay: relay addr %s names no peer id; strand %s not announced', relayAddr, strandId);
5198
5681
  return;
5199
5682
  }
5200
- await collectStrandAddrs(controlNode, [{ peerId: relayPeerId, addrs: [multiaddr(relayAddr)] }], strandId, { delegatePeerId });
5683
+ await this.collectSiblingStrandAddrs(controlNode, [{ peerId: relayPeerId, addrs: [multiaddr(relayAddr)] }], strandId, delegatePeerId);
5201
5684
  this.recordDelegateAnnounces([relayPeerId], strandId);
5202
5685
  }
5203
5686
  /** One strand's share of {@link refreshDelegateGrants}: announce to the relays whose grant is due. */
@@ -5210,13 +5693,14 @@ export class CadreNode {
5210
5693
  if (due.length === 0) {
5211
5694
  return;
5212
5695
  }
5213
- await collectStrandAddrs(controlNode, due.map(relayStrandAddrPeer), strandId, { delegatePeerId });
5696
+ await this.collectSiblingStrandAddrs(controlNode, due.map(relayStrandAddrPeer), strandId, delegatePeerId);
5214
5697
  this.recordDelegateAnnounces(due.map((relay) => relay.relayPeerId), strandId, now);
5215
5698
  }
5216
5699
  /**
5217
- * Re-resolve each running strand's SIBLING addresses over the control mesh and
5218
- * merge them into that strand's own libp2p address book, throttled to once per
5219
- * {@link STRAND_PEER_ADDR_REFRESH_MS} per strand.
5700
+ * Keep each running strand's own libp2p address book warm: re-merge its strand
5701
+ * peer book on every pass, and re-ask each connected SIBLING for its strand
5702
+ * addresses over the control mesh when that (sibling, strand) is due
5703
+ * ({@link strandAddrAskDueAt}).
5220
5704
  *
5221
5705
  * Without this, a strand's address book is written exactly once — the
5222
5706
  * launch/resume seed — and everything below cadre-core that dials a strand peer
@@ -5226,6 +5710,12 @@ export class CadreNode {
5226
5710
  * re-resolved, and even the original seed addresses fall off at the peerStore's
5227
5711
  * one-hour expiry (see `peer-addr-book.ts`).
5228
5712
  *
5713
+ * Due times are per (sibling, strand) and set from each sibling's own outcome, so
5714
+ * a sibling that connects late — a phone joining after the party's always-on
5715
+ * machines — is asked on the next tick, and one that could not answer or refused
5716
+ * (its view of the membership may not include us yet) is retried within
5717
+ * {@link STRAND_PEER_ADDR_RETRY_MS} rather than {@link STRAND_PEER_ADDR_REFRESH_MS}.
5718
+ *
5229
5719
  * Distinct from {@link refreshDelegateGrants}, deliberately: that pass covers
5230
5720
  * RELAYS on a `DELEGATE_GRANT_TTL_MS / 2` throttle to keep circuit-relay
5231
5721
  * admission grants alive, this one covers SIBLINGS on an address-expiry
@@ -5249,54 +5739,65 @@ export class CadreNode {
5249
5739
  running.add(strandId);
5250
5740
  }
5251
5741
  }
5252
- // Drop stamps for strands no longer running: the map must not grow for the
5253
- // node's lifetime, and a resumed strand should refresh immediately rather
5254
- // than inherit the stamp its previous incarnation left.
5255
- for (const strandId of this.strandPeerAddrRefreshAt.keys()) {
5256
- if (!running.has(strandId)) {
5257
- this.strandPeerAddrRefreshAt.delete(strandId);
5258
- }
5259
- }
5260
- const refreshMs = this.config.network?.controlCohort?.strandAddrRefreshMs ?? STRAND_PEER_ADDR_REFRESH_MS;
5261
- const due = [...running].filter((strandId) => now - (this.strandPeerAddrRefreshAt.get(strandId) ?? 0) >= refreshMs);
5262
- if (due.length === 0) {
5742
+ if (running.size === 0) {
5743
+ this.strandAddrAskDueAt.clear();
5263
5744
  return;
5264
5745
  }
5265
- // `connectedSiblingTargets`' membership read is unbounded, and with zero
5266
- // connections its answer is empty whatever the table holds, so decide it from the
5267
- // connection list instead of paying for the read once a tick. Note this only skips
5268
- // the RPC: a strand carrying cross-party contact addresses still refreshes below,
5269
- // because those never came from a sibling in the first place.
5270
- //
5271
- // NOTE: one strand-addr RPC per (due strand × connected sibling) per refresh
5272
- // interval — each a tiny request/response on an already-open control
5273
- // connection. If a node ever runs MANY strands at once, batch the RPC to
5274
- // carry several strand ids per request rather than one fan-out per strand.
5275
- const targets = this.controlNode.getConnections().length === 0
5276
- ? []
5277
- : await this.connectedSiblingTargets().catch((error) => {
5278
- log('refreshStrandPeerAddrs: sibling enumeration failed (skipping pass): %o', error);
5279
- return [];
5280
- });
5746
+ const targets = await this.strandAddrRefreshTargets();
5281
5747
  // A shutdown landed mid-enumeration.
5282
5748
  if (!this._running || !this.controlNode) {
5283
5749
  return;
5284
5750
  }
5285
- await Promise.all(due.map((strandId) => this.refreshOneStrandPeerAddrs(strandId, targets, now)));
5751
+ // Drop due times for strands no longer running and siblings no longer connected:
5752
+ // the map must not grow for the node's lifetime, a resumed strand must not
5753
+ // inherit its previous incarnation's due times, and a sibling that reconnects (a
5754
+ // phone restart, a new relay reservation) must be asked on its next connected
5755
+ // tick. An enumeration failure prunes everything, which costs one extra round of
5756
+ // asks, never a missed one.
5757
+ prunePeerStrandKeys(this.strandAddrAskDueAt, running, new Set(targets.map((t) => t.peerId)));
5758
+ await Promise.all([...running].map((strandId) => this.refreshOneStrandPeerAddrs(strandId, targets, now)));
5759
+ }
5760
+ /**
5761
+ * The siblings {@link refreshStrandPeerAddrs} may ask this pass: the connected
5762
+ * cohort, or none when enumeration fails (logged, never thrown).
5763
+ *
5764
+ * `connectedSiblingTargets`' membership read is unbounded, and with zero
5765
+ * connections its answer is empty whatever the table holds, so decide it from the
5766
+ * connection list instead of paying for the read once a tick.
5767
+ *
5768
+ * NOTE: otherwise the read runs on EVERY tick with a running strand, not only when
5769
+ * some (sibling, strand) is due: pruning a departed sibling needs the current
5770
+ * target set, and whether anyone is due cannot be told without it (a connected
5771
+ * non-member never gets a due time to compare). One more `CadrePeer` read per 15 s
5772
+ * tick, on top of the two `runReconcileControlCohort` already makes; if those reads
5773
+ * get costly, share one row-set across the pass (see the NOTE there) rather than
5774
+ * skipping ticks here.
5775
+ *
5776
+ * NOTE: one strand-addr RPC per (running strand × due sibling) — each a tiny
5777
+ * request/response on an already-open control connection. If a node ever runs
5778
+ * MANY strands at once, batch the RPC to carry several strand ids per request
5779
+ * rather than one fan-out per strand.
5780
+ */
5781
+ async strandAddrRefreshTargets() {
5782
+ if (this.controlNode?.getConnections().length === 0) {
5783
+ return [];
5784
+ }
5785
+ return this.connectedSiblingTargets().catch((error) => {
5786
+ log('refreshStrandPeerAddrs: sibling enumeration failed (asking nobody this pass): %o', error);
5787
+ return [];
5788
+ });
5286
5789
  }
5287
5790
  /**
5288
- * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, union their
5289
- * answers with this strand's cross-party contact addresses, and merge the lot into the
5290
- * strand's address book. Errors are logged and swallowed so one strand's failure never
5291
- * costs the others their refresh.
5791
+ * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings that are
5792
+ * due, union their answers with this strand's peer-book addresses, and merge the
5793
+ * lot into the strand's address book. Errors are logged and swallowed so one
5794
+ * strand's failure never costs the others their refresh.
5292
5795
  *
5293
- * `targets` may be EMPTY — a solo node, or one whose only control peers are strangers.
5294
- * The cross-party addresses still have to be re-merged in that case: they were learned
5295
- * at formation and can never be re-resolved, so this periodic re-merge is the ONLY
5296
- * thing standing between them and the peerStore's one-hour expiry. A strand with
5297
- * neither a sibling to ask nor a contact entry does nothing and leaves its throttle
5298
- * unstamped, so the next reconcile tick retries rather than sitting out the whole
5299
- * refresh interval having done nothing.
5796
+ * The peer book merges on EVERY pass, whether or not any sibling is due or
5797
+ * connected: nothing re-resolves another party's addresses, so this re-merge is the
5798
+ * ONLY thing standing between them and the peerStore's one-hour expiry — and it is
5799
+ * also how a re-formation's freshly carried addresses reach a running strand on the
5800
+ * next tick. A strand with nobody due and no book entry does nothing.
5300
5801
  */
5301
5802
  async refreshOneStrandPeerAddrs(strandId, targets, now) {
5302
5803
  const controlNode = this.controlNode;
@@ -5304,33 +5805,57 @@ export class CadreNode {
5304
5805
  if (!controlNode || !strandNode) {
5305
5806
  return;
5306
5807
  }
5307
- const contactAddrs = this.crossPartyStrandAddrs.get(strandId) ?? [];
5308
- if (targets.length === 0 && contactAddrs.length === 0) {
5309
- return;
5310
- }
5311
5808
  try {
5809
+ // Inside the try: a strand node torn down mid-pass may throw on any read, and one
5810
+ // strand's failure must never cost the others their refresh.
5811
+ const strandPeerId = strandNode.peerId.toString();
5812
+ const contactAddrs = this.strandPeerBookAddrs(strandId, strandPeerId);
5813
+ const due = targets.filter((target) => now >= (this.strandAddrAskDueAt.get(peerStrandKey(target.peerId, strandId)) ?? 0));
5312
5814
  // The running strand node's own peerId is the delegate to announce — see
5313
5815
  // the relationship note on refreshStrandPeerAddrs.
5314
- const siblingAddrs = targets.length === 0
5816
+ const siblingAddrs = due.length === 0
5315
5817
  ? []
5316
- : await collectStrandAddrs(controlNode, [...targets], strandId, {
5317
- delegatePeerId: strandNode.peerId.toString()
5318
- });
5319
- // Stamped on the pass having happened, not on its answer: the fan-out is
5320
- // what the throttle exists to bound, and an all-empty round is a normal
5321
- // steady state for a strand no connected sibling currently runs.
5322
- this.strandPeerAddrRefreshAt.set(strandId, now);
5818
+ : await this.askSiblingsForStrandAddrs(controlNode, due, strandId, strandPeerId, now);
5323
5819
  // Re-read the instance after the await — a strand stopped mid-pass (or one
5324
5820
  // already restarted onto a new node) must never have its store written to.
5325
5821
  if (!this._running || this.strandManager.getInstance(strandId)?.libp2pNode !== strandNode) {
5326
5822
  return;
5327
5823
  }
5328
- await this.mergeStrandPeerAddrs(strandNode, unionAddrs(siblingAddrs, contactAddrs), strandId);
5824
+ const addrs = unionAddrs(siblingAddrs, contactAddrs);
5825
+ if (addrs.length === 0) {
5826
+ return;
5827
+ }
5828
+ // NOTE: with no sibling due this still merges the whole book — up to 16 peers ×
5829
+ // 16 addrs, so 16 `peerStore.merge` calls per running strand per 15 s tick. An
5830
+ // unchanged address set writes nothing in libp2p's persistent peer store, so it
5831
+ // is cheap today; if many strands run at once, merge only book entries changed
5832
+ // since the last tick plus those nearing the one-hour expiry.
5833
+ await this.mergeStrandPeerAddrs(strandNode, addrs, strandId);
5329
5834
  }
5330
5835
  catch (error) {
5331
5836
  log('refreshStrandPeerAddrs: strand %s refresh failed (continuing): %o', strandId, error);
5332
5837
  }
5333
5838
  }
5839
+ /**
5840
+ * RPC `due` siblings for `strandId` and set each one's next due time from its own
5841
+ * outcome: an answer, even an empty one, waits the full refresh interval; anything
5842
+ * else retries in {@link STRAND_PEER_ADDR_RETRY_MS} (or the refresh interval, if
5843
+ * configured shorter). Stamped before the caller
5844
+ * re-checks that the strand is still running, so a strand torn down mid-pass still
5845
+ * records who was asked.
5846
+ */
5847
+ async askSiblingsForStrandAddrs(controlNode, due, strandId, delegatePeerId, now) {
5848
+ const { addrs, outcomes } = await this.collectSiblingStrandAddrs(controlNode, [...due], strandId, delegatePeerId);
5849
+ const refreshMs = this.config.network?.controlCohort?.strandAddrRefreshMs ?? STRAND_PEER_ADDR_REFRESH_MS;
5850
+ // A configured refresh shorter than the retry must not leave a failing sibling
5851
+ // waiting longer than a healthy one.
5852
+ const retryMs = Math.min(refreshMs, STRAND_PEER_ADDR_RETRY_MS);
5853
+ for (const [peerId, outcome] of outcomes) {
5854
+ const waitMs = siblingAnswered(outcome) ? refreshMs : retryMs;
5855
+ this.strandAddrAskDueAt.set(peerStrandKey(peerId, strandId), now + waitMs);
5856
+ }
5857
+ return addrs;
5858
+ }
5334
5859
  /**
5335
5860
  * Merge strand-network addresses into ONE strand node's libp2p address book,
5336
5861
  * attributed per peer. Best-effort throughout: an address-book write must never
@@ -5350,14 +5875,14 @@ export class CadreNode {
5350
5875
  * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
5351
5876
  * `backlog/strand-network-nat-relay-reachability`.
5352
5877
  *
5353
- * NOTE: one input does NOT age out — the cross-party addresses a formation
5354
- * carried back ({@link crossPartyStrandAddrs}). Nothing can re-resolve them, so
5355
- * {@link refreshStrandPeerAddrs} re-merges the same stored list every pass, which
5356
- * also means a junk entry from a responder survives for the node's lifetime rather
5357
- * than an hour. Bounded to {@link MAX_STRAND_ADDRS} per strand, from a party this
5358
- * node deliberately formed a strand with, and still authority-free — so the
5359
- * exposure is a handful of failed dials, not a trust hole. Evict on
5360
- * `unpublishStrand` if that ever stops being an acceptable price.
5878
+ * NOTE: one input ages out on a slower clock — the strand peer book's entries
5879
+ * ({@link strandPeerBookStore}). Nothing can re-resolve another party's addresses,
5880
+ * so {@link refreshStrandPeerAddrs} re-merges the book every pass, which means a
5881
+ * junk entry from a responder or a peer's identify survives until the book drops it
5882
+ * (14 days unrefreshed, or the peer's next connection replacing the entry) rather
5883
+ * than an hour. Bounded to 16 peers × 16 addrs per strand, forgotten on
5884
+ * `unpublishStrand` / `forgetJoinedStrand`, and still authority-free — so the
5885
+ * exposure is a handful of failed dials, not a trust hole.
5361
5886
  */
5362
5887
  async mergeStrandPeerAddrs(strandNode, addrs, strandId) {
5363
5888
  const counts = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
@@ -5423,6 +5948,132 @@ export class CadreNode {
5423
5948
  }
5424
5949
  return orderSignalingFirst(addrs);
5425
5950
  }
5951
+ /**
5952
+ * Leave a strand joined from another party, for the whole party: remove its party-wide
5953
+ * `JoinedStrand` row (owner-signed, with a `Revocation` tombstone), then this machine's
5954
+ * unpublished record, its strand peer book entries and any session-kept entry, then
5955
+ * {@link stopStrand} it here. Every other machine's watcher then sees the row gone and
5956
+ * detaches the strand, a storage replica included; a machine offline at the time reaps the
5957
+ * row once the tombstone reaches it. The joiner's counterpart of {@link unpublishStrand},
5958
+ * which only works on a row the party's own `Strand` table holds. For one of those, or for
5959
+ * a join still local-only, there is no party-wide row and this is a local forget plus
5960
+ * {@link stopStrand}.
5961
+ *
5962
+ * The other members of the strand are not told, and this party's membership row in the
5963
+ * strand stays, so a later re-formation reuses the same identity.
5964
+ *
5965
+ * @throws when a party-wide row exists and this machine cannot sign its removal (not an
5966
+ * enrolled owner) — {@link stopStrand} stops the strand on this machine only — or when
5967
+ * the party-wide table cannot be read.
5968
+ */
5969
+ async forgetJoinedStrand(strandId) {
5970
+ if (!this._running) {
5971
+ throw new Error('CadreNode not running');
5972
+ }
5973
+ await this.joinedStrands.leave(strandId);
5974
+ this.forgetStrandPeers(strandId, 'forgetJoinedStrand');
5975
+ await this.stopStrand(strandId);
5976
+ }
5977
+ /**
5978
+ * Record `row` as a join from another party when this party's control database names it
5979
+ * neither as its own strand nor as a party-wide join — the one case nothing else would
5980
+ * re-offer after a restart (`JoinedStrandSession.rememberForeign`).
5981
+ *
5982
+ * Best-effort, for {@link addStrand}: a failure costs the strand its re-offer after the
5983
+ * next restart, not this attach, so it is reported and the attach goes on.
5984
+ * {@link formStrand}, whose join nothing else would name, fails loudly instead.
5985
+ *
5986
+ * NOTE: "no control row" also describes a row the party has just unpublished. A claim
5987
+ * of such a row the app kept from an earlier offer, made after the watcher already
5988
+ * withdrew it, is remembered as a join and survives the removal on this machine until
5989
+ * {@link forgetJoinedStrand}. Contrived today (a claim of a row the node itself is still
5990
+ * offering skips this method); if it shows up, check the strand's `Revocation`
5991
+ * tombstone here before recording.
5992
+ */
5993
+ async rememberForeignStrand(row) {
5994
+ if (!this.joinedStrands || !this.controlDatabase) {
5995
+ return;
5996
+ }
5997
+ try {
5998
+ await this.joinedStrands.rememberForeign(row);
5999
+ }
6000
+ catch (error) {
6001
+ console.warn(`addStrand(${row.Id}): could not remember this strand as joined from another party, ` +
6002
+ 'so it will not be re-offered after a restart:', error);
6003
+ }
6004
+ }
6005
+ /**
6006
+ * Self-revocation arm of the joined-strand records: a party removed from a strand must
6007
+ * not re-attach it on every launch of every machine. The strand keeps running this
6008
+ * session here — the `strand:revoked` contract is that nothing is torn down for the app —
6009
+ * and its party-wide row is queued for removal by the next connected owner reconcile pass
6010
+ * (`JoinedStrandSession.forgetAfterThisSession`). A no-op for this party's own strands,
6011
+ * which have neither record; the strand peer book is forgotten either way, since a
6012
+ * removed party must not keep dialing the strand's peers.
6013
+ *
6014
+ * NOTE: accepted tradeoff — a sibling machine that has not raised `strand:revoked` itself
6015
+ * detaches the strand (`strand:stopped`) when the party-wide removal reaches it, instead
6016
+ * of keeping it for the session, which weakens "nothing is stopped on the removed
6017
+ * machine's behalf" for siblings. Weighed against a party-wide row that brings a revoked
6018
+ * strand back on every start of every machine. Revisit if apps need the "you were
6019
+ * removed" screen on every device: the sibling could re-check its own revocation state
6020
+ * (`refreshRevocationEnforcement`) before detaching a vanished joined row.
6021
+ *
6022
+ * NOTE: a manager can re-admit a removed party directly (`addMemberByManager`), and the
6023
+ * membership loop then finishes the join on its own — but the records are gone by then,
6024
+ * so the next start does not re-attach the strand. Re-forming records it again. If direct
6025
+ * re-admission becomes a routine flow, re-record the join when the revoked-peer gate
6026
+ * clears for this node.
6027
+ */
6028
+ forgetRevokedJoin(strandId) {
6029
+ void this.joinedStrands?.forgetAfterThisSession(strandId).catch((error) => {
6030
+ log('forgetting revoked joined strand %s failed; it will be re-offered on the next start: %o', strandId, error);
6031
+ });
6032
+ // The strand's peers go with the join: a removed party must not keep dialing them.
6033
+ this.forgetStrandPeers(strandId, 'self-revocation');
6034
+ }
6035
+ /**
6036
+ * Forget a strand's entries in the peer book (see {@link strandPeerBookStore}):
6037
+ * fire-and-log, like every book write — the removal is visible synchronously by the
6038
+ * store's contract, and a failed persist costs restart survival only.
6039
+ */
6040
+ forgetStrandPeers(strandId, caller) {
6041
+ void this.strandPeerBookStore?.forget(strandId).catch((error) => {
6042
+ log('%s: forgetting strand %s peers failed (continuing): %o', caller, strandId, error);
6043
+ });
6044
+ }
6045
+ /**
6046
+ * The strand peer book's observation writer (`StartStrandConfig.onStrandPeerIdentified`):
6047
+ * a strand node identified a strand peer at `observation.addrs`, so remember it as an
6048
+ * unsigned entry seen just now. The observer never reports self, and the store binds
6049
+ * every address to the peer id before filing it.
6050
+ */
6051
+ observeStrandPeer(strandId, observation) {
6052
+ this.rememberStrandPeer(strandId, {
6053
+ peerId: observation.peerId,
6054
+ addrs: observation.addrs,
6055
+ issuedAt: 0,
6056
+ lastSeenAt: Date.now()
6057
+ }, 'observeStrandPeer');
6058
+ }
6059
+ /**
6060
+ * Merge one entry into the peer book. Fire-and-log: the entry is visible
6061
+ * synchronously by the store's contract, and the promise tracks durability only. A
6062
+ * persist failure costs restart survival, never this session's seed — the same trade
6063
+ * {@link retainDialTarget} makes.
6064
+ */
6065
+ rememberStrandPeer(strandId, entry, caller) {
6066
+ const store = this.strandPeerBookStore;
6067
+ if (!store) {
6068
+ // Unreachable in production: start() builds the store before any strand can
6069
+ // launch or form. Logged rather than thrown, for the same reason as retainDialTarget.
6070
+ log('%s: no strand peer book yet — not remembering %s for strand %s', caller, entry.peerId, strandId);
6071
+ return;
6072
+ }
6073
+ void store.merge(strandId, entry).catch((error) => {
6074
+ log('%s: persisting strand %s peer %s failed (continuing): %o', caller, strandId, entry.peerId, error);
6075
+ });
6076
+ }
5426
6077
  /**
5427
6078
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
5428
6079
  * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
@@ -5431,7 +6082,9 @@ export class CadreNode {
5431
6082
  * id in the watcher (`StrandWatcher.suppressStrand`) and drops it from
5432
6083
  * {@link getDiscoveredStrands}, so neither a later poll nor a drain can undo a
5433
6084
  * deliberate stop. Only an explicit {@link addStrand} does, which is the caller
5434
- * reversing its own decision. Party-wide removal is {@link unpublishStrand}.
6085
+ * reversing its own decision. Party-wide removal is {@link unpublishStrand}. A strand
6086
+ * joined from another party comes back the same way, from its remembered join;
6087
+ * {@link forgetJoinedStrand} is how to leave one for good.
5435
6088
  */
5436
6089
  async stopStrand(strandId) {
5437
6090
  if (!this._running) {
@@ -5509,10 +6162,19 @@ export class CadreNode {
5509
6162
  void this.pushFanoutService?.notify(strandId, reason);
5510
6163
  }
5511
6164
  /**
5512
- * Force wake a hibernating strand
6165
+ * Force wake a hibernating strand. A requested wake is activity, recorded before the
6166
+ * wake starts: a check-in holding the strand — or rebuilding it, a rebuild this wake then
6167
+ * joins — leaves it up instead of re-quiescing it at the end of its window. Once awake the
6168
+ * strand's idle countdown runs, so it hibernates again if nothing uses it.
5513
6169
  */
5514
6170
  async wakeStrand(strandId) {
5515
- await this.hibernationManager.wakeStrand(strandId);
6171
+ const instance = this.strandManager.getInstance(strandId);
6172
+ if (!instance) {
6173
+ log('wakeStrand: strand %s not found; nothing to wake', strandId);
6174
+ return;
6175
+ }
6176
+ instance.lastActivity = new Date();
6177
+ await this.hibernationManager.wakeStrand(instance);
5516
6178
  }
5517
6179
  // ============================================================================
5518
6180
  // Mobile background lifecycle primitives
@@ -5528,7 +6190,7 @@ export class CadreNode {
5528
6190
  * Routes through {@link HibernationManager.forceHibernate}, which cancels the
5529
6191
  * strand's pending idle/hibernate (and check-in) timers — so a stale timer
5530
6192
  * can't re-fire on or resurrect the strand — then runs the same `onHibernate`
5531
- * path as the timer (`quiesceStrand` + `status='hibernating'` +
6193
+ * path as the timer (`quiesceStrand`, which marks it `hibernating`, then
5532
6194
  * `strand:hibernating`). Unlike the timer path it does NOT re-arm check-ins:
5533
6195
  * the strand stays down until the caller drives a wake (e.g. {@link serviceWake}).
5534
6196
  */
@@ -5589,9 +6251,14 @@ export class CadreNode {
5589
6251
  * strand share one in-flight operation ({@link serviceWakePromises}), and the
5590
6252
  * underlying resume coalesces with a racing push-wake via
5591
6253
  * {@link HibernationManager}'s wake coalescing — one runtime build, one window,
5592
- * one re-hibernate decision. Returns `{ serviced: false }` (never throws) when
5593
- * the node is not running or the strand is unknown, and surfaces a resume
5594
- * failure as `{ serviced: true, hadActivity: false }` after re-hibernating.
6254
+ * one re-hibernate decision. A wake or activity from elsewhere that lands during
6255
+ * the resume or the window leaves the strand up; this call's own wake does not.
6256
+ * Afterwards a strand left up has its idle countdown running, and a re-hibernated one
6257
+ * gets back the check-in chain this call interrupted — it never gains one it lacked,
6258
+ * so a strand the mobile runner force-hibernated stays down until the next push.
6259
+ * Returns `{ serviced: false }` (never throws) when
6260
+ * the node is not running or the strand is unknown, and surfaces a wake or
6261
+ * window failure as `{ serviced: true, hadActivity: false }`.
5595
6262
  *
5596
6263
  * @param strandId - the strand a push said has pending activity.
5597
6264
  * @param opts.windowMs - override the live-window duration (defaults to the
@@ -5625,30 +6292,58 @@ export class CadreNode {
5625
6292
  log('serviceWake: strand %s unknown to this node; not serviced', strandId);
5626
6293
  return { strandId, serviced: false, hadActivity: false };
5627
6294
  }
5628
- // Already live (active or idle — both retain their runtime): servicing is a
5629
- // no-op success. Do NOT run a window that would re-hibernate a strand the app
5630
- // may be actively using, and do NOT rebuild a second runtime.
5631
- if (instance.libp2pNode || instance.database) {
6295
+ // Already live (active or idle — both retain their runtime) or coming up (a launch,
6296
+ // or a check-in with its own window): servicing is a no-op success. Do NOT run a
6297
+ // window that would re-hibernate a strand the app may be actively using, and do NOT
6298
+ // rebuild a second runtime. `'starting'` counts before any handle is attached. A strand
6299
+ // being quiesced does not count: the wake below rebuilds it after the quiesce.
6300
+ if (!this.strandManager.isQuiescing(strandId)
6301
+ && (instance.status === 'starting' || instance.libp2pNode || instance.database)) {
5632
6302
  log('serviceWake: strand %s already live; no-op success', strandId);
5633
6303
  return { strandId, serviced: true, hadActivity: true };
5634
6304
  }
6305
+ // Before the resume, as the check-in takes it (see handleStrandCheckIn).
6306
+ // NOTE: a `wakeStrand` already resuming when this runs stamped its activity before this
6307
+ // mark, so the probe joins it without counting it and the window can re-hibernate a strand
6308
+ // that wake asked to keep up; if foreground wakes and serviceWake overlap in practice, mark
6309
+ // before the joined wake's stamp instead.
6310
+ const activityMark = instance.lastActivity;
6311
+ const windowMs = opts?.windowMs ?? this.config.hibernation?.checkInWindowMs ?? DEFAULT_CHECKIN_WINDOW_MS;
6312
+ try {
6313
+ return await this.wakeAndHoldForService(instance, activityMark, windowMs);
6314
+ }
6315
+ finally {
6316
+ // After the window (or a failure) decided the state: the idle countdown if the strand
6317
+ // stayed up, else the check-in chain this probe interrupted, if any.
6318
+ this.hibernationManager.endProbe(instance);
6319
+ }
6320
+ }
6321
+ /**
6322
+ * The wake and window of {@link runServiceWake}. A failure in either — the network
6323
+ * unreachable inside a Doze grant, say — must not throw out of a background task, so it
6324
+ * reports no activity. Each stage cleans up only what it owns.
6325
+ */
6326
+ async wakeAndHoldForService(instance, activityMark, windowMs) {
6327
+ const { strandId } = instance;
5635
6328
  try {
5636
- // Coalesced resume: routes through wakeStrand → HibernationManager.beginWake
5637
- // so a racing push-wake shares this single runtime build.
5638
- await this.wakeStrand(strandId);
5639
- const windowMs = opts?.windowMs ?? this.config.hibernation?.checkInWindowMs ?? DEFAULT_CHECKIN_WINDOW_MS;
5640
- const hadActivity = await this.runWakeWindow(instance, windowMs);
6329
+ // Coalesced resume: HibernationManager.beginWake, so a racing push-wake shares this
6330
+ // single runtime build. Not through `wakeStrand`, which records the wake as activity:
6331
+ // this probe's own wake must not count as a reason to stay up.
6332
+ await this.hibernationManager.probeWake(instance);
6333
+ }
6334
+ catch (error) {
6335
+ // handleStrandWake has already re-hibernated a failed rebuild, and left alone a strand
6336
+ // it failed before building, which may be a check-in's.
6337
+ log('serviceWake: wake of strand %s failed: %o', strandId, error);
6338
+ return { strandId, serviced: true, hadActivity: false };
6339
+ }
6340
+ try {
6341
+ const hadActivity = await this.runWakeWindow(instance, activityMark, windowMs);
5641
6342
  return { strandId, serviced: true, hadActivity };
5642
6343
  }
5643
6344
  catch (error) {
5644
- // Resume failing mid-window (network unreachable inside a Doze grant, etc.)
5645
- // must not throw out of a background task: re-hibernate and report no
5646
- // activity, mirroring handleStrandCheckIn's re-hibernate-on-error.
5647
6345
  log('serviceWake: strand %s failed during wake window; re-hibernating: %o', strandId, error);
5648
- await this.strandManager.quiesceStrand(strandId).catch((cleanupErr) => {
5649
- log('serviceWake cleanup quiesce for strand %s failed: %o', strandId, cleanupErr);
5650
- });
5651
- instance.status = 'hibernating';
6346
+ await this.rehibernateAfterFailedResume(instance, 'serviceWake');
5652
6347
  return { strandId, serviced: true, hadActivity: false };
5653
6348
  }
5654
6349
  }
@@ -5681,8 +6376,8 @@ export class CadreNode {
5681
6376
  *
5682
6377
  * BUDGET: the supervisor's first attempt is what `start()` waits on — deliberately the drive's
5683
6378
  * ordinary deadline rather than a boot-specific one. That deadline is now COUNTED, four link
5684
- * round trips at the declared `network.linkRoundTripMs` (14 s at its default, where it was a
5685
- * fixed 10 s): a healthy dial-plus-reserve is sub-second even over a WAN, so this is slack for
6379
+ * round trips at the declared `network.linkRoundTripMs` plus the relay's two admission
6380
+ * 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
5686
6381
  * a slow link, while going much longer would make a dead relay indistinguishable from a hung
5687
6382
  * start and much shorter would fail nodes on links that were merely slow. A host that declares
5688
6383
  * a slower link lengthens it without touching this path — `link-budget.ts`. The retries carry
@@ -5821,7 +6516,7 @@ export class CadreNode {
5821
6516
  *
5822
6517
  * @param ownerPrivateKey - The owner's private key (base64url encoded)
5823
6518
  */
5824
- initializeSeedBootstrap(ownerPrivateKey) {
6519
+ async initializeSeedBootstrap(ownerPrivateKey) {
5825
6520
  if (!this.controlNode || !this.controlDatabase) {
5826
6521
  throw new Error('CadreNode must be started before initializing seed bootstrap');
5827
6522
  }
@@ -5845,18 +6540,16 @@ export class CadreNode {
5845
6540
  .trust(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
5846
6541
  .catch((error) => log('Trusted-owner genesis anchor persist failed: %o', error));
5847
6542
  }
5848
- this.seedBootstrapService = new SeedBootstrapService({
6543
+ await this.installSeedBootstrapService(new SeedBootstrapService({
5849
6544
  partyId: this.config.controlNetwork.partyId,
5850
6545
  ownerPrivateKey,
5851
6546
  inviteAddressResolver: () => this.resolveInviteAddresses(),
5852
- dialBudget: this.controlDialBudget(),
6547
+ ...this.seedServiceBudgets(),
5853
6548
  trustPolicy: this.config.seedTrustPolicy,
5854
6549
  // Seed trust anchors on the node-local store (seeded just above with this
5855
6550
  // node's own genesis key), never on the replicated OwnerKey table.
5856
6551
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
5857
- });
5858
- this.seedBootstrapService.setEventCallbacks(this.seedEventCallbacks());
5859
- this.seedBootstrapService.initialize(this.controlNode, this.controlDatabase);
6552
+ }), this.controlNode, this.controlDatabase);
5860
6553
  log('Seed bootstrap service initialized');
5861
6554
  }
5862
6555
  /**
@@ -6053,7 +6746,8 @@ export class CadreNode {
6053
6746
  * is reachable through its circuit-relay address), dials `WAKE_PROTOCOL`, sends
6054
6747
  * the {@link WakeRequest}, and returns the peer's {@link WakeAck}. The receiver
6055
6748
  * gates the request on cadre membership and only resumes a strand it already
6056
- * participates in.
6749
+ * participates in; it acks once it has decided, before the strand is up. The
6750
+ * dial deadlines derive from this node's `network.linkRoundTripMs`.
6057
6751
  *
6058
6752
  * @param targetPeerId - The hibernating cadre peer to wake.
6059
6753
  * @param strandId - The strand the caller knows has pending activity.
@@ -6069,14 +6763,14 @@ export class CadreNode {
6069
6763
  throw new Error(`No dialable control-network address for peer ${targetPeerId}`);
6070
6764
  }
6071
6765
  const request = { strandId, reason };
6072
- return await dialWake(this.controlNode, addrs, request);
6766
+ return await dialWake(this.controlNode, addrs, request, { linkRoundTripMs: this.config.network?.linkRoundTripMs });
6073
6767
  }
6074
6768
  /**
6075
6769
  * Enable the seed listener for receiving seeds via the /sereus/seed/1.0.0 protocol.
6076
6770
  * This is for drone nodes that need to receive seeds without being an owner.
6077
6771
  * Does not require an owner key.
6078
6772
  */
6079
- enableSeedListener() {
6773
+ async enableSeedListener() {
6080
6774
  if (!this.controlNode || !this.controlDatabase) {
6081
6775
  throw new Error('CadreNode must be started before enabling seed listener');
6082
6776
  }
@@ -6085,19 +6779,17 @@ export class CadreNode {
6085
6779
  log('Seed bootstrap service already initialized');
6086
6780
  return;
6087
6781
  }
6088
- this.seedBootstrapService = new SeedBootstrapService({
6782
+ await this.installSeedBootstrapService(new SeedBootstrapService({
6089
6783
  partyId: this.config.controlNetwork.partyId,
6090
6784
  // No owner key - this node only receives seeds
6091
6785
  inviteAddressResolver: () => this.resolveInviteAddresses(),
6092
- dialBudget: this.controlDialBudget(),
6786
+ ...this.seedServiceBudgets(),
6093
6787
  trustPolicy: this.config.seedTrustPolicy,
6094
6788
  // A listener-only node accepts a wire-delivered seed solely against this
6095
6789
  // anchor (there is no per-call override on the inbound handler): with no
6096
6790
  // genesis/invite/operator pin it authorizes nobody, which is the point.
6097
6791
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
6098
- });
6099
- this.seedBootstrapService.setEventCallbacks(this.seedEventCallbacks());
6100
- this.seedBootstrapService.initialize(this.controlNode, this.controlDatabase);
6792
+ }), this.controlNode, this.controlDatabase);
6101
6793
  log('Seed listener enabled');
6102
6794
  }
6103
6795
  /**
@@ -6106,6 +6798,30 @@ export class CadreNode {
6106
6798
  getSeedBootstrapService() {
6107
6799
  return this.seedBootstrapService;
6108
6800
  }
6801
+ /**
6802
+ * Make `service` this node's seed service and register its inbound seed handler.
6803
+ * The field is set before the registration is awaited, so a concurrent
6804
+ * {@link enableSeedListener} finds it and does not register a second handler; a
6805
+ * failed registration puts the previous service back and rethrows.
6806
+ *
6807
+ * NOTE: libp2p's registrar stores the handler before its peer-store merge, so a
6808
+ * failed merge leaves SEED_PROTOCOL registered with no service owning it and a retry
6809
+ * here rejects as a duplicate; if peer-store writes can fail in practice, unhandle on
6810
+ * a non-duplicate failure (never on a duplicate — that handler belongs to someone else).
6811
+ */
6812
+ async installSeedBootstrapService(service, controlNode, controlDatabase) {
6813
+ service.setEventCallbacks(this.seedEventCallbacks());
6814
+ const previous = this.seedBootstrapService;
6815
+ this.seedBootstrapService = service;
6816
+ try {
6817
+ await service.initialize(controlNode, controlDatabase);
6818
+ }
6819
+ catch (error) {
6820
+ if (this.seedBootstrapService === service)
6821
+ this.seedBootstrapService = previous;
6822
+ throw error;
6823
+ }
6824
+ }
6109
6825
  /**
6110
6826
  * The event callbacks every {@link SeedBootstrapService} this node owns is
6111
6827
  * wired with — shared by the owner-capable service ({@link initializeSeedBootstrap})
@@ -6320,7 +7036,7 @@ export class CadreNode {
6320
7036
  const tempService = new SeedBootstrapService({
6321
7037
  partyId: seed.partyId,
6322
7038
  trustPolicy: this.config.seedTrustPolicy,
6323
- dialBudget: this.controlDialBudget(),
7039
+ ...this.seedServiceBudgets(),
6324
7040
  // The anchor is node-scoped, not service-scoped: a throwaway service
6325
7041
  // must consult (and persist an accepted signer into) the SAME store the
6326
7042
  // persistent one would, or a cold-start enrollment via this path would
@@ -6328,7 +7044,7 @@ export class CadreNode {
6328
7044
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
6329
7045
  });
6330
7046
  if (this.controlNode && this.controlDatabase) {
6331
- tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
7047
+ await tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
6332
7048
  }
6333
7049
  const tempResult = await tempService.applySeed(seed, options);
6334
7050
  this.noteAppliedSeed(tempResult, seed);
@@ -6500,10 +7216,10 @@ export class CadreNode {
6500
7216
  const tempService = new SeedBootstrapService({
6501
7217
  partyId: invite.partyId,
6502
7218
  trustPolicy: this.config.seedTrustPolicy,
6503
- dialBudget: this.controlDialBudget(),
7219
+ ...this.seedServiceBudgets(),
6504
7220
  });
6505
7221
  if (this.controlNode && this.controlDatabase) {
6506
- tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
7222
+ await tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
6507
7223
  }
6508
7224
  await tempService.dialInvite(invite);
6509
7225
  return;
@@ -6519,14 +7235,20 @@ export class CadreNode {
6519
7235
  *
6520
7236
  * @param options - Configuration for the solicitation service
6521
7237
  */
6522
- initializeStrandSolicitation(options) {
7238
+ async initializeStrandSolicitation(options) {
6523
7239
  if (!this.controlNode) {
6524
7240
  throw new Error('CadreNode must be started before initializing strand solicitation');
6525
7241
  }
6526
- this.strandSolicitationService = new StrandSolicitationService({
7242
+ const service = new StrandSolicitationService({
6527
7243
  ...options,
6528
7244
  partyId: this.config.controlNetwork.partyId,
6529
7245
  cadrePeerAddrs: this.getMultiaddrs(),
7246
+ // Every formation deadline derives from this node's declared link, like every other
7247
+ // dial budget here; a caller's own declaration on the formation config still wins.
7248
+ formationConfig: {
7249
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
7250
+ ...options?.formationConfig
7251
+ },
6530
7252
  // Overrides any caller-supplied hook, exactly like partyId/cadrePeerAddrs: only
6531
7253
  // this node can say which strand-network addresses it is actually listening on,
6532
7254
  // and a wrong answer here seeds a joiner's mesh with addresses that reach nobody.
@@ -6534,10 +7256,23 @@ export class CadreNode {
6534
7256
  // Overridden on the same grounds: only this node holds the running strand
6535
7257
  // instance's database and this party's `StrandPartyKey` identity, and a wrong
6536
7258
  // issuer here would admit joiners under someone else's authority.
6537
- issueMembershipInvite: (strandId) => this.issueStrandMembershipInvite(strandId)
7259
+ issueMembershipInvite: (strandId, signal) => this.issueStrandMembershipInvite(strandId, signal)
6538
7260
  });
6539
- // Register as responder on the control node
6540
- this.strandSolicitationService.registerResponder(this.controlNode);
7261
+ // Set before the registration is awaited, so a concurrent createOpenInvitation /
7262
+ // formStrand uses this service instead of building a second one whose handler
7263
+ // would collide; a failed registration puts the previous service back.
7264
+ // NOTE: same stray-handler caveat as installSeedBootstrapService — a failed
7265
+ // peer-store merge leaves the formation handler registered, so a retry is a duplicate.
7266
+ const previous = this.strandSolicitationService;
7267
+ this.strandSolicitationService = service;
7268
+ try {
7269
+ await service.registerResponder(this.controlNode);
7270
+ }
7271
+ catch (error) {
7272
+ if (this.strandSolicitationService === service)
7273
+ this.strandSolicitationService = previous;
7274
+ throw error;
7275
+ }
6541
7276
  log('Strand solicitation service initialized');
6542
7277
  }
6543
7278
  /**
@@ -6557,7 +7292,7 @@ export class CadreNode {
6557
7292
  ) {
6558
7293
  if (!this.strandSolicitationService) {
6559
7294
  // Create a temporary service for creating invitations
6560
- this.initializeStrandSolicitation();
7295
+ await this.initializeStrandSolicitation();
6561
7296
  }
6562
7297
  const bootstrap = this.getMultiaddrs();
6563
7298
  if (bootstrap.length === 0) {
@@ -6577,10 +7312,10 @@ export class CadreNode {
6577
7312
  throw new Error('CadreNode must be started before forming strands');
6578
7313
  }
6579
7314
  if (!this.strandSolicitationService) {
6580
- this.initializeStrandSolicitation();
7315
+ await this.initializeStrandSolicitation();
6581
7316
  }
6582
7317
  const result = await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
6583
- this.recordCrossPartyStrandAddrs(result.strandId, result.strandAddrs);
7318
+ this.recordFormationStrandPeers(result.strandId, result.strandAddrs);
6584
7319
  // A closed host strand's approval carries the joiner's own membership invitation —
6585
7320
  // persist this party's identity key and stage the invitation for bring-up to redeem.
6586
7321
  // Runs AFTER the addr recording so a persistence failure (which throws — see the
@@ -6588,8 +7323,36 @@ export class CadreNode {
6588
7323
  if (result.membershipInvite) {
6589
7324
  await this.adoptFormationMembershipInvite(result.strandId, result.membershipInvite);
6590
7325
  }
7326
+ await this.rememberFormedStrand(result);
6591
7327
  return result;
6592
7328
  }
7329
+ /**
7330
+ * Remember the strand a formation just joined (see {@link joinedStrandStore}), so it
7331
+ * is re-offered after a restart even when the app is killed before its `addStrand`, and
7332
+ * published party-wide by the next connected owner reconcile pass. A re-join also
7333
+ * cancels a party-wide removal a self-revocation queued for the strand.
7334
+ * Last in {@link formStrand}, after the membership adoption, so a failure here leaves
7335
+ * the party key and staged invitation in place for the re-formation it asks for.
7336
+ *
7337
+ * Throws on failure, like {@link adoptFormationMembershipInvite}: the formation's
7338
+ * token is spent, and a join no store names would vanish at the next restart.
7339
+ */
7340
+ async rememberFormedStrand(result) {
7341
+ try {
7342
+ await this.joinedStrands.remember({
7343
+ Id: result.strandId,
7344
+ Type: result.memberPrivateKey ? 'c' : 'o',
7345
+ MemberPrivateKey: result.memberPrivateKey ?? null,
7346
+ joinedAt: Date.now()
7347
+ });
7348
+ }
7349
+ catch (error) {
7350
+ throw new Error(`Formation for strand ${result.strandId} was approved (its one-time token is spent), but ` +
7351
+ 'remembering the join in this node\'s joined-strand store failed, so the strand would not ' +
7352
+ 'come back after a restart. Fix the store (usually the configured keyStore), then redeem a ' +
7353
+ 'fresh invitation.', { cause: error });
7354
+ }
7355
+ }
6593
7356
  /**
6594
7357
  * Adopt an approved closed-strand formation's membership half on the JOINER:
6595
7358
  *
@@ -6667,9 +7430,15 @@ export class CadreNode {
6667
7430
  * founder's publish/launch paths mint it, so this is a not-yet-converged sibling.)
6668
7431
  * The manager maps the throw to a clean retryable rejection BEFORE the formation
6669
7432
  * token is spent.
6670
- * - Closed host strand with no running local instance/database → throw, same mapping:
6671
- * a joiner admitted without an invitation would look joined and never become a
6672
- * member, and a responder not running the strand cannot serve its sync anyway.
7433
+ * - Closed host strand whose runtime is HIBERNATING or being quiesced → woken first
7434
+ * ({@link wakeHostStrandForFormation}, bounded by `signal`), then issued as below. In
7435
+ * every state the redemption counts as activity, so the host stays up for the
7436
+ * joiner's first sync.
7437
+ * - Closed host strand with no running local instance/database (never launched, still
7438
+ * starting, or a hibernating one whose wake failed or outran `signal`) →
7439
+ * throw, same mapping: a joiner admitted without an invitation would look joined and
7440
+ * never become a member, and a responder not running the strand cannot serve its sync
7441
+ * anyway.
6673
7442
  * - Closed host strand whose LIVE rows carry the pre-split fingerprint
6674
7443
  * (`assertNotPreSplitStrand`) → throw `PreSplitStrandIdentityError`, same mapping as
6675
7444
  * the recorded refusal. Covers the responders that never ran a refused founder launch:
@@ -6679,7 +7448,12 @@ export class CadreNode {
6679
7448
  * permanent diagnosis. Identity is checked BEFORE the runtime: it is the cheaper read
6680
7449
  * and the more actionable diagnosis when both are missing (a missing runtime is
6681
7450
  * transient, a missing identity is not), and it keeps the branch reachable without
6682
- * standing a strand runtime up.
7451
+ * standing a strand runtime up. For the same reason every control-database check runs
7452
+ * before a wake: a strand that cannot issue anyway is not woken.
7453
+ *
7454
+ * `signal` is the formation's provisioning budget. Once it has aborted nothing is
7455
+ * issued — the joiner has already been told to retry, and an invitation written now
7456
+ * would only sit in the strand until it expires.
6683
7457
  *
6684
7458
  * The invitation expires `MEMBERSHIP_INVITE_TTL_MS` from now — see that constant for
6685
7459
  * the slow-joiner / lost-result tradeoff.
@@ -6694,7 +7468,7 @@ export class CadreNode {
6694
7468
  * `MEMBERSHIP_INVITE_UNAVAILABLE_REASON` forever; that flow needs manager delegation,
6695
7469
  * not a retry.
6696
7470
  */
6697
- async issueStrandMembershipInvite(strandId) {
7471
+ async issueStrandMembershipInvite(strandId, signal) {
6698
7472
  if (!this.controlDatabase) {
6699
7473
  throw new Error(`Cannot issue a membership invitation for strand ${strandId}: control database unavailable`);
6700
7474
  }
@@ -6716,50 +7490,101 @@ export class CadreNode {
6716
7490
  throw new Error(`Cannot issue a membership invitation for closed strand ${strandId}: this party holds ` +
6717
7491
  'no StrandPartyKey row for it (identity not yet converged from the machine that published it)');
6718
7492
  }
7493
+ await this.wakeHostStrandForFormation(strandId, signal);
6719
7494
  const db = this.strandManager.getInstance(strandId)?.database?.getDatabase();
6720
7495
  if (!db) {
6721
7496
  throw new Error(`Cannot issue a membership invitation for closed strand ${strandId}: its runtime is ` +
6722
- 'not live on this responder (not launched, hibernating, or quiescing)');
7497
+ 'not live on this responder (not launched, or still starting)');
6723
7498
  }
6724
7499
  if (row.MemberPrivateKey) {
6725
7500
  await assertNotPreSplitStrand(db, strandId, strandMemberKeyPair(row.MemberPrivateKey).publicKeyB64);
6726
7501
  }
7502
+ if (signal?.aborted) {
7503
+ throw new Error(`Cannot issue a membership invitation for closed strand ${strandId}: the formation ` +
7504
+ 'provisioning budget expired before issuance');
7505
+ }
6727
7506
  return await issueInvite(db, {
6728
7507
  managerKeyPair: strandMemberKeyPair(partyKey),
6729
7508
  expiration: Date.now() + MEMBERSHIP_INVITE_TTL_MS
6730
7509
  });
6731
7510
  }
7511
+ /**
7512
+ * Count a bound closed-strand redemption as activity on the host strand, and wake the
7513
+ * strand when it is HIBERNATING or being quiesced (its database is closing) so the
7514
+ * membership invitation can be issued. Only reached after the formation manager has
7515
+ * authorized the redemption (token, disclosure, outside approval, seat pre-check), so only
7516
+ * a caller already entitled to the strand's member key can cause a wake. No other state is
7517
+ * woken — never launched or still starting is not something this node recovers from on
7518
+ * demand; the caller's live-database check refuses those.
7519
+ *
7520
+ * The activity is recorded in every state, through the hibernation manager rather than
7521
+ * {@link recordStrandActivity}, whose push fan-out would wake this party's phones for
7522
+ * nothing. It keeps the host up for the joiner's first sync: a live strand's idle timer
7523
+ * restarts, a check-in window that happens to have the strand live sees activity and
7524
+ * leaves it up instead of re-quiescing it, and a hibernating strand has its idle →
7525
+ * hibernate timers re-armed once the wake leaves it `active`. The explicit
7526
+ * {@link wakeStrand} coalesces onto the wake `recordActivity` began, and still wakes when
7527
+ * `recordActivity` is a no-op (hibernation disabled but the strand force-hibernated, or
7528
+ * the manager stopped).
7529
+ *
7530
+ * Bounded by `signal` (the formation's provisioning budget): when it aborts first this
7531
+ * throws — a retryable rejection, token unspent — and leaves the wake running, so the
7532
+ * joiner's retry finds the strand live.
7533
+ */
7534
+ async wakeHostStrandForFormation(strandId, signal) {
7535
+ const instance = this.strandManager.getInstance(strandId);
7536
+ if (!instance) {
7537
+ return;
7538
+ }
7539
+ this.hibernationManager.recordActivity(instance);
7540
+ if (!this.strandManager.isQuiescing(strandId) && (instance.status !== 'hibernating' || instance.database)) {
7541
+ return;
7542
+ }
7543
+ log('wakeHostStrandForFormation: waking host strand %s (%s) for an authorized formation', strandId, instance.status);
7544
+ const wake = this.wakeStrand(strandId);
7545
+ if (await resolvesBeforeAbort(wake, signal)) {
7546
+ return;
7547
+ }
7548
+ void wake.catch((error) => log('wakeHostStrandForFormation: background wake of host strand %s failed: %o', strandId, error));
7549
+ throw new Error(`Cannot issue a membership invitation for closed strand ${strandId}: its hibernating ` +
7550
+ 'runtime did not wake within the formation provisioning budget (still waking)');
7551
+ }
6732
7552
  /**
6733
7553
  * Remember the responder's strand-network addresses for a strand this node just
6734
- * formed, so the strand's discovery seed has something to work with when the app
6735
- * launches it (see {@link crossPartyStrandAddrs}).
6736
- *
6737
- * Scoped strictly to `strandId`: these addresses reach ONE strand node of ONE other
6738
- * party and must never seed another strand's mesh or the control peerStore. An empty
6739
- * list is recorded as no entry at all rather than an empty one, so a later formation
6740
- * against the same strand that DOES disclose addresses is not shadowed.
6741
- */
6742
- recordCrossPartyStrandAddrs(strandId, strandAddrs) {
7554
+ * formed in the strand peer book (see {@link strandPeerBookStore}), so the strand's
7555
+ * discovery seed has something to work with when the app launches it — and again
7556
+ * after every restart.
7557
+ *
7558
+ * The carried list is peer-agnostic (`sanitizeStrandAddrs` bounds and parses it,
7559
+ * nothing more), so it is attributed per peer here (`groupAddrsByPeerId`, the same
7560
+ * rule the address-book merge applies) and each peer becomes one unsigned entry seen
7561
+ * just now: the responder disclosed the addresses live a moment ago. An entry naming
7562
+ * no destination peer is dropped there. Scoped strictly to `strandId`: these
7563
+ * addresses reach ONE strand node of ONE other party and must never seed another
7564
+ * strand's mesh or the control peerStore. An empty list records nothing, so a later
7565
+ * formation against the same strand that DOES disclose addresses is not shadowed.
7566
+ */
7567
+ recordFormationStrandPeers(strandId, strandAddrs) {
6743
7568
  if (strandAddrs.length === 0) {
6744
7569
  log('formStrand: responder disclosed no strand addrs for %s — cross-party seed stays empty', strandId);
6745
7570
  return;
6746
7571
  }
6747
- // Capped at the same `MAX_STRAND_ADDRS` one arriving list is, because this
6748
- // ACCUMULATES: re-forming against the same strand (the documented recovery for a
6749
- // responder whose relay reservation rotated) unions a fresh list onto the old one,
6750
- // and nothing here can tell a dead entry from a live one. Without the cap, the seed
6751
- // — and the peerStore rows every refresh pass re-merges from it — would grow by up
6752
- // to 16 entries per redemption for the node's lifetime. Oldest entries fall off the
6753
- // end, which is the right end to lose: `unionAddrs` keeps the freshest list first.
6754
- const merged = unionAddrs(strandAddrs, this.crossPartyStrandAddrs.get(strandId) ?? [])
6755
- .slice(0, MAX_STRAND_ADDRS);
6756
- this.crossPartyStrandAddrs.set(strandId, merged);
6757
- // A strand already running when this lands (a re-formation) would otherwise wait out
6758
- // the rest of its ~10-minute refresh throttle before the new addresses reached its
6759
- // address book. Clearing the stamp makes the next 15 s reconcile tick merge them,
6760
- // which matters precisely because re-forming is the recovery path for a dead entry.
6761
- this.strandPeerAddrRefreshAt.delete(strandId);
6762
- log('formStrand: recorded %d cross-party strand addr(s) for %s', merged.length, strandId);
7572
+ const now = Date.now();
7573
+ let peers = 0;
7574
+ for (const [peerId, addrs] of groupAddrsByPeerId([...strandAddrs])) {
7575
+ this.rememberStrandPeer(strandId, {
7576
+ peerId,
7577
+ addrs: addrs.map((ma) => ma.toString()),
7578
+ issuedAt: 0,
7579
+ lastSeenAt: now
7580
+ }, 'formStrand');
7581
+ peers++;
7582
+ }
7583
+ // A strand already running when this lands (a re-formation) needs nothing more: the
7584
+ // refresh pass merges the book into its address book on every reconcile tick, so the
7585
+ // new addresses land within 15 s — which matters because re-forming is the recovery
7586
+ // path for a dead entry.
7587
+ log('formStrand: recorded %d cross-party strand addr(s) for %s under %d peer(s)', strandAddrs.length, strandId, peers);
6763
7588
  }
6764
7589
  /**
6765
7590
  * Encode an open invitation for out-of-band delivery (QR, link, etc.).