@serfab/cadre-core 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/cadre-node.d.ts +153 -104
  2. package/dist/cadre-node.js +227 -199
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/index.d.ts +2 -4
  5. package/dist/index.js +10 -11
  6. package/dist/index.js.map +1 -1
  7. package/dist/node-local-snapshot.d.ts +6 -4
  8. package/dist/node-local-snapshot.js +7 -3
  9. package/dist/node-local-snapshot.js.map +1 -1
  10. package/dist/peer-join-backfill.d.ts +0 -9
  11. package/dist/peer-join-backfill.js +2 -3
  12. package/dist/peer-join-backfill.js.map +1 -1
  13. package/dist/strand-formation-protocol.d.ts +0 -11
  14. package/dist/strand-formation-protocol.js +3 -4
  15. package/dist/strand-formation-protocol.js.map +1 -1
  16. package/dist/strand-fret-addrs.d.ts +54 -0
  17. package/dist/strand-fret-addrs.js +108 -0
  18. package/dist/strand-fret-addrs.js.map +1 -0
  19. package/dist/strand-instance-manager.d.ts +9 -38
  20. package/dist/strand-instance-manager.js +13 -73
  21. package/dist/strand-instance-manager.js.map +1 -1
  22. package/dist/strand-network-state-file.d.ts +12 -0
  23. package/dist/strand-network-state-file.js +38 -0
  24. package/dist/strand-network-state-file.js.map +1 -0
  25. package/dist/strand-network-state.d.ts +111 -0
  26. package/dist/strand-network-state.js +191 -0
  27. package/dist/strand-network-state.js.map +1 -0
  28. package/dist/types.d.ts +20 -23
  29. package/package.json +11 -10
  30. package/src/cadre-node.ts +238 -205
  31. package/src/index.ts +15 -55
  32. package/src/node-local-snapshot.ts +12 -7
  33. package/src/peer-join-backfill.ts +2 -3
  34. package/src/strand-formation-protocol.ts +3 -4
  35. package/src/strand-fret-addrs.ts +145 -0
  36. package/src/strand-instance-manager.ts +20 -96
  37. package/src/strand-network-state-file.ts +40 -0
  38. package/src/strand-network-state.ts +243 -0
  39. package/src/types.ts +20 -23
  40. package/dist/strand-peer-book-file.d.ts +0 -12
  41. package/dist/strand-peer-book-file.js +0 -37
  42. package/dist/strand-peer-book-file.js.map +0 -1
  43. package/dist/strand-peer-book-protocol.d.ts +0 -168
  44. package/dist/strand-peer-book-protocol.js +0 -356
  45. package/dist/strand-peer-book-protocol.js.map +0 -1
  46. package/dist/strand-peer-book-swap.d.ts +0 -104
  47. package/dist/strand-peer-book-swap.js +0 -284
  48. package/dist/strand-peer-book-swap.js.map +0 -1
  49. package/dist/strand-peer-book.d.ts +0 -159
  50. package/dist/strand-peer-book.js +0 -361
  51. package/dist/strand-peer-book.js.map +0 -1
  52. package/dist/strand-peer-observer.d.ts +0 -88
  53. package/dist/strand-peer-observer.js +0 -180
  54. package/dist/strand-peer-observer.js.map +0 -1
  55. package/src/strand-peer-book-file.ts +0 -39
  56. package/src/strand-peer-book-protocol.ts +0 -466
  57. package/src/strand-peer-book-swap.ts +0 -338
  58. package/src/strand-peer-book.ts +0 -475
  59. package/src/strand-peer-observer.ts +0 -235
package/src/cadre-node.ts CHANGED
@@ -56,8 +56,7 @@ import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
56
56
  import { loadOrCreateIdentityKey } from './identity-key.js';
57
57
  import { MemoryTrustedOwnerStore, type TrustedOwnerStore, type TrustSource } from './trusted-owner-store.js';
58
58
  import { MemoryBootstrapPeerStore, type BootstrapPeerStore } from './bootstrap-peer-store.js';
59
- import { MemoryStrandPeerBookStore, type StrandPeerBookStore, type StrandPeerEntry } from './strand-peer-book.js';
60
- import type { StrandPeerObservation } from './strand-peer-observer.js';
59
+ import { MemoryStrandNetworkStateStore, type StrandNetworkStateStore } from './strand-network-state.js';
61
60
  import { MemoryEnrolledMachineStore, type EnrolledMachineStore } from './enrolled-machine-store.js';
62
61
  import {
63
62
  JoinedStrandSession,
@@ -67,6 +66,7 @@ import {
67
66
  type PartyJoinedStrandLedger
68
67
  } from './joined-strand-store.js';
69
68
  import { mergePeerAddrs, groupAddrsByPeerId, type MergeAddrsResult } from './peer-addr-book.js';
69
+ import { strandFretPeerAddrs } from './strand-fret-addrs.js';
70
70
  import { verifyCadrePeerVoucher } from './peer-authorization.js';
71
71
  import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
72
72
  import {
@@ -208,8 +208,8 @@ function siblingAnswered(outcome: StrandAddrOutcome): boolean {
208
208
  * `primary` followed by the entries of `extra` it does not already contain, de-duplicated
209
209
  * and order-preserving.
210
210
  *
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
211
+ * Used where a strand's discovery seed unions two sources of address strings — the
212
+ * freshly-RPC'd sibling answers and the addresses the strand's formation carried. The
213
213
  * caller passes the FRESHER source as `primary`, so a stale entry can only ever be
214
214
  * appended, never promoted ahead of a current one, and each source keeps whatever
215
215
  * signaling-first ordering it arrived with.
@@ -615,36 +615,55 @@ export class CadreNode implements SAppIdLookup {
615
615
  private readonly strandAddrAskDueAt = new Map<string, number>();
616
616
 
617
617
  /**
618
- * Node-local strand peer book (see `strand-peer-book.ts`): per strand, the strand
619
- * peers this node has met and their last-known strand-network addresses. Three
620
- * writers: {@link recordFormationStrandPeers} files the responder's live strand
621
- * addrs a formation result carried back (`FormationResultMessage.strandAddrs`),
622
- * {@link observeStrandPeer} files every strand peer a running strand node
623
- * identifies, and the per-strand book swap (`strand-peer-book-swap.ts`, handed the
624
- * store as `StartStrandConfig.strandPeerBook`) files the node's own signed entry
625
- * plus every signed entry a strand peer sends it — the only writer whose entries
626
- * carry a signature, and the only one that can refresh a peer's addresses without
627
- * meeting that peer. Read by {@link resolveCohortSeed} (launch + hibernation resume) and
628
- * re-merged by every {@link refreshStrandPeerAddrs} pass, which is what keeps the
629
- * entries alive past the peerStore's one-hour address expiry.
630
- *
631
- * This is the ONLY cross-party discovery input there is. The strand-addr RPC that
618
+ * The strand-network addresses a formation carried back
619
+ * (`FormationResultMessage.strandAddrs`), by strand id: the responder's live strand
620
+ * node addresses, kept so the strand's first attach has something to dial. Written by
621
+ * {@link recordFormationStrandAddrs}, read by {@link resolveCohortSeed} (launch and
622
+ * hibernation resume) and by every refresh pass for the peers the strand node's FRET
623
+ * table holds no record for yet ({@link remergeUnrecordedFormationAddrs}).
624
+ *
625
+ * This is the only cross-party input a FIRST attach has. The strand-addr RPC that
632
626
  * 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
627
+ * only, so without it a joiner's seed for a two-party strand is empty and the mesh
628
+ * never forms. Every later launch has the strand node's saved network state as well
629
+ * ({@link strandNetworkStateStore}), which is what a restart re-finds the other party
630
+ * from (gotchoices/sereus#18).
631
+ *
632
+ * A re-formation replaces the strand's list. An entry survives a {@link stopStrand}
633
+ * and a stop()→start() of this node instance ({@link cleanup} leaves the map alone),
634
+ * and is dropped where the saved network state is: {@link unpublishStrand},
635
+ * {@link forgetJoinedStrand} and self-revocation.
636
+ *
637
+ * NOTE: accepted tradeoff — in memory only, so a process restart between `formStrand`
638
+ * and the strand's first launch loses the carried addresses, and the strand then
639
+ * launches with no cross-party seed until it is re-formed. Persisting the list on the
640
+ * joined-strand record was weighed and declined: it mixes address state into the join
641
+ * record, and needs an "only until the first run" rule to stop dead addresses being
642
+ * dialled on every launch. Every embedder launches straight after forming, and once
643
+ * the strand has run the saved network state covers every later restart. Revisit if an
644
+ * embedder forms and launches in separate sessions.
645
+ */
646
+ private readonly formationStrandAddrs = new Map<string, string[]>();
647
+
648
+ /**
649
+ * Node-local strand network state (see `strand-network-state.ts`): per strand, the
650
+ * state db-p2p saves for the strand's libp2p node — its FRET routing table with each
651
+ * peer's signed address record. Handed to every strand launch as
652
+ * `StartStrandConfig.networkState`; the strand node saves into it on every connection
653
+ * and re-imports it when it is next built, which is what gives a restarted strand
654
+ * node addresses for the peers it was talking to.
655
+ *
656
+ * Constructed (or adopted from `config.strandNetworkState.store`) by
657
+ * {@link initializeStrandNetworkStateStore} during {@link start}, deliberately NOT
638
658
  * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same node
639
659
  * instance — same lifecycle as {@link bootstrapPeerStore}.
640
660
  *
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.
661
+ * A strand's state survives a {@link stopStrand} and a hibernation quiesce, and is
662
+ * forgotten by {@link unpublishStrand}, {@link forgetJoinedStrand} and
663
+ * self-revocation. A strand detached because the watcher saw its row gone keeps its
664
+ * state (`NOTE:` on `PersistentStrandNetworkStateStore`).
646
665
  */
647
- private strandPeerBookStore: StrandPeerBookStore | null = null;
666
+ private strandNetworkStateStore: StrandNetworkStateStore | null = null;
648
667
 
649
668
  /**
650
669
  * PENDING strand membership invitations learned at formation, keyed by strandId — the
@@ -673,7 +692,7 @@ export class CadreNode implements SAppIdLookup {
673
692
  * NOTE: entries live for the node's lifetime (one small pair per formed closed
674
693
  * strand); the number of keys is bounded only by the strands this node has ever
675
694
  * formed, not by time — if a node ever forms strands at scale, evict on
676
- * `unpublishStrand` / `forgetJoinedStrand` as the strand peer book does.
695
+ * `unpublishStrand` / `forgetJoinedStrand` as {@link formationStrandAddrs} is.
677
696
  */
678
697
  private readonly pendingMembershipInvites = new Map<string, StrandMembershipInvite>();
679
698
 
@@ -1114,8 +1133,9 @@ export class CadreNode implements SAppIdLookup {
1114
1133
  // fails closed like the two above.
1115
1134
  this.initializeJoinedStrandStore();
1116
1135
 
1117
- // The strand peer book those joins are dialed from. Same placement, same reason.
1118
- this.initializeStrandPeerBookStore();
1136
+ // Each strand node's saved network state, which those joins are dialed from after
1137
+ // a restart. Same placement, same reason.
1138
+ this.initializeStrandNetworkStateStore();
1119
1139
 
1120
1140
  // Read the party's enrolled-machine count out of its node-local record and
1121
1141
  // capture it for buildControlNodeOptions below. This MUST precede
@@ -1542,27 +1562,27 @@ export class CadreNode implements SAppIdLookup {
1542
1562
  }
1543
1563
 
1544
1564
  /**
1545
- * Construct (or adopt) the node-local strand peer book (see
1546
- * {@link strandPeerBookStore}). Synchronous for the reason
1565
+ * Construct (or adopt) the node-local strand network state (see
1566
+ * {@link strandNetworkStateStore}). Synchronous for the reason
1547
1567
  * {@link initializeBootstrapPeerStore} is: an injected store has already loaded its
1548
1568
  * persisted entries by the time it is handed in, and the in-memory fallback has
1549
1569
  * 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.
1570
+ * different party is a configuration error, fail closed: another party's routing
1571
+ * tables must never be imported into this node's strands.
1552
1572
  */
1553
- private initializeStrandPeerBookStore(): void {
1573
+ private initializeStrandNetworkStateStore(): void {
1554
1574
  const partyId = this.config.controlNetwork.partyId;
1555
- if (this.strandPeerBookStore) {
1575
+ if (this.strandNetworkStateStore) {
1556
1576
  return;
1557
1577
  }
1558
- const store = this.config.strandPeers?.store ?? new MemoryStrandPeerBookStore(partyId);
1578
+ const store = this.config.strandNetworkState?.store ?? new MemoryStrandNetworkStateStore(partyId);
1559
1579
  if (store.partyId !== partyId) {
1560
1580
  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`
1581
+ `CadreNodeConfig: strandNetworkState.store is scoped to party ${store.partyId}, ` +
1582
+ `but this node serves party ${partyId} — refusing to mix strand network state`
1563
1583
  );
1564
1584
  }
1565
- this.strandPeerBookStore = store;
1585
+ this.strandNetworkStateStore = store;
1566
1586
  }
1567
1587
 
1568
1588
  /**
@@ -1585,13 +1605,13 @@ export class CadreNode implements SAppIdLookup {
1585
1605
  }
1586
1606
 
1587
1607
  /**
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?".
1608
+ * The node-local strand network state (null before {@link start}) — per strand, the
1609
+ * state its strand node last saved and will re-import when next built. Exposed for
1610
+ * diagnostics and for a host that wants to show "what routing table would this
1611
+ * strand restart with?".
1592
1612
  */
1593
- getStrandPeerBookStore(): StrandPeerBookStore | null {
1594
- return this.strandPeerBookStore;
1613
+ getStrandNetworkStateStore(): StrandNetworkStateStore | null {
1614
+ return this.strandNetworkStateStore;
1595
1615
  }
1596
1616
 
1597
1617
  /**
@@ -3117,10 +3137,10 @@ export class CadreNode implements SAppIdLookup {
3117
3137
  if (!this._running || !this.controlNode || !this.controlDatabase) {
3118
3138
  return { dialed: [] };
3119
3139
  }
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.
3140
+ // Then re-warm each running strand's own address book from its FRET address
3141
+ // records and from whichever siblings are due an ask. Same reasoning as
3142
+ // warmSiblingAddrBook below, one layer down: replication runs on the STRAND
3143
+ // network, and every layer under cadre-core dials strand peers by bare peer id.
3124
3144
  await this.refreshStrandPeerAddrs();
3125
3145
  if (!this._running || !this.controlNode || !this.controlDatabase) {
3126
3146
  return { dialed: [] };
@@ -5851,9 +5871,9 @@ export class CadreNode implements SAppIdLookup {
5851
5871
  // `privateKey` — tests, and an embedder that opted out of a stable identity) the
5852
5872
  // strand node still runs under a key CADRE-CORE HOLDS: a fresh random Ed25519 key,
5853
5873
  // 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.
5874
+ // delegate announcement below can name the strand peer id before the node exists and
5875
+ // the retained launch config keeps that id across a hibernation resume. Stability
5876
+ // across RESTARTS still needs an identity key.
5857
5877
  //
5858
5878
  // NOTE: derivation requires an Ed25519 identity key, so a node configured
5859
5879
  // with some other key type now fails strand launch outright (surfaced as
@@ -5908,16 +5928,12 @@ export class CadreNode implements SAppIdLookup {
5908
5928
  this.forgetRevokedJoin(revokedStrandId);
5909
5929
  this.emit('strand:revoked', { strandId: revokedStrandId });
5910
5930
  },
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,
5931
+ // Where the strand node saves its network state and what it re-imports when it is
5932
+ // built. Retained with the launch config, so a hibernation wake rebuilds the node
5933
+ // over the table the quiesced node last saved. `?? undefined`: the field is `null`
5934
+ // before start(), and a strand cannot launch before start(), so this is belt and
5935
+ // braces.
5936
+ networkState: this.strandNetworkStateStore ?? undefined,
5921
5937
  onRejoinBlocked: (blockedStrandId) => this.emit('strand:rejoin-blocked', { strandId: blockedStrandId }),
5922
5938
  // The joiner's first-sync write gate (strand-first-sync-gate.ts): a launch that
5923
5939
  // comes up `'syncing'` announces the moment its database is published.
@@ -6050,32 +6066,11 @@ export class CadreNode implements SAppIdLookup {
6050
6066
  */
6051
6067
  private async resolveCohortSeed(strandId: string, delegatePeerId?: string): Promise<string[]> {
6052
6068
  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
6069
+ // Sibling answers FIRST: they were resolved just now, while the formation-carried
6070
+ // addresses are as old as the formation. `unionAddrs` appends only what the siblings
6055
6071
  // 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;
6072
+ // its own ordering.
6073
+ return unionAddrs(siblings, this.formationStrandAddrs.get(strandId) ?? []);
6079
6074
  }
6080
6075
 
6081
6076
  /**
@@ -6311,18 +6306,19 @@ export class CadreNode implements SAppIdLookup {
6311
6306
  }
6312
6307
 
6313
6308
  /**
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
6309
+ * Keep each running strand's own libp2p address book warm: re-merge the address
6310
+ * records its FRET routing table holds on every pass
6311
+ * ({@link remergeStrandFretRecords}), and re-ask each connected SIBLING for its
6312
+ * strand addresses over the control mesh when that (sibling, strand) is due
6317
6313
  * ({@link strandAddrAskDueAt}).
6318
6314
  *
6319
- * Without this, a strand's address book is written exactly once — the
6315
+ * Without this, cadre-core writes a strand's address book exactly once — the
6320
6316
  * launch/resume seed — and everything below cadre-core that dials a strand peer
6321
6317
  * 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
6318
+ * loses its address for any peer it is not currently connected to: a sibling
6323
6319
  * 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`).
6320
+ * re-resolved, and every address, another party's included, falls off at the
6321
+ * peerStore's one-hour expiry (see `peer-addr-book.ts`).
6326
6322
  *
6327
6323
  * Due times are per (sibling, strand) and set from each sibling's own outcome, so
6328
6324
  * a sibling that connects late — a phone joining after the party's always-on
@@ -6405,15 +6401,15 @@ export class CadreNode implements SAppIdLookup {
6405
6401
 
6406
6402
  /**
6407
6403
  * 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.
6404
+ * due and merge their answers into the strand's address book, then re-merge the
6405
+ * address records the strand node's FRET table holds. Errors are logged and
6406
+ * swallowed so one strand's failure never costs the others their refresh.
6411
6407
  *
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.
6408
+ * The sibling half runs first, so a failure reading the FRET table cannot cost a
6409
+ * sibling answer its merge. The FRET half, and the formation-carried addresses of
6410
+ * the peers it holds no record for, run on EVERY pass, whether or not any sibling is
6411
+ * due or connected — see {@link remergeStrandFretRecords} and
6412
+ * {@link remergeUnrecordedFormationAddrs} for why.
6417
6413
  */
6418
6414
  private async refreshOneStrandPeerAddrs(
6419
6415
  strandId: string,
@@ -6429,7 +6425,6 @@ export class CadreNode implements SAppIdLookup {
6429
6425
  // Inside the try: a strand node torn down mid-pass may throw on any read, and one
6430
6426
  // strand's failure must never cost the others their refresh.
6431
6427
  const strandPeerId = strandNode.peerId.toString();
6432
- const contactAddrs = this.strandPeerBookAddrs(strandId, strandPeerId);
6433
6428
  const due = targets.filter(
6434
6429
  (target) => now >= (this.strandAddrAskDueAt.get(peerStrandKey(target.peerId, strandId)) ?? 0)
6435
6430
  );
@@ -6438,26 +6433,95 @@ export class CadreNode implements SAppIdLookup {
6438
6433
  const siblingAddrs = due.length === 0
6439
6434
  ? []
6440
6435
  : 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) {
6436
+ if (!this.isRunningStrandNode(strandId, strandNode)) {
6444
6437
  return;
6445
6438
  }
6446
- const addrs = unionAddrs(siblingAddrs, contactAddrs);
6447
- if (addrs.length === 0) {
6448
- return;
6439
+ if (siblingAddrs.length > 0) {
6440
+ await this.mergeStrandPeerAddrs(strandNode, siblingAddrs, strandId);
6449
6441
  }
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);
6442
+ const recorded = await this.remergeStrandFretRecords(strandNode, strandId);
6443
+ await this.remergeUnrecordedFormationAddrs(strandNode, strandId, recorded);
6456
6444
  } catch (error) {
6457
6445
  log('refreshStrandPeerAddrs: strand %s refresh failed (continuing): %o', strandId, error);
6458
6446
  }
6459
6447
  }
6460
6448
 
6449
+ /**
6450
+ * Is `strandNode` still the node `strandId` runs on? Asked again after every await
6451
+ * in a refresh pass: a strand stopped mid-pass, or one already restarted onto a new
6452
+ * node, must never have its store written to.
6453
+ */
6454
+ private isRunningStrandNode(strandId: string, strandNode: Libp2p): boolean {
6455
+ return this._running && this.strandManager.getInstance(strandId)?.libp2pNode === strandNode;
6456
+ }
6457
+
6458
+ /**
6459
+ * Re-merge, into a running strand node's address book, the addresses of every peer
6460
+ * its FRET routing table holds a signed address record for (`strand-fret-addrs.ts`).
6461
+ *
6462
+ * This is what keeps ANOTHER PARTY's strand nodes dialable from a node that stays
6463
+ * up. Nothing re-resolves their addresses — the strand-addr RPC answers own-party
6464
+ * siblings only — and the peerStore hides an address one hour after it was first
6465
+ * observed. FRET keeps each peer's record but hands it to the peerStore only when it
6466
+ * arrives or is imported, so without this pass a connection to another party that
6467
+ * drops after the first hour could not be redialed by either side.
6468
+ *
6469
+ * A record is held while FRET holds the peer's table entry, and at most 14 days after
6470
+ * it was last confirmed, so that is also how long a wrong address can keep being
6471
+ * re-merged. Each record is signed by the peer it names and verified before use, so
6472
+ * the exposure is failed dials to an address that peer itself published.
6473
+ *
6474
+ * NOTE: every pass serialises the whole FRET table (`exportTable()`), verifies one
6475
+ * signature per record and makes one `peerStore.merge` per peer, per running strand
6476
+ * per 15 s tick. Fine at strand table sizes, and an unchanged address set writes
6477
+ * nothing in libp2p's persistent peer store. If many strands or large tables make it
6478
+ * show up, read only the entries whose address stamp is near the one-hour mark.
6479
+ *
6480
+ * @returns the ids of the peers the table holds a usable record for.
6481
+ */
6482
+ private async remergeStrandFretRecords(strandNode: Libp2p, strandId: string): Promise<ReadonlySet<string>> {
6483
+ const { peers, rejected } = await strandFretPeerAddrs(strandNode);
6484
+ const recorded = new Set(peers.keys());
6485
+ if ((peers.size === 0 && rejected === 0) || !this.isRunningStrandNode(strandId, strandNode)) {
6486
+ return recorded;
6487
+ }
6488
+ const counts: Record<MergeAddrsResult, number> = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
6489
+ for (const [peerId, addrs] of peers) {
6490
+ counts[await mergePeerAddrs(strandNode, peerId, addrs)]++;
6491
+ }
6492
+ log('strand %s FRET address records re-merged (peers=%d, merged=%d, restamped=%d, failed=%d, rejected=%d)',
6493
+ strandId, peers.size, counts.merged, counts.restamped, counts.failed, rejected);
6494
+ return recorded;
6495
+ }
6496
+
6497
+ /**
6498
+ * Re-merge the addresses this strand's formation carried ({@link formationStrandAddrs})
6499
+ * for every peer the strand node's FRET table holds no record for (`recorded`).
6500
+ *
6501
+ * Until the first connection to the responder, the carried addresses are the only
6502
+ * thing naming it, and the launch seed writes them once. FRET's `bootstraps` keep only
6503
+ * the peer id, so if the responder stays unreachable past the peerStore's one-hour
6504
+ * expiry, nothing could dial it when it returns. Once FRET holds the peer's own signed
6505
+ * record, that record supersedes what the formation carried and this stops.
6506
+ */
6507
+ private async remergeUnrecordedFormationAddrs(
6508
+ strandNode: Libp2p,
6509
+ strandId: string,
6510
+ recorded: ReadonlySet<string>
6511
+ ): Promise<void> {
6512
+ const carried = this.formationStrandAddrs.get(strandId);
6513
+ if (!carried) {
6514
+ return;
6515
+ }
6516
+ const unrecorded = [...groupAddrsByPeerId(carried)]
6517
+ .filter(([peerId]) => !recorded.has(peerId))
6518
+ .flatMap(([, addrs]) => addrs.map((addr) => addr.toString()));
6519
+ if (unrecorded.length === 0 || !this.isRunningStrandNode(strandId, strandNode)) {
6520
+ return;
6521
+ }
6522
+ await this.mergeStrandPeerAddrs(strandNode, unrecorded, strandId);
6523
+ }
6524
+
6461
6525
  /**
6462
6526
  * RPC `due` siblings for `strandId` and set each one's next due time from its own
6463
6527
  * outcome: an answer, even an empty one, waits the full refresh interval; anything
@@ -6490,11 +6554,11 @@ export class CadreNode implements SAppIdLookup {
6490
6554
  * attributed per peer. Best-effort throughout: an address-book write must never
6491
6555
  * fail a launch, a resume, or a reconcile pass.
6492
6556
  *
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.
6557
+ * `addrs` is a peer-agnostic list — the union the strand-addr RPC returns, or the
6558
+ * addresses a formation carried — so {@link groupAddrsByPeerId} attributes each
6559
+ * entry to the **strand transport** peerId in its final `/p2p/` component — never
6560
+ * the sibling's control peerId, which names a different libp2p node entirely.
6561
+ * Entries that name no peer are dropped there and counted here, once per pass.
6498
6562
  *
6499
6563
  * NOTE: a member could answer with arbitrary multiaddrs bound to arbitrary peer
6500
6564
  * ids and poison this address book. That is the same exposure the launch-time
@@ -6504,14 +6568,13 @@ export class CadreNode implements SAppIdLookup {
6504
6568
  * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
6505
6569
  * `backlog/strand-network-nat-relay-reachability`.
6506
6570
  *
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.
6571
+ * NOTE: one input outlives that hour — a formation's carried addresses
6572
+ * ({@link formationStrandAddrs}) are in every launch and resume seed for as long as
6573
+ * this process remembers the formation, and re-merged on every refresh pass until the
6574
+ * strand node's FRET table holds the peer's own record, so a junk address from a
6575
+ * responder that is never met keeps being re-merged. Bounded by `sanitizeStrandAddrs`,
6576
+ * replaced by a re-formation, dropped on `unpublishStrand` / `forgetJoinedStrand`, and
6577
+ * still authority-free — so the exposure is a handful of failed dials, not a trust hole.
6515
6578
  */
6516
6579
  private async mergeStrandPeerAddrs(strandNode: Libp2p, addrs: string[], strandId: string): Promise<void> {
6517
6580
  const counts: Record<MergeAddrsResult, number> = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
@@ -6562,7 +6625,7 @@ export class CadreNode implements SAppIdLookup {
6562
6625
  // is normally a no-op; it is the guard for a bare listen addr and for a relay hop
6563
6626
  // whose trailing `/p2p/` names the RELAY. An addr terminating in a DIFFERENT id is
6564
6627
  // 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.
6628
+ // would file our address under someone else's id in the receiver's address book.
6566
6629
  //
6567
6630
  // Re-parsed through this package's own `multiaddr` rather than passed straight in:
6568
6631
  // libp2p hands back its nested `@multiformats/multiaddr` copy, a structurally
@@ -6583,7 +6646,8 @@ export class CadreNode implements SAppIdLookup {
6583
6646
  /**
6584
6647
  * Leave a strand joined from another party, for the whole party: remove its party-wide
6585
6648
  * `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
6649
+ * unpublished record, its formation-carried addresses, its saved network state and any
6650
+ * session-kept entry, then
6587
6651
  * {@link stopStrand} it here. Every other machine's watcher then sees the row gone and
6588
6652
  * detaches the strand, a storage replica included; a machine offline at the time reaps the
6589
6653
  * row once the tombstone reaches it. The joiner's counterpart of {@link unpublishStrand},
@@ -6641,8 +6705,9 @@ export class CadreNode implements SAppIdLookup {
6641
6705
  * session here — the `strand:revoked` contract is that nothing is torn down for the app —
6642
6706
  * and its party-wide row is queued for removal by the next connected owner reconcile pass
6643
6707
  * (`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.
6708
+ * which have neither record; the formation-carried addresses and the saved network
6709
+ * state are forgotten either way, since a removed party must not keep dialing the
6710
+ * strand's peers.
6646
6711
  *
6647
6712
  * NOTE: accepted tradeoff — a sibling machine that has not raised `strand:revoked` itself
6648
6713
  * detaches the strand (`strand:stopped`) when the party-wide removal reaches it, instead
@@ -6668,47 +6733,16 @@ export class CadreNode implements SAppIdLookup {
6668
6733
  }
6669
6734
 
6670
6735
  /**
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.
6736
+ * Forget what this node holds about a strand's peers: the addresses its formation
6737
+ * carried (see {@link formationStrandAddrs}) and its saved network state (see
6738
+ * {@link strandNetworkStateStore}). The store write is fire-and-log — the removal is
6739
+ * visible synchronously by the store's contract, and a failed persist only leaves the
6740
+ * entry on disk for the next start.
6674
6741
  */
6675
6742
  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);
6743
+ this.formationStrandAddrs.delete(strandId);
6744
+ void this.strandNetworkStateStore?.forget(strandId).catch((error: unknown) => {
6745
+ log('%s: forgetting strand %s network state failed (continuing): %o', caller, strandId, error);
6712
6746
  });
6713
6747
  }
6714
6748
 
@@ -8045,7 +8079,7 @@ export class CadreNode implements SAppIdLookup {
8045
8079
  disclosure,
8046
8080
  this.controlNode
8047
8081
  );
8048
- this.recordFormationStrandPeers(result.strandId, result.strandAddrs);
8082
+ await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs);
8049
8083
  // A closed host strand's approval carries the joiner's own membership invitation —
8050
8084
  // persist this party's identity key and stage the invitation for bring-up to redeem.
8051
8085
  // Runs AFTER the addr recording so a persistence failure (which throws — see the
@@ -8303,41 +8337,40 @@ export class CadreNode implements SAppIdLookup {
8303
8337
  }
8304
8338
 
8305
8339
  /**
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.
8340
+ * Keep the responder's strand-network addresses for a strand this node just formed
8341
+ * (see {@link formationStrandAddrs}), so the strand's discovery seed has something to
8342
+ * dial when the app launches it.
8310
8343
  *
8311
8344
  * The carried list is peer-agnostic (`sanitizeStrandAddrs` bounds and parses it,
8312
8345
  * 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);
8346
+ * rule the address-book merge applies) and kept grouped by peer; an entry naming no
8347
+ * destination peer is dropped there. Scoped strictly to `strandId`: these addresses
8348
+ * reach ONE strand node of ONE other party and must never seed another strand's mesh
8349
+ * or the control peerStore.
8350
+ *
8351
+ * A list that names a peer REPLACES the strand's earlier one: the responder
8352
+ * disclosed its current addresses, so the older list is stale. An empty list, or one
8353
+ * naming no peer, records nothing, so it cannot wipe an earlier disclosure.
8354
+ *
8355
+ * A strand already running when this lands (a re-formation, which is the recovery
8356
+ * path for a dead address) gets the new addresses merged into its address book now.
8357
+ * Nothing else would deliver them: the seed is read only at launch and resume, and the
8358
+ * refresh pass skips a peer FRET already holds a record for — the stale one, here.
8359
+ */
8360
+ private async recordFormationStrandAddrs(strandId: string, strandAddrs: readonly string[]): Promise<void> {
8361
+ const attributed = [...groupAddrsByPeerId([...strandAddrs]).values()]
8362
+ .flatMap((peerAddrs) => peerAddrs.map((addr) => addr.toString()));
8363
+ if (attributed.length === 0) {
8364
+ log('formStrand: responder disclosed no usable strand addrs for %s (%d carried) — cross-party seed unchanged',
8365
+ strandId, strandAddrs.length);
8323
8366
  return;
8324
8367
  }
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);
8368
+ this.formationStrandAddrs.set(strandId, attributed);
8369
+ log('formStrand: kept %d of %d cross-party strand addr(s) for %s', attributed.length, strandAddrs.length, strandId);
8370
+ const runningNode = this.strandManager.getInstance(strandId)?.libp2pNode;
8371
+ if (runningNode) {
8372
+ await this.mergeStrandPeerAddrs(runningNode, attributed, strandId);
8373
+ }
8341
8374
  }
8342
8375
 
8343
8376
  /**