@serfab/cadre-core 1.8.0 → 1.10.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 (126) hide show
  1. package/dist/cadre-node.d.ts +301 -121
  2. package/dist/cadre-node.js +588 -244
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-authorization.d.ts +2 -2
  5. package/dist/control-authorization.js +1 -0
  6. package/dist/control-authorization.js.map +1 -1
  7. package/dist/control-cohort.d.ts +1 -1
  8. package/dist/control-cohort.js +1 -1
  9. package/dist/control-database.d.ts +105 -14
  10. package/dist/control-database.js +242 -73
  11. package/dist/control-database.js.map +1 -1
  12. package/dist/control-formation-recorder.d.ts +6 -6
  13. package/dist/control-formation-recorder.js +7 -8
  14. package/dist/control-formation-recorder.js.map +1 -1
  15. package/dist/control-schema.d.ts +1 -1
  16. package/dist/control-schema.js +90 -3
  17. package/dist/control-schema.js.map +1 -1
  18. package/dist/control-write-retry.d.ts +21 -15
  19. package/dist/control-write-retry.js +21 -15
  20. package/dist/control-write-retry.js.map +1 -1
  21. package/dist/index.d.ts +7 -7
  22. package/dist/index.js +16 -13
  23. package/dist/index.js.map +1 -1
  24. package/dist/invitation-bootstrap.d.ts +32 -0
  25. package/dist/invitation-bootstrap.js +30 -0
  26. package/dist/invitation-bootstrap.js.map +1 -0
  27. package/dist/link-budget.d.ts +204 -119
  28. package/dist/link-budget.js +208 -120
  29. package/dist/link-budget.js.map +1 -1
  30. package/dist/membership-connection-gater.d.ts +3 -3
  31. package/dist/membership-connection-gater.js +3 -3
  32. package/dist/node-local-snapshot.d.ts +6 -4
  33. package/dist/node-local-snapshot.js +7 -3
  34. package/dist/node-local-snapshot.js.map +1 -1
  35. package/dist/peer-dial.d.ts +18 -14
  36. package/dist/peer-dial.js +18 -14
  37. package/dist/peer-dial.js.map +1 -1
  38. package/dist/peer-join-backfill.d.ts +3 -12
  39. package/dist/peer-join-backfill.js +2 -3
  40. package/dist/peer-join-backfill.js.map +1 -1
  41. package/dist/pending-join-runner.d.ts +186 -0
  42. package/dist/pending-join-runner.js +649 -0
  43. package/dist/pending-join-runner.js.map +1 -0
  44. package/dist/push-fanout.d.ts +1 -1
  45. package/dist/push-fanout.js +1 -1
  46. package/dist/seed-bootstrap.d.ts +2 -2
  47. package/dist/seed-bootstrap.js +1 -1
  48. package/dist/strand-addr-protocol.js +2 -2
  49. package/dist/strand-formation-deadlines.d.ts +5 -5
  50. package/dist/strand-formation-deadlines.js +5 -5
  51. package/dist/strand-formation-manager.d.ts +18 -4
  52. package/dist/strand-formation-manager.js +48 -33
  53. package/dist/strand-formation-manager.js.map +1 -1
  54. package/dist/strand-formation-protocol.d.ts +71 -25
  55. package/dist/strand-formation-protocol.js +225 -51
  56. package/dist/strand-formation-protocol.js.map +1 -1
  57. package/dist/strand-formation-rejection.d.ts +94 -0
  58. package/dist/strand-formation-rejection.js +77 -0
  59. package/dist/strand-formation-rejection.js.map +1 -0
  60. package/dist/strand-fret-addrs.d.ts +54 -0
  61. package/dist/strand-fret-addrs.js +108 -0
  62. package/dist/strand-fret-addrs.js.map +1 -0
  63. package/dist/strand-instance-manager.d.ts +9 -38
  64. package/dist/strand-instance-manager.js +17 -74
  65. package/dist/strand-instance-manager.js.map +1 -1
  66. package/dist/strand-network-state-file.d.ts +12 -0
  67. package/dist/strand-network-state-file.js +38 -0
  68. package/dist/strand-network-state-file.js.map +1 -0
  69. package/dist/strand-network-state.d.ts +111 -0
  70. package/dist/strand-network-state.js +191 -0
  71. package/dist/strand-network-state.js.map +1 -0
  72. package/dist/strand-solicitation.d.ts +11 -3
  73. package/dist/strand-solicitation.js +9 -0
  74. package/dist/strand-solicitation.js.map +1 -1
  75. package/dist/types.d.ts +123 -40
  76. package/dist/types.js +5 -9
  77. package/dist/types.js.map +1 -1
  78. package/package.json +25 -24
  79. package/src/cadre-node.ts +637 -251
  80. package/src/control-authorization.ts +117 -116
  81. package/src/control-cohort.ts +1 -1
  82. package/src/control-database.ts +303 -82
  83. package/src/control-formation-recorder.ts +8 -9
  84. package/src/control-schema.ts +90 -3
  85. package/src/control-write-retry.ts +21 -15
  86. package/src/index.ts +36 -56
  87. package/src/invitation-bootstrap.ts +40 -0
  88. package/src/link-budget.ts +221 -120
  89. package/src/membership-connection-gater.ts +3 -3
  90. package/src/node-local-snapshot.ts +12 -7
  91. package/src/peer-dial.ts +18 -14
  92. package/src/peer-join-backfill.ts +5 -6
  93. package/src/pending-join-runner.ts +735 -0
  94. package/src/push-fanout.ts +1 -1
  95. package/src/seed-bootstrap.ts +2 -2
  96. package/src/strand-addr-protocol.ts +2 -2
  97. package/src/strand-formation-deadlines.ts +5 -5
  98. package/src/strand-formation-manager.ts +62 -37
  99. package/src/strand-formation-protocol.ts +1036 -803
  100. package/src/strand-formation-rejection.ts +129 -0
  101. package/src/strand-fret-addrs.ts +145 -0
  102. package/src/strand-instance-manager.ts +24 -97
  103. package/src/strand-network-state-file.ts +40 -0
  104. package/src/strand-network-state.ts +243 -0
  105. package/src/strand-solicitation.ts +17 -4
  106. package/src/types.ts +119 -40
  107. package/dist/strand-peer-book-file.d.ts +0 -12
  108. package/dist/strand-peer-book-file.js +0 -37
  109. package/dist/strand-peer-book-file.js.map +0 -1
  110. package/dist/strand-peer-book-protocol.d.ts +0 -168
  111. package/dist/strand-peer-book-protocol.js +0 -356
  112. package/dist/strand-peer-book-protocol.js.map +0 -1
  113. package/dist/strand-peer-book-swap.d.ts +0 -104
  114. package/dist/strand-peer-book-swap.js +0 -284
  115. package/dist/strand-peer-book-swap.js.map +0 -1
  116. package/dist/strand-peer-book.d.ts +0 -159
  117. package/dist/strand-peer-book.js +0 -361
  118. package/dist/strand-peer-book.js.map +0 -1
  119. package/dist/strand-peer-observer.d.ts +0 -88
  120. package/dist/strand-peer-observer.js +0 -180
  121. package/dist/strand-peer-observer.js.map +0 -1
  122. package/src/strand-peer-book-file.ts +0 -39
  123. package/src/strand-peer-book-protocol.ts +0 -466
  124. package/src/strand-peer-book-swap.ts +0 -338
  125. package/src/strand-peer-book.ts +0 -475
  126. package/src/strand-peer-observer.ts +0 -235
package/src/cadre-node.ts CHANGED
@@ -36,7 +36,9 @@ import type {
36
36
  CadrePeerVoucherFields,
37
37
  CadrePeerRow,
38
38
  PeerAddressRecord,
39
- ResolveDeviceTokenOpts
39
+ ResolveDeviceTokenOpts,
40
+ PendingJoinRow,
41
+ PendingJoinStatus
40
42
  } from './types.js';
41
43
  import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
42
44
  import { sign } from '@optimystic/quereus-plugin-crypto';
@@ -56,8 +58,7 @@ import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
56
58
  import { loadOrCreateIdentityKey } from './identity-key.js';
57
59
  import { MemoryTrustedOwnerStore, type TrustedOwnerStore, type TrustSource } from './trusted-owner-store.js';
58
60
  import { MemoryBootstrapPeerStore, type BootstrapPeerStore } from './bootstrap-peer-store.js';
59
- import { MemoryStrandPeerBookStore, type StrandPeerBookStore, type StrandPeerEntry } from './strand-peer-book.js';
60
- import type { StrandPeerObservation } from './strand-peer-observer.js';
61
+ import { MemoryStrandNetworkStateStore, type StrandNetworkStateStore } from './strand-network-state.js';
61
62
  import { MemoryEnrolledMachineStore, type EnrolledMachineStore } from './enrolled-machine-store.js';
62
63
  import {
63
64
  JoinedStrandSession,
@@ -67,6 +68,7 @@ import {
67
68
  type PartyJoinedStrandLedger
68
69
  } from './joined-strand-store.js';
69
70
  import { mergePeerAddrs, groupAddrsByPeerId, type MergeAddrsResult } from './peer-addr-book.js';
71
+ import { strandFretPeerAddrs } from './strand-fret-addrs.js';
70
72
  import { verifyCadrePeerVoucher } from './peer-authorization.js';
71
73
  import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
72
74
  import {
@@ -103,10 +105,18 @@ import {
103
105
  CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS,
104
106
  type PeerDialBudget
105
107
  } from './peer-dial.js';
106
- import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
108
+ import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
107
109
  import { EnrollmentService } from './enrollment.js';
108
110
  import { HibernationManager, type HibernationCallbacks } from './hibernation-manager.js';
109
- import { ControlDatabase, isStrandIdConflict, type RevokedRowRef } from './control-database.js';
111
+ import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type RevokedRowRef } from './control-database.js';
112
+ import { FormationPostApprovalError } from './strand-formation-rejection.js';
113
+ import {
114
+ PendingJoinRunner,
115
+ membershipInvitesToStage,
116
+ parseStoredDisclosure,
117
+ requestedPendingJoin,
118
+ type PendingJoinFields
119
+ } from './pending-join-runner.js';
110
120
  import type { ControlRetryAbandonment } from './control-retry.js';
111
121
  import { SeedBootstrapService, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
112
122
  import type { SeedTrustPolicy } from './seed-trust-policy.js';
@@ -114,6 +124,8 @@ import {
114
124
  StrandSolicitationService,
115
125
  type StrandSolicitationServiceOptions
116
126
  } from './strand-solicitation.js';
127
+ import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
128
+ import { selectInvitationSiblingAddrs, type InvitationSibling } from './invitation-bootstrap.js';
117
129
  import {
118
130
  createMembershipConnectionGater,
119
131
  DEFAULT_ENROLLMENT_WINDOW_MS,
@@ -208,8 +220,8 @@ function siblingAnswered(outcome: StrandAddrOutcome): boolean {
208
220
  * `primary` followed by the entries of `extra` it does not already contain, de-duplicated
209
221
  * and order-preserving.
210
222
  *
211
- * Used wherever a strand's discovery seed unions two sources of address strings — the
212
- * freshly-RPC'd sibling answers and the strand peer book's last-known addresses. The
223
+ * Used where a strand's discovery seed unions two sources of address strings — the
224
+ * freshly-RPC'd sibling answers and the addresses the strand's formation carried. The
213
225
  * caller passes the FRESHER source as `primary`, so a stale entry can only ever be
214
226
  * appended, never promoted ahead of a current one, and each source keeps whatever
215
227
  * signaling-first ordering it arrived with.
@@ -378,6 +390,15 @@ export class CadreNode implements SAppIdLookup {
378
390
  private enrollmentService: EnrollmentService;
379
391
  private seedBootstrapService: SeedBootstrapService | null = null;
380
392
  private strandSolicitationService: StrandSolicitationService | null = null;
393
+ /**
394
+ * The service whose formation handler is registered on the control node. Trails
395
+ * {@link strandSolicitationService} while {@link initializeStrandSolicitation} swaps a new
396
+ * one in: the field is set at once so concurrent callers reuse it, the handler only when
397
+ * its queued swap runs.
398
+ */
399
+ private registeredSolicitation: StrandSolicitationService | null = null;
400
+ /** Tail of the queued formation-handler swaps ({@link swapFormationResponder}). */
401
+ private solicitationSwaps: Promise<void> = Promise.resolve();
381
402
  private strandWakeService: StrandWakeService | null = null;
382
403
  /**
383
404
  * Control-network strand-address responder. Answers a co-cadre sibling's
@@ -615,36 +636,55 @@ export class CadreNode implements SAppIdLookup {
615
636
  private readonly strandAddrAskDueAt = new Map<string, number>();
616
637
 
617
638
  /**
618
- * Node-local strand peer book (see `strand-peer-book.ts`): per strand, the strand
619
- * peers this node has met and their last-known strand-network addresses. Three
620
- * writers: {@link recordFormationStrandPeers} files the responder's live strand
621
- * addrs a formation result carried back (`FormationResultMessage.strandAddrs`),
622
- * {@link observeStrandPeer} files every strand peer a running strand node
623
- * identifies, and the per-strand book swap (`strand-peer-book-swap.ts`, handed the
624
- * store as `StartStrandConfig.strandPeerBook`) files the node's own signed entry
625
- * plus every signed entry a strand peer sends it — the only writer whose entries
626
- * carry a signature, and the only one that can refresh a peer's addresses without
627
- * meeting that peer. Read by {@link resolveCohortSeed} (launch + hibernation resume) and
628
- * re-merged by every {@link refreshStrandPeerAddrs} pass, which is what keeps the
629
- * entries alive past the peerStore's one-hour address expiry.
630
- *
631
- * This is the ONLY cross-party discovery input there is. The strand-addr RPC that
639
+ * The strand-network addresses a formation carried back
640
+ * (`FormationResultMessage.strandAddrs`), by strand id: the responder's live strand
641
+ * node addresses, kept so the strand's first attach has something to dial. Written by
642
+ * {@link recordFormationStrandAddrs}, read by {@link resolveCohortSeed} (launch and
643
+ * hibernation resume) and by every refresh pass for the peers the strand node's FRET
644
+ * table holds no record for yet ({@link remergeUnrecordedFormationAddrs}).
645
+ *
646
+ * This is the only cross-party input a FIRST attach has. The strand-addr RPC that
632
647
  * resolves a strand's addresses is membership-gated and answers own-party siblings
633
- * only, so without the book a joiner's cohort seed for a two-party strand is empty
634
- * and the mesh never forms — and, with an in-memory book, never RE-forms after a
635
- * restart (gotchoices/sereus#18). A STORE rather than a map so an embedder can make
636
- * it outlive the process; constructed (or adopted from `config.strandPeers.store`)
637
- * by {@link initializeStrandPeerBookStore} during {@link start}, deliberately NOT
648
+ * only, so without it a joiner's seed for a two-party strand is empty and the mesh
649
+ * never forms. Every later launch has the strand node's saved network state as well
650
+ * ({@link strandNetworkStateStore}), which is what a restart re-finds the other party
651
+ * from (gotchoices/sereus#18).
652
+ *
653
+ * A re-formation replaces the strand's list. An entry survives a {@link stopStrand}
654
+ * and a stop()→start() of this node instance ({@link cleanup} leaves the map alone),
655
+ * and is dropped where the saved network state is: {@link unpublishStrand},
656
+ * {@link forgetJoinedStrand} and self-revocation.
657
+ *
658
+ * NOTE: accepted tradeoff — in memory only, so a process restart between `formStrand`
659
+ * and the strand's first launch loses the carried addresses, and the strand then
660
+ * launches with no cross-party seed until it is re-formed. Persisting the list on the
661
+ * joined-strand record was weighed and declined: it mixes address state into the join
662
+ * record, and needs an "only until the first run" rule to stop dead addresses being
663
+ * dialled on every launch. Every embedder launches straight after forming, and once
664
+ * the strand has run the saved network state covers every later restart. Revisit if an
665
+ * embedder forms and launches in separate sessions.
666
+ */
667
+ private readonly formationStrandAddrs = new Map<string, string[]>();
668
+
669
+ /**
670
+ * Node-local strand network state (see `strand-network-state.ts`): per strand, the
671
+ * state db-p2p saves for the strand's libp2p node — its FRET routing table with each
672
+ * peer's signed address record. Handed to every strand launch as
673
+ * `StartStrandConfig.networkState`; the strand node saves into it on every connection
674
+ * and re-imports it when it is next built, which is what gives a restarted strand
675
+ * node addresses for the peers it was talking to.
676
+ *
677
+ * Constructed (or adopted from `config.strandNetworkState.store`) by
678
+ * {@link initializeStrandNetworkStateStore} during {@link start}, deliberately NOT
638
679
  * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same node
639
680
  * instance — same lifecycle as {@link bootstrapPeerStore}.
640
681
  *
641
- * Entries survive a {@link stopStrand} (a stopped strand may be claimed again with
642
- * {@link addStrand}, and is rediscovered on restart) and are forgotten by
643
- * {@link unpublishStrand}, {@link forgetJoinedStrand} and self-revocation; the store
644
- * ages the rest out after 14 days and caps each strand at 16 peers, so the book is
645
- * bounded by time and by the strands this node runs rather than by its history.
682
+ * A strand's state survives a {@link stopStrand} and a hibernation quiesce, and is
683
+ * forgotten by {@link unpublishStrand}, {@link forgetJoinedStrand} and
684
+ * self-revocation. A strand detached because the watcher saw its row gone keeps its
685
+ * state (`NOTE:` on `PersistentStrandNetworkStateStore`).
646
686
  */
647
- private strandPeerBookStore: StrandPeerBookStore | null = null;
687
+ private strandNetworkStateStore: StrandNetworkStateStore | null = null;
648
688
 
649
689
  /**
650
690
  * PENDING strand membership invitations learned at formation, keyed by strandId — the
@@ -673,10 +713,26 @@ export class CadreNode implements SAppIdLookup {
673
713
  * NOTE: entries live for the node's lifetime (one small pair per formed closed
674
714
  * strand); the number of keys is bounded only by the strands this node has ever
675
715
  * formed, not by time — if a node ever forms strands at scale, evict on
676
- * `unpublishStrand` / `forgetJoinedStrand` as the strand peer book does.
716
+ * `unpublishStrand` / `forgetJoinedStrand` as {@link formationStrandAddrs} is.
677
717
  */
678
718
  private readonly pendingMembershipInvites = new Map<string, StrandMembershipInvite>();
679
719
 
720
+ /**
721
+ * Invite keys of the membership invitations this process has staged, by
722
+ * {@link adoptFormationMembershipInvite} or from a `joined` `PendingJoin` row
723
+ * ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
724
+ * staged again by the next pending-join pass. Same lifetime as
725
+ * {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
726
+ * reconciler settles it again (spent, or the party already seated).
727
+ */
728
+ private readonly stagedMembershipInviteKeys = new Set<string>();
729
+
730
+ /**
731
+ * The pending-join retry loop (`pending-join-runner.ts`): built by {@link start}, stopped by
732
+ * {@link cleanup}. It acts only while this machine is an enrolled owner.
733
+ */
734
+ private pendingJoinRunner: PendingJoinRunner | null = null;
735
+
680
736
  /**
681
737
  * Founder launches refused because the strand was founded before the per-party
682
738
  * identity split (`PreSplitStrandIdentityError`), keyed by strandId. Read by
@@ -1114,8 +1170,9 @@ export class CadreNode implements SAppIdLookup {
1114
1170
  // fails closed like the two above.
1115
1171
  this.initializeJoinedStrandStore();
1116
1172
 
1117
- // The strand peer book those joins are dialed from. Same placement, same reason.
1118
- this.initializeStrandPeerBookStore();
1173
+ // Each strand node's saved network state, which those joins are dialed from after
1174
+ // a restart. Same placement, same reason.
1175
+ this.initializeStrandNetworkStateStore();
1119
1176
 
1120
1177
  // Read the party's enrolled-machine count out of its node-local record and
1121
1178
  // capture it for buildControlNodeOptions below. This MUST precede
@@ -1246,6 +1303,12 @@ export class CadreNode implements SAppIdLookup {
1246
1303
  });
1247
1304
  await this.strandAddrService.initialize(this.controlNode);
1248
1305
 
1306
+ // Answer strand formation for this party from now on. Every machine of a party
1307
+ // holds the replicated FormationInvite/FormationUsage rows, so an always-on one can
1308
+ // check a token for an inviter that is offline. After the control database is up,
1309
+ // so the responder never reads a database that cannot answer yet.
1310
+ await this.installDefaultFormationResponder();
1311
+
1249
1312
  // Server-side push-wake fan-out: only when push is configured.
1250
1313
  if (this.config.push) {
1251
1314
  this.pushFanoutService = this.buildPushFanout(this.config.push);
@@ -1286,6 +1349,9 @@ export class CadreNode implements SAppIdLookup {
1286
1349
  // Schedule self-registration in background
1287
1350
  this.scheduleSelfRegistration();
1288
1351
 
1352
+ // Last: its first pass may dial another party's machines.
1353
+ this.startPendingJoinRunner();
1354
+
1289
1355
  } catch (error) {
1290
1356
  log('Failed to start CadreNode: %o', error);
1291
1357
  // `driveControlRelayReservation` throws AFTER `control:connected` was emitted,
@@ -1542,27 +1608,27 @@ export class CadreNode implements SAppIdLookup {
1542
1608
  }
1543
1609
 
1544
1610
  /**
1545
- * Construct (or adopt) the node-local strand peer book (see
1546
- * {@link strandPeerBookStore}). Synchronous for the reason
1611
+ * Construct (or adopt) the node-local strand network state (see
1612
+ * {@link strandNetworkStateStore}). Synchronous for the reason
1547
1613
  * {@link initializeBootstrapPeerStore} is: an injected store has already loaded its
1548
1614
  * persisted entries by the time it is handed in, and the in-memory fallback has
1549
1615
  * nothing to load. The instance is kept across stop()→start(). A store scoped to a
1550
- * different party is a configuration error, fail closed: another party's strand
1551
- * peers must never enter this node's dial loop.
1616
+ * different party is a configuration error, fail closed: another party's routing
1617
+ * tables must never be imported into this node's strands.
1552
1618
  */
1553
- private initializeStrandPeerBookStore(): void {
1619
+ private initializeStrandNetworkStateStore(): void {
1554
1620
  const partyId = this.config.controlNetwork.partyId;
1555
- if (this.strandPeerBookStore) {
1621
+ if (this.strandNetworkStateStore) {
1556
1622
  return;
1557
1623
  }
1558
- const store = this.config.strandPeers?.store ?? new MemoryStrandPeerBookStore(partyId);
1624
+ const store = this.config.strandNetworkState?.store ?? new MemoryStrandNetworkStateStore(partyId);
1559
1625
  if (store.partyId !== partyId) {
1560
1626
  throw new Error(
1561
- `CadreNodeConfig: strandPeers.store is scoped to party ${store.partyId}, ` +
1562
- `but this node serves party ${partyId} — refusing to mix strand peer books`
1627
+ `CadreNodeConfig: strandNetworkState.store is scoped to party ${store.partyId}, ` +
1628
+ `but this node serves party ${partyId} — refusing to mix strand network state`
1563
1629
  );
1564
1630
  }
1565
- this.strandPeerBookStore = store;
1631
+ this.strandNetworkStateStore = store;
1566
1632
  }
1567
1633
 
1568
1634
  /**
@@ -1585,13 +1651,13 @@ export class CadreNode implements SAppIdLookup {
1585
1651
  }
1586
1652
 
1587
1653
  /**
1588
- * The node-local strand peer book (null before {@link start}) — per strand, the
1589
- * strand peers this node dials first on launch, resume and refresh. Exposed for
1590
- * diagnostics and for a host that wants to show "whom would this strand re-find
1591
- * after a restart?".
1654
+ * The node-local strand network state (null before {@link start}) — per strand, the
1655
+ * state its strand node last saved and will re-import when next built. Exposed for
1656
+ * diagnostics and for a host that wants to show "what routing table would this
1657
+ * strand restart with?".
1592
1658
  */
1593
- getStrandPeerBookStore(): StrandPeerBookStore | null {
1594
- return this.strandPeerBookStore;
1659
+ getStrandNetworkStateStore(): StrandNetworkStateStore | null {
1660
+ return this.strandNetworkStateStore;
1595
1661
  }
1596
1662
 
1597
1663
  /**
@@ -1884,11 +1950,15 @@ export class CadreNode implements SAppIdLookup {
1884
1950
  // replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
1885
1951
  connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
1886
1952
  // The declared link every cadre budget is derived from, stated to Optimystic too so it
1887
- // derives its own deadlines from the same number: its request dials and responses, its
1888
- // block pushes, and libp2p's `dialTimeout` and `inboundUpgradeTimeout`. Always stated,
1889
- // default included: undeclared, Optimystic keeps LAN deadlines that cannot open a
1890
- // relayed connection at the link sereus supports (`link-budget.ts`).
1953
+ // derives its own deadlines from the same number: its request responses, its block
1954
+ // pushes, and libp2p's `inboundUpgradeTimeout`. Always stated, default included:
1955
+ // undeclared, Optimystic keeps LAN deadlines that cannot open a relayed connection at the
1956
+ // link sereus supports (`link-budget.ts`).
1891
1957
  linkRoundTripMs: resolveLinkRoundTripMs(network?.linkRoundTripMs),
1958
+ // The three limits on opening a connection (libp2p's per-address and whole-dial limits,
1959
+ // and Optimystic's request dial): Optimystic's derivation from the same link plus the
1960
+ // relay's and the called machine's admission decisions, which run on this dial's clock.
1961
+ ...optimysticDialLimits(network?.linkRoundTripMs),
1892
1962
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1893
1963
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1894
1964
  // then. Spread NEXT to `transports` because the two answer the same question.
@@ -1971,11 +2041,9 @@ export class CadreNode implements SAppIdLookup {
1971
2041
  * `StrandSolicitationService.hasOutstandingInvitation`). A formation
1972
2042
  * initiator is another party's peer by design and its token is only
1973
2043
  * checkable inside the protocol, so the gate asks the coarser question
1974
- * "does this node expect a stranger at all?". Merely REGISTERING the
1975
- * responder ({@link initializeStrandSolicitation}) no longer suspends
1976
- * stranger denial — eager registration (as `reference-app-rn` does at
1977
- * bring-up) and `formStrand`'s lazy initialization both leave the gate
1978
- * armed, because neither mints an invitation.
2044
+ * "does this node expect a stranger at all?". REGISTERING the responder
2045
+ * does not suspend stranger denial: every node registers one at
2046
+ * {@link start}, and registering mints no invitation.
1979
2047
  *
1980
2048
  * Ordering is semantically free (the checks are OR'd) but decides who pays:
1981
2049
  * checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
@@ -2007,6 +2075,11 @@ export class CadreNode implements SAppIdLookup {
2007
2075
  * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
2008
2076
  * read is safe here — unlike the per-stream gate, which must consult the
2009
2077
  * materialized {@link authorizedControlPeers} snapshot instead.
2078
+ * NOTE: check 6 adds a second control read (`hasOutstandingFormationInvite`) for a
2079
+ * stranger with no locally minted invitation in play, on every node now that every
2080
+ * node runs the responder — a relay-enabled storage node included. If stranger
2081
+ * connections to such a node ever arrive fast enough for that read to show, cache
2082
+ * the answer for a few seconds.
2010
2083
  * NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
2011
2084
  * replicated here is denied until the row converges (typically via the
2012
2085
  * owner); either side's next outbound reconcile dial (outbound is never
@@ -2898,6 +2971,15 @@ export class CadreNode implements SAppIdLookup {
2898
2971
  * handling, is what keeps a mixed list from taking a whole peer offline.
2899
2972
  */
2900
2973
  async resolvePeerAddrs(peerId: string, opts: ResolveOpts = {}): Promise<Multiaddr[]> {
2974
+ return (await this.resolvePeerRecord(peerId, opts))?.addrs ?? [];
2975
+ }
2976
+
2977
+ /**
2978
+ * {@link resolvePeerAddrs} with the record's `UpdatedAt` kept beside the addresses, for a
2979
+ * caller that ranks peers by how recently they published. `null` wherever that returns `[]`
2980
+ * for a failed gate.
2981
+ */
2982
+ private async resolvePeerRecord(peerId: string, opts: ResolveOpts): Promise<{ addrs: Multiaddr[]; updatedAt: number } | null> {
2901
2983
  if (!this.controlDatabase) {
2902
2984
  throw new Error('CadreNode must be started before resolving peer addrs');
2903
2985
  }
@@ -2905,14 +2987,14 @@ export class CadreNode implements SAppIdLookup {
2905
2987
  const record = await this.controlDatabase.queryPeerRecord(peerId);
2906
2988
  if (!record) {
2907
2989
  log('resolvePeerAddrs: no record for %s', peerId);
2908
- return [];
2990
+ return null;
2909
2991
  }
2910
2992
 
2911
2993
  // publicKey <-> peerId binding: the stored key must be the one embedded in
2912
2994
  // the requested Ed25519 peer id (also rejects a non-Ed25519 / missing key).
2913
2995
  if (!record.publicKey || ed25519PublicKeyB64FromPeerId(peerId) !== record.publicKey) {
2914
2996
  log('resolvePeerAddrs: publicKey does not match peerId for %s', peerId);
2915
- return [];
2997
+ return null;
2916
2998
  }
2917
2999
 
2918
3000
  // Self-signature over (peerId, addrs, updatedAt).
@@ -2923,14 +3005,14 @@ export class CadreNode implements SAppIdLookup {
2923
3005
  log('resolvePeerAddrs: signature verification failed for %s (updatedAt=%d, addrs=%o, sig=%s)',
2924
3006
  peerId, record.updatedAt, record.addrs,
2925
3007
  record.sig ? `${record.sig.slice(0, 16)}…` : '(empty)');
2926
- return [];
3008
+ return null;
2927
3009
  }
2928
3010
 
2929
3011
  // Freshness: never hand back a dead relay reservation.
2930
3012
  const maxAgeMs = opts.maxAgeMs ?? DEFAULT_PEER_RECORD_MAX_AGE_MS;
2931
3013
  if (!isPeerRecordFresh(record.updatedAt, maxAgeMs, Date.now())) {
2932
3014
  log('resolvePeerAddrs: record for %s is stale (updatedAt=%d, maxAgeMs=%d)', peerId, record.updatedAt, maxAgeMs);
2933
- return [];
3015
+ return null;
2934
3016
  }
2935
3017
 
2936
3018
  // Pluggable trust gate (defaults to current-member).
@@ -2943,7 +3025,7 @@ export class CadreNode implements SAppIdLookup {
2943
3025
  });
2944
3026
  if (!trusted) {
2945
3027
  log('resolvePeerAddrs: trust policy rejected %s', peerId);
2946
- return [];
3028
+ return null;
2947
3029
  }
2948
3030
 
2949
3031
  // Order signaling-first (the on-record order was what we verified above),
@@ -2954,7 +3036,7 @@ export class CadreNode implements SAppIdLookup {
2954
3036
  if (opts.signalingOnly) {
2955
3037
  addrs = addrs.filter(isSignalingAddr);
2956
3038
  }
2957
- return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
3039
+ return { addrs: this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId), updatedAt: record.updatedAt };
2958
3040
  }
2959
3041
 
2960
3042
  /**
@@ -3117,10 +3199,10 @@ export class CadreNode implements SAppIdLookup {
3117
3199
  if (!this._running || !this.controlNode || !this.controlDatabase) {
3118
3200
  return { dialed: [] };
3119
3201
  }
3120
- // Then re-warm each running strand's own address book from its peer book and
3121
- // from whichever siblings are due an ask. Same reasoning as warmSiblingAddrBook
3122
- // below, one layer down: replication runs on the STRAND network, and every
3123
- // layer under cadre-core dials strand peers by bare peer id.
3202
+ // Then re-warm each running strand's own address book from its FRET address
3203
+ // records and from whichever siblings are due an ask. Same reasoning as
3204
+ // warmSiblingAddrBook below, one layer down: replication runs on the STRAND
3205
+ // network, and every layer under cadre-core dials strand peers by bare peer id.
3124
3206
  await this.refreshStrandPeerAddrs();
3125
3207
  if (!this._running || !this.controlNode || !this.controlDatabase) {
3126
3208
  return { dialed: [] };
@@ -3515,15 +3597,18 @@ export class CadreNode implements SAppIdLookup {
3515
3597
  *
3516
3598
  * The list may name transports this node cannot dial (a lent node reports TCP
3517
3599
  * and `/ws` addresses to a phone that dials WebSockets only). That needs no
3518
- * filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.1.3)
3600
+ * filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.3.11)
3519
3601
  * rejects an address no transport can dial before touching the network, so
3520
3602
  * such an address costs a log line.
3521
3603
  *
3522
3604
  * The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
3523
- * addresses one after another under a single deadline, with no limit per
3524
- * address, and sorts loopback addresses last — so one or two addresses that
3525
- * never answer used up the whole deadline before the address that worked was
3526
- * tried. {@link dialPeerAddrs} dials each address on its own time limit instead.
3605
+ * addresses one after another under a single deadline, and sorts loopback
3606
+ * addresses last — so one or two addresses that never answer use up most of
3607
+ * the deadline before the address that works is tried. libp2p 3.3's
3608
+ * per-address `addressDialTimeout` does not prevent that: cadre sizes it for a
3609
+ * cold relayed open through two admission decisions (`optimysticDialLimits`,
3610
+ * 39 s at the default declared link).
3611
+ * {@link dialPeerAddrs} dials each address on its own time limit instead.
3527
3612
  */
3528
3613
  private async resolveControlDialAddrs(peerId: string, resolved: Multiaddr[]): Promise<Multiaddr[]> {
3529
3614
  if (resolved.length > 0) {
@@ -3912,6 +3997,9 @@ export class CadreNode implements SAppIdLookup {
3912
3997
 
3913
3998
  // 4. In-session pending owner authorize writes.
3914
3999
  await this.drainPendingPeerWrites();
4000
+
4001
+ // 5. `PendingJoin` rows this process wrote while alone. Their tombstones went out in step 1.
4002
+ await this.pendingJoinRunner?.reissueWritesMadeAlone();
3915
4003
  }
3916
4004
 
3917
4005
  /**
@@ -4591,6 +4679,10 @@ export class CadreNode implements SAppIdLookup {
4591
4679
  // it is stopped here.
4592
4680
  this.relayReserveSupervisor?.stop();
4593
4681
  this.relayReserveSupervisor = null;
4682
+ // Before anything it reads is torn down. An attempt in flight is not awaited (NOTE at
4683
+ // `PendingJoinRunner.stop`).
4684
+ this.pendingJoinRunner?.stop();
4685
+ this.pendingJoinRunner = null;
4594
4686
  // Drop the rest of the relay-reservation posture: a torn-down node holds no
4595
4687
  // reservation, so neither a restarted instance nor a caller inspecting a failed
4596
4688
  // start reports the previous attempt's `reserved`.
@@ -4638,11 +4730,14 @@ export class CadreNode implements SAppIdLookup {
4638
4730
  this.pushFanoutService = null;
4639
4731
  }
4640
4732
 
4641
- // Unregister strand solicitation service
4642
- if (this.strandSolicitationService && this.controlNode) {
4643
- await this.strandSolicitationService.unregisterResponder(this.controlNode);
4644
- this.strandSolicitationService = null;
4733
+ // Unregister the formation responder once any queued swap has run, so no swap
4734
+ // registers a handler after this. A restarted node installs a fresh one.
4735
+ await this.solicitationSwaps;
4736
+ if (this.registeredSolicitation && this.controlNode) {
4737
+ await this.registeredSolicitation.unregisterResponder(this.controlNode);
4645
4738
  }
4739
+ this.registeredSolicitation = null;
4740
+ this.strandSolicitationService = null;
4646
4741
 
4647
4742
  // Stop strand watcher
4648
4743
  if (this.strandWatcher) {
@@ -5851,9 +5946,9 @@ export class CadreNode implements SAppIdLookup {
5851
5946
  // `privateKey` — tests, and an embedder that opted out of a stable identity) the
5852
5947
  // strand node still runs under a key CADRE-CORE HOLDS: a fresh random Ed25519 key,
5853
5948
  // exactly what libp2p would generate internally if handed none, except that the
5854
- // book swap can sign this node's own entry with it (`strand-peer-book-swap.ts`) and
5855
- // the retained launch config keeps the same strand peer id across a hibernation
5856
- // resume. Stability across RESTARTS still needs an identity key.
5949
+ // delegate announcement below can name the strand peer id before the node exists and
5950
+ // the retained launch config keeps that id across a hibernation resume. Stability
5951
+ // across RESTARTS still needs an identity key.
5857
5952
  //
5858
5953
  // NOTE: derivation requires an Ed25519 identity key, so a node configured
5859
5954
  // with some other key type now fails strand launch outright (surfaced as
@@ -5908,16 +6003,12 @@ export class CadreNode implements SAppIdLookup {
5908
6003
  this.forgetRevokedJoin(revokedStrandId);
5909
6004
  this.emit('strand:revoked', { strandId: revokedStrandId });
5910
6005
  },
5911
- // The strand peer book's observation writer: every strand peer the node
5912
- // identifies is remembered with its dialable addresses, so a restart re-finds
5913
- // it. Retained with the launch config, so a hibernation wake re-arms it.
5914
- onStrandPeerIdentified: (observedStrandId, observation) =>
5915
- this.observeStrandPeer(observedStrandId, observation),
5916
- // The book's third writer, the signed swap between strand peers: the strand node
5917
- // reads and writes the store directly (`strand-peer-book-swap.ts`). Retained with
5918
- // the launch config too. `?? undefined`: the field is `null` before start(), and a
5919
- // strand cannot launch before start(), so this is belt and braces.
5920
- strandPeerBook: this.strandPeerBookStore ?? undefined,
6006
+ // Where the strand node saves its network state and what it re-imports when it is
6007
+ // built. Retained with the launch config, so a hibernation wake rebuilds the node
6008
+ // over the table the quiesced node last saved. `?? undefined`: the field is `null`
6009
+ // before start(), and a strand cannot launch before start(), so this is belt and
6010
+ // braces.
6011
+ networkState: this.strandNetworkStateStore ?? undefined,
5921
6012
  onRejoinBlocked: (blockedStrandId) => this.emit('strand:rejoin-blocked', { strandId: blockedStrandId }),
5922
6013
  // The joiner's first-sync write gate (strand-first-sync-gate.ts): a launch that
5923
6014
  // comes up `'syncing'` announces the moment its database is published.
@@ -6050,32 +6141,11 @@ export class CadreNode implements SAppIdLookup {
6050
6141
  */
6051
6142
  private async resolveCohortSeed(strandId: string, delegatePeerId?: string): Promise<string[]> {
6052
6143
  const siblings = await this.resolveSiblingSeed(strandId, delegatePeerId);
6053
- // Sibling answers FIRST: they were resolved just now, while a book entry is as old
6054
- // as the last connection to that peer. `unionAddrs` appends only what the siblings
6144
+ // Sibling answers FIRST: they were resolved just now, while the formation-carried
6145
+ // addresses are as old as the formation. `unionAddrs` appends only what the siblings
6055
6146
  // did not already name, so a fresher answer is never displaced and each source keeps
6056
- // its own signaling-first ordering. `delegatePeerId` IS this strand node's own id.
6057
- return unionAddrs(siblings, this.strandPeerBookAddrs(strandId, delegatePeerId));
6058
- }
6059
-
6060
- /**
6061
- * The strand peer book's contribution to a strand's seed: every live entry's
6062
- * addresses, freshest peer first, minus this node's own entry when `selfPeerId` is
6063
- * known (the book swap files one under the strand transport id; a node must never
6064
- * dial itself). Empty before {@link start} builds the store.
6065
- */
6066
- private strandPeerBookAddrs(strandId: string, selfPeerId?: string): string[] {
6067
- const store = this.strandPeerBookStore;
6068
- if (!store) {
6069
- return [];
6070
- }
6071
- const addrs: string[] = [];
6072
- for (const entry of store.entries(strandId)) {
6073
- if (entry.peerId === selfPeerId) {
6074
- continue;
6075
- }
6076
- addrs.push(...entry.addrs);
6077
- }
6078
- return addrs;
6147
+ // its own ordering.
6148
+ return unionAddrs(siblings, this.formationStrandAddrs.get(strandId) ?? []);
6079
6149
  }
6080
6150
 
6081
6151
  /**
@@ -6263,9 +6333,9 @@ export class CadreNode implements SAppIdLookup {
6263
6333
  * caller runs the drive regardless).
6264
6334
  *
6265
6335
  * NOTE: against a relay that is DOWN this hook costs up to two strand-addr
6266
- * timeouts (dial by peer id, then by addr; 23 s each at the default declared link round trip)
6336
+ * timeouts (dial by peer id, then by addr; 28.5 s each at the default declared link round trip)
6267
6337
  * before the reservation drive even starts, so one failed re-drive holds the supervisor
6268
- * `driving` for those 46 s plus the drive's own deadline — 64 s at the default, and longer on
6338
+ * `driving` for those 57 s plus the drive's own deadline — 75 s at the default, and longer on
6269
6339
  * a host that declared a slower one (`link-budget.ts`).
6270
6340
  * Bounded and harmless while the relay is unreachable anyway; if recovery
6271
6341
  * latency after a relay comes back ever matters, skip the announce when the
@@ -6311,18 +6381,19 @@ export class CadreNode implements SAppIdLookup {
6311
6381
  }
6312
6382
 
6313
6383
  /**
6314
- * Keep each running strand's own libp2p address book warm: re-merge its strand
6315
- * peer book on every pass, and re-ask each connected SIBLING for its strand
6316
- * addresses over the control mesh when that (sibling, strand) is due
6384
+ * Keep each running strand's own libp2p address book warm: re-merge the address
6385
+ * records its FRET routing table holds on every pass
6386
+ * ({@link remergeStrandFretRecords}), and re-ask each connected SIBLING for its
6387
+ * strand addresses over the control mesh when that (sibling, strand) is due
6317
6388
  * ({@link strandAddrAskDueAt}).
6318
6389
  *
6319
- * Without this, a strand's address book is written exactly once — the
6390
+ * Without this, cadre-core writes a strand's address book exactly once — the
6320
6391
  * launch/resume seed — and everything below cadre-core that dials a strand peer
6321
6392
  * by bare peer id (Optimystic's cluster and repo clients, FRET ping/announce)
6322
- * loses its address for any sibling it is not currently connected to: a sibling
6393
+ * loses its address for any peer it is not currently connected to: a sibling
6323
6394
  * that restarted its strand node or rotated its relay reservation is never
6324
- * re-resolved, and even the original seed addresses fall off at the peerStore's
6325
- * one-hour expiry (see `peer-addr-book.ts`).
6395
+ * re-resolved, and every address, another party's included, falls off at the
6396
+ * peerStore's one-hour expiry (see `peer-addr-book.ts`).
6326
6397
  *
6327
6398
  * Due times are per (sibling, strand) and set from each sibling's own outcome, so
6328
6399
  * a sibling that connects late — a phone joining after the party's always-on
@@ -6405,15 +6476,15 @@ export class CadreNode implements SAppIdLookup {
6405
6476
 
6406
6477
  /**
6407
6478
  * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings that are
6408
- * due, union their answers with this strand's peer-book addresses, and merge the
6409
- * lot into the strand's address book. Errors are logged and swallowed so one
6410
- * strand's failure never costs the others their refresh.
6479
+ * due and merge their answers into the strand's address book, then re-merge the
6480
+ * address records the strand node's FRET table holds. Errors are logged and
6481
+ * swallowed so one strand's failure never costs the others their refresh.
6411
6482
  *
6412
- * The peer book merges on EVERY pass, whether or not any sibling is due or
6413
- * connected: nothing re-resolves another party's addresses, so this re-merge is the
6414
- * ONLY thing standing between them and the peerStore's one-hour expiry — and it is
6415
- * also how a re-formation's freshly carried addresses reach a running strand on the
6416
- * next tick. A strand with nobody due and no book entry does nothing.
6483
+ * The sibling half runs first, so a failure reading the FRET table cannot cost a
6484
+ * sibling answer its merge. The FRET half, and the formation-carried addresses of
6485
+ * the peers it holds no record for, run on EVERY pass, whether or not any sibling is
6486
+ * due or connected — see {@link remergeStrandFretRecords} and
6487
+ * {@link remergeUnrecordedFormationAddrs} for why.
6417
6488
  */
6418
6489
  private async refreshOneStrandPeerAddrs(
6419
6490
  strandId: string,
@@ -6429,7 +6500,6 @@ export class CadreNode implements SAppIdLookup {
6429
6500
  // Inside the try: a strand node torn down mid-pass may throw on any read, and one
6430
6501
  // strand's failure must never cost the others their refresh.
6431
6502
  const strandPeerId = strandNode.peerId.toString();
6432
- const contactAddrs = this.strandPeerBookAddrs(strandId, strandPeerId);
6433
6503
  const due = targets.filter(
6434
6504
  (target) => now >= (this.strandAddrAskDueAt.get(peerStrandKey(target.peerId, strandId)) ?? 0)
6435
6505
  );
@@ -6438,26 +6508,95 @@ export class CadreNode implements SAppIdLookup {
6438
6508
  const siblingAddrs = due.length === 0
6439
6509
  ? []
6440
6510
  : await this.askSiblingsForStrandAddrs(controlNode, due, strandId, strandPeerId, now);
6441
- // Re-read the instance after the await — a strand stopped mid-pass (or one
6442
- // already restarted onto a new node) must never have its store written to.
6443
- if (!this._running || this.strandManager.getInstance(strandId)?.libp2pNode !== strandNode) {
6511
+ if (!this.isRunningStrandNode(strandId, strandNode)) {
6444
6512
  return;
6445
6513
  }
6446
- const addrs = unionAddrs(siblingAddrs, contactAddrs);
6447
- if (addrs.length === 0) {
6448
- return;
6514
+ if (siblingAddrs.length > 0) {
6515
+ await this.mergeStrandPeerAddrs(strandNode, siblingAddrs, strandId);
6449
6516
  }
6450
- // NOTE: with no sibling due this still merges the whole book — up to 16 peers ×
6451
- // 16 addrs, so 16 `peerStore.merge` calls per running strand per 15 s tick. An
6452
- // unchanged address set writes nothing in libp2p's persistent peer store, so it
6453
- // is cheap today; if many strands run at once, merge only book entries changed
6454
- // since the last tick plus those nearing the one-hour expiry.
6455
- await this.mergeStrandPeerAddrs(strandNode, addrs, strandId);
6517
+ const recorded = await this.remergeStrandFretRecords(strandNode, strandId);
6518
+ await this.remergeUnrecordedFormationAddrs(strandNode, strandId, recorded);
6456
6519
  } catch (error) {
6457
6520
  log('refreshStrandPeerAddrs: strand %s refresh failed (continuing): %o', strandId, error);
6458
6521
  }
6459
6522
  }
6460
6523
 
6524
+ /**
6525
+ * Is `strandNode` still the node `strandId` runs on? Asked again after every await
6526
+ * in a refresh pass: a strand stopped mid-pass, or one already restarted onto a new
6527
+ * node, must never have its store written to.
6528
+ */
6529
+ private isRunningStrandNode(strandId: string, strandNode: Libp2p): boolean {
6530
+ return this._running && this.strandManager.getInstance(strandId)?.libp2pNode === strandNode;
6531
+ }
6532
+
6533
+ /**
6534
+ * Re-merge, into a running strand node's address book, the addresses of every peer
6535
+ * its FRET routing table holds a signed address record for (`strand-fret-addrs.ts`).
6536
+ *
6537
+ * This is what keeps ANOTHER PARTY's strand nodes dialable from a node that stays
6538
+ * up. Nothing re-resolves their addresses — the strand-addr RPC answers own-party
6539
+ * siblings only — and the peerStore hides an address one hour after it was first
6540
+ * observed. FRET keeps each peer's record but hands it to the peerStore only when it
6541
+ * arrives or is imported, so without this pass a connection to another party that
6542
+ * drops after the first hour could not be redialed by either side.
6543
+ *
6544
+ * A record is held while FRET holds the peer's table entry, and at most 14 days after
6545
+ * it was last confirmed, so that is also how long a wrong address can keep being
6546
+ * re-merged. Each record is signed by the peer it names and verified before use, so
6547
+ * the exposure is failed dials to an address that peer itself published.
6548
+ *
6549
+ * NOTE: every pass serialises the whole FRET table (`exportTable()`), verifies one
6550
+ * signature per record and makes one `peerStore.merge` per peer, per running strand
6551
+ * per 15 s tick. Fine at strand table sizes, and an unchanged address set writes
6552
+ * nothing in libp2p's persistent peer store. If many strands or large tables make it
6553
+ * show up, read only the entries whose address stamp is near the one-hour mark.
6554
+ *
6555
+ * @returns the ids of the peers the table holds a usable record for.
6556
+ */
6557
+ private async remergeStrandFretRecords(strandNode: Libp2p, strandId: string): Promise<ReadonlySet<string>> {
6558
+ const { peers, rejected } = await strandFretPeerAddrs(strandNode);
6559
+ const recorded = new Set(peers.keys());
6560
+ if ((peers.size === 0 && rejected === 0) || !this.isRunningStrandNode(strandId, strandNode)) {
6561
+ return recorded;
6562
+ }
6563
+ const counts: Record<MergeAddrsResult, number> = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
6564
+ for (const [peerId, addrs] of peers) {
6565
+ counts[await mergePeerAddrs(strandNode, peerId, addrs)]++;
6566
+ }
6567
+ log('strand %s FRET address records re-merged (peers=%d, merged=%d, restamped=%d, failed=%d, rejected=%d)',
6568
+ strandId, peers.size, counts.merged, counts.restamped, counts.failed, rejected);
6569
+ return recorded;
6570
+ }
6571
+
6572
+ /**
6573
+ * Re-merge the addresses this strand's formation carried ({@link formationStrandAddrs})
6574
+ * for every peer the strand node's FRET table holds no record for (`recorded`).
6575
+ *
6576
+ * Until the first connection to the responder, the carried addresses are the only
6577
+ * thing naming it, and the launch seed writes them once. FRET's `bootstraps` keep only
6578
+ * the peer id, so if the responder stays unreachable past the peerStore's one-hour
6579
+ * expiry, nothing could dial it when it returns. Once FRET holds the peer's own signed
6580
+ * record, that record supersedes what the formation carried and this stops.
6581
+ */
6582
+ private async remergeUnrecordedFormationAddrs(
6583
+ strandNode: Libp2p,
6584
+ strandId: string,
6585
+ recorded: ReadonlySet<string>
6586
+ ): Promise<void> {
6587
+ const carried = this.formationStrandAddrs.get(strandId);
6588
+ if (!carried) {
6589
+ return;
6590
+ }
6591
+ const unrecorded = [...groupAddrsByPeerId(carried)]
6592
+ .filter(([peerId]) => !recorded.has(peerId))
6593
+ .flatMap(([, addrs]) => addrs.map((addr) => addr.toString()));
6594
+ if (unrecorded.length === 0 || !this.isRunningStrandNode(strandId, strandNode)) {
6595
+ return;
6596
+ }
6597
+ await this.mergeStrandPeerAddrs(strandNode, unrecorded, strandId);
6598
+ }
6599
+
6461
6600
  /**
6462
6601
  * RPC `due` siblings for `strandId` and set each one's next due time from its own
6463
6602
  * outcome: an answer, even an empty one, waits the full refresh interval; anything
@@ -6490,11 +6629,11 @@ export class CadreNode implements SAppIdLookup {
6490
6629
  * attributed per peer. Best-effort throughout: an address-book write must never
6491
6630
  * fail a launch, a resume, or a reconcile pass.
6492
6631
  *
6493
- * `addrs` is the peer-agnostic union the strand-addr RPC returns, so
6494
- * {@link groupAddrsByPeerId} attributes each entry to the **strand transport**
6495
- * peerId in its final `/p2p/` component — never the sibling's control peerId,
6496
- * which names a different libp2p node entirely. Entries that name no peer are
6497
- * dropped there and counted here, once per pass.
6632
+ * `addrs` is a peer-agnostic list — the union the strand-addr RPC returns, or the
6633
+ * addresses a formation carried — so {@link groupAddrsByPeerId} attributes each
6634
+ * entry to the **strand transport** peerId in its final `/p2p/` component — never
6635
+ * the sibling's control peerId, which names a different libp2p node entirely.
6636
+ * Entries that name no peer are dropped there and counted here, once per pass.
6498
6637
  *
6499
6638
  * NOTE: a member could answer with arbitrary multiaddrs bound to arbitrary peer
6500
6639
  * ids and poison this address book. That is the same exposure the launch-time
@@ -6504,14 +6643,13 @@ export class CadreNode implements SAppIdLookup {
6504
6643
  * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
6505
6644
  * `backlog/strand-network-nat-relay-reachability`.
6506
6645
  *
6507
- * NOTE: one input ages out on a slower clock — the strand peer book's entries
6508
- * ({@link strandPeerBookStore}). Nothing can re-resolve another party's addresses,
6509
- * so {@link refreshStrandPeerAddrs} re-merges the book every pass, which means a
6510
- * junk entry from a responder or a peer's identify survives until the book drops it
6511
- * (14 days unrefreshed, or the peer's next connection replacing the entry) rather
6512
- * than an hour. Bounded to 16 peers × 16 addrs per strand, forgotten on
6513
- * `unpublishStrand` / `forgetJoinedStrand`, and still authority-free — so the
6514
- * exposure is a handful of failed dials, not a trust hole.
6646
+ * NOTE: one input outlives that hour — a formation's carried addresses
6647
+ * ({@link formationStrandAddrs}) are in every launch and resume seed for as long as
6648
+ * this process remembers the formation, and re-merged on every refresh pass until the
6649
+ * strand node's FRET table holds the peer's own record, so a junk address from a
6650
+ * responder that is never met keeps being re-merged. Bounded by `sanitizeStrandAddrs`,
6651
+ * replaced by a re-formation, dropped on `unpublishStrand` / `forgetJoinedStrand`, and
6652
+ * still authority-free — so the exposure is a handful of failed dials, not a trust hole.
6515
6653
  */
6516
6654
  private async mergeStrandPeerAddrs(strandNode: Libp2p, addrs: string[], strandId: string): Promise<void> {
6517
6655
  const counts: Record<MergeAddrsResult, number> = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
@@ -6562,7 +6700,7 @@ export class CadreNode implements SAppIdLookup {
6562
6700
  // is normally a no-op; it is the guard for a bare listen addr and for a relay hop
6563
6701
  // whose trailing `/p2p/` names the RELAY. An addr terminating in a DIFFERENT id is
6564
6702
  // dropped rather than announced — it does not reach this node, and announcing it
6565
- // would file our address under someone else's id in the receiver's book.
6703
+ // would file our address under someone else's id in the receiver's address book.
6566
6704
  //
6567
6705
  // Re-parsed through this package's own `multiaddr` rather than passed straight in:
6568
6706
  // libp2p hands back its nested `@multiformats/multiaddr` copy, a structurally
@@ -6583,7 +6721,8 @@ export class CadreNode implements SAppIdLookup {
6583
6721
  /**
6584
6722
  * Leave a strand joined from another party, for the whole party: remove its party-wide
6585
6723
  * `JoinedStrand` row (owner-signed, with a `Revocation` tombstone), then this machine's
6586
- * unpublished record, its strand peer book entries and any session-kept entry, then
6724
+ * unpublished record, its formation-carried addresses, its saved network state and any
6725
+ * session-kept entry, then
6587
6726
  * {@link stopStrand} it here. Every other machine's watcher then sees the row gone and
6588
6727
  * detaches the strand, a storage replica included; a machine offline at the time reaps the
6589
6728
  * row once the tombstone reaches it. The joiner's counterpart of {@link unpublishStrand},
@@ -6641,8 +6780,9 @@ export class CadreNode implements SAppIdLookup {
6641
6780
  * session here — the `strand:revoked` contract is that nothing is torn down for the app —
6642
6781
  * and its party-wide row is queued for removal by the next connected owner reconcile pass
6643
6782
  * (`JoinedStrandSession.forgetAfterThisSession`). A no-op for this party's own strands,
6644
- * which have neither record; the strand peer book is forgotten either way, since a
6645
- * removed party must not keep dialing the strand's peers.
6783
+ * which have neither record; the formation-carried addresses and the saved network
6784
+ * state are forgotten either way, since a removed party must not keep dialing the
6785
+ * strand's peers.
6646
6786
  *
6647
6787
  * NOTE: accepted tradeoff — a sibling machine that has not raised `strand:revoked` itself
6648
6788
  * detaches the strand (`strand:stopped`) when the party-wide removal reaches it, instead
@@ -6668,47 +6808,16 @@ export class CadreNode implements SAppIdLookup {
6668
6808
  }
6669
6809
 
6670
6810
  /**
6671
- * Forget a strand's entries in the peer book (see {@link strandPeerBookStore}):
6672
- * fire-and-log, like every book write — the removal is visible synchronously by the
6673
- * store's contract, and a failed persist costs restart survival only.
6811
+ * Forget what this node holds about a strand's peers: the addresses its formation
6812
+ * carried (see {@link formationStrandAddrs}) and its saved network state (see
6813
+ * {@link strandNetworkStateStore}). The store write is fire-and-log — the removal is
6814
+ * visible synchronously by the store's contract, and a failed persist only leaves the
6815
+ * entry on disk for the next start.
6674
6816
  */
6675
6817
  private forgetStrandPeers(strandId: string, caller: string): void {
6676
- void this.strandPeerBookStore?.forget(strandId).catch((error: unknown) => {
6677
- log('%s: forgetting strand %s peers failed (continuing): %o', caller, strandId, error);
6678
- });
6679
- }
6680
-
6681
- /**
6682
- * The strand peer book's observation writer (`StartStrandConfig.onStrandPeerIdentified`):
6683
- * a strand node identified a strand peer at `observation.addrs`, so remember it as an
6684
- * unsigned entry seen just now. The observer never reports self, and the store binds
6685
- * every address to the peer id before filing it.
6686
- */
6687
- private observeStrandPeer(strandId: string, observation: StrandPeerObservation): void {
6688
- this.rememberStrandPeer(strandId, {
6689
- peerId: observation.peerId,
6690
- addrs: observation.addrs,
6691
- issuedAt: 0,
6692
- lastSeenAt: Date.now()
6693
- }, 'observeStrandPeer');
6694
- }
6695
-
6696
- /**
6697
- * Merge one entry into the peer book. Fire-and-log: the entry is visible
6698
- * synchronously by the store's contract, and the promise tracks durability only. A
6699
- * persist failure costs restart survival, never this session's seed — the same trade
6700
- * {@link retainDialTarget} makes.
6701
- */
6702
- private rememberStrandPeer(strandId: string, entry: StrandPeerEntry, caller: string): void {
6703
- const store = this.strandPeerBookStore;
6704
- if (!store) {
6705
- // Unreachable in production: start() builds the store before any strand can
6706
- // launch or form. Logged rather than thrown, for the same reason as retainDialTarget.
6707
- log('%s: no strand peer book yet — not remembering %s for strand %s', caller, entry.peerId, strandId);
6708
- return;
6709
- }
6710
- void store.merge(strandId, entry).catch((error: unknown) => {
6711
- log('%s: persisting strand %s peer %s failed (continuing): %o', caller, strandId, entry.peerId, error);
6818
+ this.formationStrandAddrs.delete(strandId);
6819
+ void this.strandNetworkStateStore?.forget(strandId).catch((error: unknown) => {
6820
+ log('%s: forgetting strand %s network state failed (continuing): %o', caller, strandId, error);
6712
6821
  });
6713
6822
  }
6714
6823
 
@@ -7219,6 +7328,9 @@ export class CadreNode implements SAppIdLookup {
7219
7328
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
7220
7329
  }), this.controlNode, this.controlDatabase);
7221
7330
  log('Seed bootstrap service initialized');
7331
+ // Embedders wire the owner key after start(), so the runner's first pass usually found
7332
+ // this machine not yet an owner; without this it would wait a full poll interval.
7333
+ this.pendingJoinRunner?.kick();
7222
7334
  }
7223
7335
 
7224
7336
  /**
@@ -7939,20 +8051,33 @@ export class CadreNode implements SAppIdLookup {
7939
8051
  // ============================================================================
7940
8052
 
7941
8053
  /**
7942
- * Initialize the strand solicitation service.
7943
- * This enables forming strands with other parties via open invitations.
8054
+ * Replace this node's strand solicitation service — the formation responder every node
8055
+ * installs at {@link start}, and the initiator side of {@link formStrand}. Optional: call it
8056
+ * only to customize the responder (an approver, a provisioner, formation deadlines). The
8057
+ * new service's handler replaces the previous one's on the control node, and invitations
8058
+ * the previous service minted stay outstanding.
8059
+ *
8060
+ * Without `options.formationUsageRecorder`, tokens are checked against this party's
8061
+ * replicated `FormationInvite`/`FormationUsage` rows ({@link ControlFormationUsageRecorder}).
8062
+ * A responder that accepts every token is reachable only by constructing
8063
+ * {@link StrandSolicitationService} directly.
7944
8064
  *
7945
8065
  * @param options - Configuration for the solicitation service
7946
8066
  */
7947
8067
  async initializeStrandSolicitation(options?: StrandSolicitationServiceOptions): Promise<void> {
7948
- if (!this.controlNode) {
8068
+ const controlNode = this.controlNode;
8069
+ const controlDatabase = this.controlDatabase;
8070
+ if (!controlNode || !controlDatabase) {
7949
8071
  throw new Error('CadreNode must be started before initializing strand solicitation');
7950
8072
  }
7951
8073
 
7952
8074
  const service = new StrandSolicitationService({
7953
8075
  ...options,
8076
+ formationUsageRecorder: options?.formationUsageRecorder ?? new ControlFormationUsageRecorder(controlDatabase),
7954
8077
  partyId: this.config.controlNetwork.partyId,
7955
- cadrePeerAddrs: this.getMultiaddrs(),
8078
+ // Read per formation: the responder is installed during start, before a relay
8079
+ // reservation gives a relay-only node any address, and addresses change after.
8080
+ cadrePeerAddrs: () => this.getMultiaddrs(),
7956
8081
  // Every formation deadline derives from this node's declared link, like every other
7957
8082
  // dial budget here; a caller's own declaration on the formation config still wins.
7958
8083
  formationConfig: {
@@ -7970,24 +8095,61 @@ export class CadreNode implements SAppIdLookup {
7970
8095
  this.issueStrandMembershipInvite(strandId, signal)
7971
8096
  });
7972
8097
 
7973
- // Set before the registration is awaited, so a concurrent createOpenInvitation /
7974
- // formStrand uses this service instead of building a second one whose handler
7975
- // would collide; a failed registration puts the previous service back.
7976
- // NOTE: same stray-handler caveat as installSeedBootstrapService — a failed
7977
- // peer-store merge leaves the formation handler registered, so a retry is a duplicate.
7978
8098
  const previous = this.strandSolicitationService;
8099
+ if (previous) {
8100
+ service.adoptMintedInvitations(previous);
8101
+ }
8102
+ // Set before the swap is awaited, so a concurrent createOpenInvitation / formStrand
8103
+ // uses this service instead of building another.
7979
8104
  this.strandSolicitationService = service;
8105
+ const swap = this.solicitationSwaps.then(() => this.swapFormationResponder(service, controlNode));
8106
+ // The queue only orders swaps; a failed one rejects this call's own await below.
8107
+ this.solicitationSwaps = swap.catch(() => undefined);
8108
+ await swap;
8109
+ log('Strand solicitation service initialized');
8110
+ }
8111
+
8112
+ /**
8113
+ * Answer formation from start. Log-and-continue: a node that cannot is still useful, and
8114
+ * {@link createOpenInvitation} / {@link formStrand} install the responder when it is missing.
8115
+ */
8116
+ private async installDefaultFormationResponder(): Promise<void> {
8117
+ try {
8118
+ await this.initializeStrandSolicitation();
8119
+ } catch (error) {
8120
+ log('Formation responder not installed at start; createOpenInvitation/formStrand will retry: %o', error);
8121
+ }
8122
+ }
8123
+
8124
+ /**
8125
+ * Move the control node's formation handler from the registered service to `service`.
8126
+ * Queued by {@link initializeStrandSolicitation} so swaps run one at a time: libp2p allows
8127
+ * one handler per protocol id, and two overlapping swaps would each find the other's
8128
+ * handler in the way. A failed swap registers the previous service again, points
8129
+ * {@link strandSolicitationService} back at it unless a later call has replaced it, and
8130
+ * rethrows.
8131
+ *
8132
+ * NOTE: same stray-handler caveat as {@link installSeedBootstrapService} — a failed
8133
+ * peer-store merge leaves the formation handler registered, so the restore below (and a
8134
+ * retry) rejects as a duplicate.
8135
+ */
8136
+ private async swapFormationResponder(service: StrandSolicitationService, controlNode: Libp2p): Promise<void> {
8137
+ const previous = this.registeredSolicitation;
7980
8138
  try {
7981
- await service.registerResponder(this.controlNode);
8139
+ await previous?.unregisterResponder(controlNode);
8140
+ await service.registerResponder(controlNode);
8141
+ this.registeredSolicitation = service;
7982
8142
  } catch (error) {
7983
8143
  if (this.strandSolicitationService === service) this.strandSolicitationService = previous;
8144
+ await previous?.registerResponder(controlNode).catch((restoreError: unknown) =>
8145
+ log('Re-registering the previous formation responder failed; none answers until the next initializeStrandSolicitation: %o', restoreError));
7984
8146
  throw error;
7985
8147
  }
7986
- log('Strand solicitation service initialized');
7987
8148
  }
7988
8149
 
7989
8150
  /**
7990
- * Get the strand solicitation service (for advanced use)
8151
+ * Get the strand solicitation service (for advanced use). Non-null on a started node unless
8152
+ * the install at {@link start} failed.
7991
8153
  */
7992
8154
  getStrandSolicitationService(): StrandSolicitationService | null {
7993
8155
  return this.strandSolicitationService;
@@ -7996,20 +8158,25 @@ export class CadreNode implements SAppIdLookup {
7996
8158
  /**
7997
8159
  * Create an open invitation for others to form strands with this party.
7998
8160
  *
8161
+ * The invitation's bootstrap list names this machine first (it is the one most likely to
8162
+ * run the host strand), then a few of the party's other machines
8163
+ * ({@link siblingInvitationAddrs}), so a joiner can still form while this one is offline.
8164
+ *
7999
8165
  * @param sAppId - The sApp to use for formed strands
8000
8166
  * @param expirationMs - How long the invitation is valid (ms from now)
8001
8167
  * @returns The open invitation to share out-of-band
8168
+ * @throws when neither this machine nor any other machine of the party has an address
8002
8169
  */
8003
8170
  async createOpenInvitation(
8004
8171
  sAppId: string,
8005
8172
  expirationMs: number = 24 * 60 * 60 * 1000 // 24 hours default
8006
8173
  ): Promise<OpenInvitation> {
8007
8174
  if (!this.strandSolicitationService) {
8008
- // Create a temporary service for creating invitations
8175
+ // Only when the install at start failed.
8009
8176
  await this.initializeStrandSolicitation();
8010
8177
  }
8011
8178
 
8012
- const bootstrap = this.getMultiaddrs();
8179
+ const bootstrap = [...this.getMultiaddrs(), ...await this.siblingInvitationAddrs()];
8013
8180
  if (bootstrap.length === 0) {
8014
8181
  throw new Error('No multiaddrs available for invitation');
8015
8182
  }
@@ -8022,11 +8189,44 @@ export class CadreNode implements SAppIdLookup {
8022
8189
  }
8023
8190
 
8024
8191
  /**
8025
- * Form a strand with a responder via an open invitation.
8192
+ * Addresses of the party's other machines for an invitation's bootstrap list
8193
+ * ({@link selectInvitationSiblingAddrs} picks which). The source is each authorized
8194
+ * member's signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record
8195
+ * is missing, stale or untrusted is left out; live connections only order them.
8196
+ *
8197
+ * Best-effort: a failed read leaves the invitation naming this machine alone, which is what
8198
+ * it named before siblings were added.
8199
+ */
8200
+ private async siblingInvitationAddrs(): Promise<string[]> {
8201
+ const controlNode = this.controlNode;
8202
+ if (!controlNode || !this.controlDatabase) {
8203
+ return [];
8204
+ }
8205
+ try {
8206
+ const siblings: InvitationSibling[] = [];
8207
+ for (const { peerId } of await this.listAuthorizedMembers()) {
8208
+ const resolved = await this.resolvePeerRecord(peerId, {});
8209
+ if (resolved && resolved.addrs.length > 0) {
8210
+ siblings.push({ peerId, updatedAt: resolved.updatedAt, addrs: resolved.addrs.map(String) });
8211
+ }
8212
+ }
8213
+ const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
8214
+ return selectInvitationSiblingAddrs(siblings, connected);
8215
+ } catch (error) {
8216
+ log('createOpenInvitation: resolving the other machines failed; the invitation names only this one: %o', error);
8217
+ return [];
8218
+ }
8219
+ }
8220
+
8221
+ /**
8222
+ * Form a strand with a responder via an open invitation: one attempt, from this machine, with
8223
+ * nothing recorded party-wide. {@link requestJoin} is the path that keeps trying.
8026
8224
  *
8027
8225
  * @param invitation - The open invitation received out-of-band
8028
8226
  * @param disclosure - Identity/context information to share with the responder
8029
8227
  * @returns The member key and strand info if successful
8228
+ * @throws `FormationRejectedError` or `FormationUnreachableError` when the formation fails, and
8229
+ * `FormationPostApprovalError` when it was approved and a step on this machine then failed
8030
8230
  */
8031
8231
  async formStrand(
8032
8232
  invitation: OpenInvitation,
@@ -8045,7 +8245,7 @@ export class CadreNode implements SAppIdLookup {
8045
8245
  disclosure,
8046
8246
  this.controlNode
8047
8247
  );
8048
- this.recordFormationStrandPeers(result.strandId, result.strandAddrs);
8248
+ await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs);
8049
8249
  // A closed host strand's approval carries the joiner's own membership invitation —
8050
8250
  // persist this party's identity key and stage the invitation for bring-up to redeem.
8051
8251
  // Runs AFTER the addr recording so a persistence failure (which throws — see the
@@ -8057,6 +8257,190 @@ export class CadreNode implements SAppIdLookup {
8057
8257
  return result;
8058
8258
  }
8059
8259
 
8260
+ /**
8261
+ * Ask to join through `invitation` and have the party keep trying until the join works, the
8262
+ * invitation is used up or expires, or the request is dismissed: across restarts, and from
8263
+ * any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
8264
+ * offline"). Records a party-wide `PendingJoin` row, runs one attempt on this machine at
8265
+ * once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
8266
+ * when it was unreachable or not ready, `'failed'` when it refused for good.
8267
+ *
8268
+ * Asking again for an invitation whose request is still pending adopts that request as it
8269
+ * is; asking again after it finished starts a fresh one. Later changes arrive as
8270
+ * `pendingJoin:changed`. A joined strand is offered through `strand:discovered` like any
8271
+ * {@link formStrand} join. Do not also call {@link formStrand} with the same invitation:
8272
+ * nothing coordinates the two, and one of them gets `token-spent`.
8273
+ *
8274
+ * @throws on a machine that is not an enrolled owner (the row is owner-signed), and on an
8275
+ * invitation already past its expiration
8276
+ */
8277
+ async requestJoin(invitation: OpenInvitation, disclosure: StrandFormationDisclosure = {}): Promise<PendingJoinStatus> {
8278
+ const { database, runner } = this.requirePendingJoins();
8279
+ const signer = await this.requireOwnerSigner(
8280
+ 'requestJoin needs an enrolled owner machine of this party, because the party-wide join request is ' +
8281
+ 'owner-signed. formStrand(invitation, disclosure) joins once from this machine, with no retries.'
8282
+ );
8283
+ const now = Date.now();
8284
+ const expiresAt = invitation.expiration.getTime();
8285
+ if (!Number.isFinite(expiresAt)) {
8286
+ throw new Error('The invitation carries no valid expiration');
8287
+ }
8288
+ if (now >= expiresAt) {
8289
+ throw new Error(`The invitation expired at ${invitation.expiration.toISOString()}; ask the inviter for a fresh one`);
8290
+ }
8291
+ const requested = requestedPendingJoin(
8292
+ pendingJoinId(invitation.token), this.encodeInvitation(invitation), disclosure, expiresAt, now);
8293
+ const row = await this.recordJoinRequest(database, requested, signer);
8294
+ log('requestJoin: pending join %s recorded; attempting now', row.Id);
8295
+ return runner.attemptNow(row);
8296
+ }
8297
+
8298
+ /**
8299
+ * Every join asked for with {@link requestJoin} and not dismissed, pending or finished, as this
8300
+ * machine sees it (see `PendingJoinStatus` for which states are machine-local).
8301
+ */
8302
+ async listPendingJoins(): Promise<PendingJoinStatus[]> {
8303
+ const { database, runner } = this.requirePendingJoins();
8304
+ return (await database.queryPendingJoins()).map((row) => runner.statusOf(row));
8305
+ }
8306
+
8307
+ /**
8308
+ * Remove a join request party-wide. On a pending request this cancels it: every owner machine
8309
+ * stops at its next read, and an attempt already running does not record its outcome (a join
8310
+ * it made stays on that machine, as any {@link formStrand} join does). Owner machines only.
8311
+ * Resolves `false` when there was no such request.
8312
+ */
8313
+ async dismissPendingJoin(id: string): Promise<boolean> {
8314
+ const { database, runner } = this.requirePendingJoins();
8315
+ const { ownerKey, signMessage } = await this.requireOwnerSigner(
8316
+ 'dismissPendingJoin needs an enrolled owner machine of this party: the removal is owner-signed');
8317
+ const removed = await database.deletePendingJoin(id, ownerKey, signMessage);
8318
+ runner.forgetRow(id);
8319
+ return removed;
8320
+ }
8321
+
8322
+ private requirePendingJoins(): { database: ControlDatabase; runner: PendingJoinRunner } {
8323
+ if (!this._running || !this.controlDatabase || !this.pendingJoinRunner) {
8324
+ throw new Error('CadreNode must be started before using pending joins');
8325
+ }
8326
+ return { database: this.controlDatabase, runner: this.pendingJoinRunner };
8327
+ }
8328
+
8329
+ /** This machine's owner key and signer for an owner-signed control write, or a throw carrying `refusal`. */
8330
+ private async requireOwnerSigner(refusal: string): Promise<{ ownerKey: string; signMessage: (message: Uint8Array) => string }> {
8331
+ const signingKey = await this.enrolledOwnerSigningKey();
8332
+ if (!signingKey) {
8333
+ throw new Error(refusal);
8334
+ }
8335
+ return { ownerKey: signingKey.publicKeyB64, signMessage: signMessageWith(signingKey.privateKeyB64) };
8336
+ }
8337
+
8338
+ /**
8339
+ * Write a join request's row, or adopt the party's existing row for the same invitation: a
8340
+ * pending one as it is, a finished one replaced by `requested` (the user asked again).
8341
+ */
8342
+ private async recordJoinRequest(
8343
+ database: ControlDatabase,
8344
+ requested: PendingJoinFields,
8345
+ { ownerKey, signMessage }: { ownerKey: string; signMessage: (message: Uint8Array) => string }
8346
+ ): Promise<PendingJoinRow> {
8347
+ let written: PendingJoinRow;
8348
+ try {
8349
+ written = await database.insertPendingJoin(requested, ownerKey, signMessage);
8350
+ } catch (error) {
8351
+ if (!isPendingJoinConflict(error)) {
8352
+ throw error;
8353
+ }
8354
+ const existing = await database.queryPendingJoin(requested.Id);
8355
+ if (!existing) {
8356
+ throw new Error(
8357
+ `A removed join request for this invitation (${requested.Id}) is still held on this machine until its ` +
8358
+ 'clean-up runs, which needs a connection to the party. Ask again once connected.',
8359
+ { cause: error }
8360
+ );
8361
+ }
8362
+ if (existing.Outcome === null) {
8363
+ return existing;
8364
+ }
8365
+ written = await database.replacePendingJoin(existing.StampId, requested, ownerKey, signMessage);
8366
+ }
8367
+ this.pendingJoinRunner?.noteWritten(written);
8368
+ return written;
8369
+ }
8370
+
8371
+ /** Build and start the pending-join retry loop over this node (see {@link pendingJoinRunner}). */
8372
+ private startPendingJoinRunner(): void {
8373
+ const database = (): ControlDatabase => {
8374
+ if (!this.controlDatabase) {
8375
+ throw new Error('CadreNode is stopped: no control database for pending joins');
8376
+ }
8377
+ return this.controlDatabase;
8378
+ };
8379
+ const outcomeSigner = (): Promise<{ ownerKey: string; signMessage: (message: Uint8Array) => string }> =>
8380
+ this.requireOwnerSigner('Cannot record a pending join outcome: this machine is not an enrolled owner of the party');
8381
+ this.pendingJoinRunner = new PendingJoinRunner({
8382
+ selfId: this.controlNode!.peerId.toString(),
8383
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
8384
+ isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
8385
+ // NOTE: one owner-key read and one `PendingJoin` read per pass (30 s); for a party that never
8386
+ // asked for a join the second reads a never-written block, which consults the cohort. If it
8387
+ // shows up in a device profile, poll more slowly while the table reads empty.
8388
+ readRows: () => database().queryPendingJoins(),
8389
+ readRow: (id) => database().queryPendingJoin(id),
8390
+ attempt: (row) => this.formStrand(this.pendingJoinInvitation(row), parseStoredDisclosure(row)),
8391
+ replace: async (expectedStampId, next) => {
8392
+ const { ownerKey, signMessage } = await outcomeSigner();
8393
+ return database().replacePendingJoin(expectedStampId, next, ownerKey, signMessage);
8394
+ },
8395
+ remove: async (id) => {
8396
+ const { ownerKey, signMessage } = await outcomeSigner();
8397
+ return database().deletePendingJoin(id, ownerKey, signMessage);
8398
+ },
8399
+ isAlone: () => this.committedAlone(),
8400
+ sAppIdOf: (row) => this.pendingJoinSAppId(row),
8401
+ observeRows: (rows) => this.stageMembershipInvitesFromPendingJoins(rows),
8402
+ emit: (status) => this.emit('pendingJoin:changed', status),
8403
+ });
8404
+ this.pendingJoinRunner.start();
8405
+ }
8406
+
8407
+ /** The invitation a `PendingJoin` row stores. A failure names the row, never the invitation, which is a bearer credential. */
8408
+ private pendingJoinInvitation(row: PendingJoinRow): OpenInvitation {
8409
+ try {
8410
+ return this.decodeInvitation(row.Invitation);
8411
+ } catch {
8412
+ throw new Error(`PendingJoin ${row.Id}: the stored invitation does not decode`);
8413
+ }
8414
+ }
8415
+
8416
+ private pendingJoinSAppId(row: PendingJoinRow): string {
8417
+ try {
8418
+ return this.decodeInvitation(row.Invitation).sAppId;
8419
+ } catch {
8420
+ log('PendingJoin %s: the stored invitation does not decode; reporting no sApp', row.Id);
8421
+ return '';
8422
+ }
8423
+ }
8424
+
8425
+ /**
8426
+ * Stage the membership invitation of each `joined` row this process has not staged yet, so a
8427
+ * closed strand joined on another owner machine is seated by whichever machine launches it
8428
+ * first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
8429
+ * seated. A strand that already has an invitation staged keeps it, for the reconciler to
8430
+ * settle first.
8431
+ */
8432
+ private stageMembershipInvitesFromPendingJoins(rows: readonly PendingJoinRow[]): void {
8433
+ for (const { rowId, strandId, invite } of membershipInvitesToStage(rows, this.stagedMembershipInviteKeys, Date.now())) {
8434
+ this.stagedMembershipInviteKeys.add(invite.inviteKey);
8435
+ if (this.pendingMembershipInvites.has(strandId)) {
8436
+ continue;
8437
+ }
8438
+ this.pendingMembershipInvites.set(strandId, invite);
8439
+ log('pending join %s: staged its membership invitation for strand %s', rowId, strandId);
8440
+ this.strandManager.notifyMembershipInviteStaged(strandId);
8441
+ }
8442
+ }
8443
+
8060
8444
  /**
8061
8445
  * Remember the strand a formation just joined (see {@link joinedStrandStore}), so it
8062
8446
  * is re-offered after a restart even when the app is killed before its `addStrand`, and
@@ -8077,7 +8461,8 @@ export class CadreNode implements SAppIdLookup {
8077
8461
  joinedAt: Date.now()
8078
8462
  });
8079
8463
  } catch (error) {
8080
- throw new Error(
8464
+ throw new FormationPostApprovalError(
8465
+ result.strandId,
8081
8466
  `Formation for strand ${result.strandId} was approved (its one-time token is spent), but ` +
8082
8467
  'remembering the join in this node\'s joined-strand store failed, so the strand would not ' +
8083
8468
  'come back after a restart. Fix the store (usually the configured keyStore), then redeem a ' +
@@ -8113,7 +8498,8 @@ export class CadreNode implements SAppIdLookup {
8113
8498
  try {
8114
8499
  await this.ensureStrandPartyKey(strandId);
8115
8500
  } catch (error) {
8116
- throw new Error(
8501
+ throw new FormationPostApprovalError(
8502
+ strandId,
8117
8503
  `Formation for strand ${strandId} was approved (its one-time token is spent), but ` +
8118
8504
  'persisting this party\'s membership identity (StrandPartyKey) failed — the joiner ' +
8119
8505
  'cannot become a member without it. Fix the underlying cause, then redeem a fresh ' +
@@ -8122,6 +8508,7 @@ export class CadreNode implements SAppIdLookup {
8122
8508
  );
8123
8509
  }
8124
8510
  this.pendingMembershipInvites.set(strandId, invite);
8511
+ this.stagedMembershipInviteKeys.add(invite.inviteKey);
8125
8512
  log('formStrand: staged membership invitation for strand %s (party key persisted)', strandId);
8126
8513
  this.strandManager.notifyMembershipInviteStaged(strandId);
8127
8514
  }
@@ -8303,41 +8690,40 @@ export class CadreNode implements SAppIdLookup {
8303
8690
  }
8304
8691
 
8305
8692
  /**
8306
- * Remember the responder's strand-network addresses for a strand this node just
8307
- * formed in the strand peer book (see {@link strandPeerBookStore}), so the strand's
8308
- * discovery seed has something to work with when the app launches it — and again
8309
- * after every restart.
8693
+ * Keep the responder's strand-network addresses for a strand this node just formed
8694
+ * (see {@link formationStrandAddrs}), so the strand's discovery seed has something to
8695
+ * dial when the app launches it.
8310
8696
  *
8311
8697
  * The carried list is peer-agnostic (`sanitizeStrandAddrs` bounds and parses it,
8312
8698
  * nothing more), so it is attributed per peer here (`groupAddrsByPeerId`, the same
8313
- * rule the address-book merge applies) and each peer becomes one unsigned entry seen
8314
- * just now: the responder disclosed the addresses live a moment ago. An entry naming
8315
- * no destination peer is dropped there. Scoped strictly to `strandId`: these
8316
- * addresses reach ONE strand node of ONE other party and must never seed another
8317
- * strand's mesh or the control peerStore. An empty list records nothing, so a later
8318
- * formation against the same strand that DOES disclose addresses is not shadowed.
8319
- */
8320
- private recordFormationStrandPeers(strandId: string, strandAddrs: readonly string[]): void {
8321
- if (strandAddrs.length === 0) {
8322
- log('formStrand: responder disclosed no strand addrs for %s — cross-party seed stays empty', strandId);
8699
+ * rule the address-book merge applies) and kept grouped by peer; an entry naming no
8700
+ * destination peer is dropped there. Scoped strictly to `strandId`: these addresses
8701
+ * reach ONE strand node of ONE other party and must never seed another strand's mesh
8702
+ * or the control peerStore.
8703
+ *
8704
+ * A list that names a peer REPLACES the strand's earlier one: the responder
8705
+ * disclosed its current addresses, so the older list is stale. An empty list, or one
8706
+ * naming no peer, records nothing, so it cannot wipe an earlier disclosure.
8707
+ *
8708
+ * A strand already running when this lands (a re-formation, which is the recovery
8709
+ * path for a dead address) gets the new addresses merged into its address book now.
8710
+ * Nothing else would deliver them: the seed is read only at launch and resume, and the
8711
+ * refresh pass skips a peer FRET already holds a record for — the stale one, here.
8712
+ */
8713
+ private async recordFormationStrandAddrs(strandId: string, strandAddrs: readonly string[]): Promise<void> {
8714
+ const attributed = [...groupAddrsByPeerId([...strandAddrs]).values()]
8715
+ .flatMap((peerAddrs) => peerAddrs.map((addr) => addr.toString()));
8716
+ if (attributed.length === 0) {
8717
+ log('formStrand: responder disclosed no usable strand addrs for %s (%d carried) — cross-party seed unchanged',
8718
+ strandId, strandAddrs.length);
8323
8719
  return;
8324
8720
  }
8325
- const now = Date.now();
8326
- let peers = 0;
8327
- for (const [peerId, addrs] of groupAddrsByPeerId([...strandAddrs])) {
8328
- this.rememberStrandPeer(strandId, {
8329
- peerId,
8330
- addrs: addrs.map((ma) => ma.toString()),
8331
- issuedAt: 0,
8332
- lastSeenAt: now
8333
- }, 'formStrand');
8334
- peers++;
8335
- }
8336
- // A strand already running when this lands (a re-formation) needs nothing more: the
8337
- // refresh pass merges the book into its address book on every reconcile tick, so the
8338
- // new addresses land within 15 s — which matters because re-forming is the recovery
8339
- // path for a dead entry.
8340
- log('formStrand: recorded %d cross-party strand addr(s) for %s under %d peer(s)', strandAddrs.length, strandId, peers);
8721
+ this.formationStrandAddrs.set(strandId, attributed);
8722
+ log('formStrand: kept %d of %d cross-party strand addr(s) for %s', attributed.length, strandAddrs.length, strandId);
8723
+ const runningNode = this.strandManager.getInstance(strandId)?.libp2pNode;
8724
+ if (runningNode) {
8725
+ await this.mergeStrandPeerAddrs(runningNode, attributed, strandId);
8726
+ }
8341
8727
  }
8342
8728
 
8343
8729
  /**