@serfab/cadre-core 1.5.0 → 1.7.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 (68) hide show
  1. package/dist/cadre-node.d.ts +206 -67
  2. package/dist/cadre-node.js +399 -98
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/index.d.ts +6 -1
  5. package/dist/index.js +17 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/joined-strand-store.d.ts +112 -0
  8. package/dist/joined-strand-store.js +263 -0
  9. package/dist/joined-strand-store.js.map +1 -0
  10. package/dist/key-store.d.ts +6 -0
  11. package/dist/key-store.js.map +1 -1
  12. package/dist/link-budget.d.ts +95 -53
  13. package/dist/link-budget.js +97 -53
  14. package/dist/link-budget.js.map +1 -1
  15. package/dist/node-local-snapshot.d.ts +5 -4
  16. package/dist/node-local-snapshot.js +6 -3
  17. package/dist/node-local-snapshot.js.map +1 -1
  18. package/dist/peer-dial.d.ts +3 -3
  19. package/dist/peer-dial.js +3 -3
  20. package/dist/peer-join-backfill.d.ts +35 -16
  21. package/dist/peer-join-backfill.js +117 -57
  22. package/dist/peer-join-backfill.js.map +1 -1
  23. package/dist/relay-reservation.d.ts +2 -2
  24. package/dist/relay-reservation.js +2 -2
  25. package/dist/seed-bootstrap.js +1 -1
  26. package/dist/strand-first-sync-gate.d.ts +88 -43
  27. package/dist/strand-first-sync-gate.js +92 -46
  28. package/dist/strand-first-sync-gate.js.map +1 -1
  29. package/dist/strand-instance-manager.d.ts +39 -0
  30. package/dist/strand-instance-manager.js +80 -3
  31. package/dist/strand-instance-manager.js.map +1 -1
  32. package/dist/strand-peer-book-file.d.ts +12 -0
  33. package/dist/strand-peer-book-file.js +37 -0
  34. package/dist/strand-peer-book-file.js.map +1 -0
  35. package/dist/strand-peer-book-protocol.d.ts +159 -0
  36. package/dist/strand-peer-book-protocol.js +360 -0
  37. package/dist/strand-peer-book-protocol.js.map +1 -0
  38. package/dist/strand-peer-book-swap.d.ts +104 -0
  39. package/dist/strand-peer-book-swap.js +284 -0
  40. package/dist/strand-peer-book-swap.js.map +1 -0
  41. package/dist/strand-peer-book.d.ts +159 -0
  42. package/dist/strand-peer-book.js +354 -0
  43. package/dist/strand-peer-book.js.map +1 -0
  44. package/dist/strand-peer-observer.d.ts +88 -0
  45. package/dist/strand-peer-observer.js +180 -0
  46. package/dist/strand-peer-observer.js.map +1 -0
  47. package/dist/types.d.ts +84 -17
  48. package/dist/types.js +6 -0
  49. package/dist/types.js.map +1 -1
  50. package/package.json +11 -7
  51. package/src/cadre-node.ts +7776 -7445
  52. package/src/index.ts +68 -0
  53. package/src/joined-strand-store.ts +308 -0
  54. package/src/key-store.ts +6 -0
  55. package/src/link-budget.ts +100 -53
  56. package/src/node-local-snapshot.ts +293 -289
  57. package/src/peer-dial.ts +3 -3
  58. package/src/peer-join-backfill.ts +709 -647
  59. package/src/relay-reservation.ts +2 -2
  60. package/src/seed-bootstrap.ts +1 -1
  61. package/src/strand-first-sync-gate.ts +352 -306
  62. package/src/strand-instance-manager.ts +106 -3
  63. package/src/strand-peer-book-file.ts +39 -0
  64. package/src/strand-peer-book-protocol.ts +463 -0
  65. package/src/strand-peer-book-swap.ts +338 -0
  66. package/src/strand-peer-book.ts +468 -0
  67. package/src/strand-peer-observer.ts +235 -0
  68. package/src/types.ts +86 -17
@@ -1,6 +1,7 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
3
  import { peerIdFromString, peerIdFromPrivateKey } from '@libp2p/peer-id';
4
+ import { generateKeyPair } from '@libp2p/crypto/keys';
4
5
  import { createLibp2pNode } from '@optimystic/db-p2p';
5
6
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
6
7
  import { multiaddr } from '@multiformats/multiaddr';
@@ -16,7 +17,9 @@ import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
16
17
  import { loadOrCreateIdentityKey } from './identity-key.js';
17
18
  import { MemoryTrustedOwnerStore } from './trusted-owner-store.js';
18
19
  import { MemoryBootstrapPeerStore } from './bootstrap-peer-store.js';
20
+ import { MemoryStrandPeerBookStore } from './strand-peer-book.js';
19
21
  import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
22
+ import { JoinedStrandRows, KeyStoreJoinedStrandStore, MemoryJoinedStrandStore } from './joined-strand-store.js';
20
23
  import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
21
24
  import { verifyCadrePeerVoucher } from './peer-authorization.js';
22
25
  import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
@@ -28,13 +31,12 @@ import { PeerJoinBackfill } from './peer-join-backfill.js';
28
31
  import { deriveCohortMembers } from './strand-cohort.js';
29
32
  import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
30
33
  import { dialPeerAddrs, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
31
- import { peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
34
+ import { connectionManagerTimeouts, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
32
35
  import { EnrollmentService } from './enrollment.js';
33
36
  import { HibernationManager } from './hibernation-manager.js';
34
37
  import { ControlDatabase, isStrandIdConflict } from './control-database.js';
35
38
  import { SeedBootstrapService } from './seed-bootstrap.js';
36
39
  import { StrandSolicitationService } from './strand-solicitation.js';
37
- import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
38
40
  import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS, UnauthorizedReservationBudget } from './membership-connection-gater.js';
39
41
  import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
40
42
  import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
@@ -78,7 +80,7 @@ function relayStrandAddrPeer(relay) {
78
80
  * and order-preserving.
79
81
  *
80
82
  * Used wherever a strand's discovery seed unions two sources of address strings — the
81
- * freshly-RPC'd sibling answers and the cross-party addresses learned at formation. The
83
+ * freshly-RPC'd sibling answers and the strand peer book's last-known addresses. The
82
84
  * caller passes the FRESHER source as `primary`, so a stale entry can only ever be
83
85
  * appended, never promoted ahead of a current one, and each source keeps whatever
84
86
  * signaling-first ordering it arrived with.
@@ -373,37 +375,36 @@ export class CadreNode {
373
375
  */
374
376
  this.strandPeerAddrRefreshAt = new Map();
375
377
  /**
376
- * CROSS-PARTY strand-network addresses learned at formation, keyed by strandId — the
377
- * responder's live strand addrs, carried back on the formation result
378
- * (`FormationResultMessage.strandAddrs`) and recorded by {@link formStrand}.
378
+ * Node-local strand peer book (see `strand-peer-book.ts`): per strand, the strand
379
+ * peers this node has met and their last-known strand-network addresses. Three
380
+ * writers: {@link recordFormationStrandPeers} files the responder's live strand
381
+ * addrs a formation result carried back (`FormationResultMessage.strandAddrs`),
382
+ * {@link observeStrandPeer} files every strand peer a running strand node
383
+ * identifies, and the per-strand book swap (`strand-peer-book-swap.ts`, handed the
384
+ * store as `StartStrandConfig.strandPeerBook`) files the node's own signed entry
385
+ * plus every signed entry a strand peer sends it — the only writer whose entries
386
+ * carry a signature, and the only one that can refresh a peer's addresses without
387
+ * meeting that peer. Read by {@link resolveCohortSeed} (launch + hibernation resume) and
388
+ * re-merged by every {@link refreshStrandPeerAddrs} pass, which is what keeps the
389
+ * entries alive past the peerStore's one-hour address expiry.
379
390
  *
380
391
  * This is the ONLY cross-party discovery input there is. The strand-addr RPC that
381
392
  * resolves a strand's addresses is membership-gated and answers own-party siblings
382
- * only, so without this map a joiner's cohort seed for a two-party strand is empty and
383
- * the mesh never forms.
393
+ * only, so without the book a joiner's cohort seed for a two-party strand is empty
394
+ * and the mesh never forms — and, with an in-memory book, never RE-forms after a
395
+ * restart (gotchoices/sereus#18). A STORE rather than a map so an embedder can make
396
+ * it outlive the process; constructed (or adopted from `config.strandPeers.store`)
397
+ * by {@link initializeStrandPeerBookStore} during {@link start}, deliberately NOT
398
+ * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same node
399
+ * instance — same lifecycle as {@link bootstrapPeerStore}.
384
400
  *
385
- * Read by {@link resolveCohortSeed} (launch + hibernation resume) and re-merged by
386
- * every {@link refreshStrandPeerAddrs} pass — the re-merge is what keeps the entries
387
- * alive past the peerStore's one-hour address expiry, since nothing can re-resolve
388
- * them.
389
- *
390
- * IN-MEMORY and ONE-SHOT, deliberately (see `docs/strands.md`): the addresses die with
391
- * the process, and a restarted initiator with no sibling running the strand is back to
392
- * an empty seed until the durability work lands
393
- * (`backlog/feat-cross-party-strand-addr-durability`). They are also never refreshed,
394
- * so a responder whose relay reservation rotates before the initiator dials leaves a
395
- * dead entry behind; recovery today is a fresh invitation.
396
- *
397
- * NOTE: entries are kept for the node's lifetime, including across a
398
- * {@link stopStrand} — a stopped strand may be claimed again with {@link addStrand}
399
- * (the watcher itself will not re-offer it: the stop suppresses the id for the rest of
400
- * the session) and must still get its seed. Each strand's list is capped at
401
- * `MAX_STRAND_ADDRS`, but the
402
- * number of KEYS is bounded only by the strands this node has ever formed as an
403
- * initiator (not by time); if a node ever forms strands at scale, evict on
404
- * `unpublishStrand` or cap the map.
401
+ * Entries survive a {@link stopStrand} (a stopped strand may be claimed again with
402
+ * {@link addStrand}, and is rediscovered on restart) and are forgotten by
403
+ * {@link unpublishStrand}, {@link forgetJoinedStrand} and self-revocation; the store
404
+ * ages the rest out after 14 days and caps each strand at 16 peers, so the book is
405
+ * bounded by time and by the strands this node runs rather than by its history.
405
406
  */
406
- this.crossPartyStrandAddrs = new Map();
407
+ this.strandPeerBookStore = null;
407
408
  /**
408
409
  * PENDING strand membership invitations learned at formation, keyed by strandId — the
409
410
  * single-use `Strand.Invite` credential a closed-strand formation result carried back
@@ -411,10 +412,9 @@ export class CadreNode {
411
412
  * to redeem (`consumeInvite` seats the `Strand.Member` row under this party's own key
412
413
  * — the `strand-node-binds-member-peer` half of the party-identity chain).
413
414
  *
414
- * IN-MEMORY, deliberately, like {@link crossPartyStrandAddrs}: a restarted joiner
415
- * re-forms from scratch anyway (durability is
416
- * `backlog/feat-cross-party-strand-addr-durability`'s problem), and re-forming issues
417
- * a fresh invitation. A re-formation against the same strand REPLACES the entry — the
415
+ * IN-MEMORY, deliberately: an invitation is single-use and short-lived, a restarted
416
+ * joiner that never redeemed it re-forms from scratch, and re-forming issues a fresh
417
+ * one. A re-formation against the same strand REPLACES the entry — the
418
418
  * fresh invitation supersedes one that may have expired. The party key the invitation
419
419
  * admits, by contrast, IS persisted (`StrandPartyKey`, seated by
420
420
  * {@link adoptFormationMembershipInvite} before the entry lands here), so a lost
@@ -430,8 +430,9 @@ export class CadreNode {
430
430
  * just waits; once launched, staging notifies the manager, which re-arms a finished loop.
431
431
  *
432
432
  * NOTE: entries live for the node's lifetime (one small pair per formed closed
433
- * strand), the same unbounded-keys tripwire {@link crossPartyStrandAddrs} documents —
434
- * if a node ever forms strands at scale, evict alongside that map.
433
+ * strand); the number of keys is bounded only by the strands this node has ever
434
+ * formed, not by time — if a node ever forms strands at scale, evict on
435
+ * `unpublishStrand` / `forgetJoinedStrand` as the strand peer book does.
435
436
  */
436
437
  this.pendingMembershipInvites = new Map();
437
438
  /**
@@ -502,6 +503,23 @@ export class CadreNode {
502
503
  * so it survives a stop()→start() cycle of the same node instance.
503
504
  */
504
505
  this.enrolledMachineStore = null;
506
+ /**
507
+ * Node-local record of the strands this node joined from ANOTHER party (see
508
+ * `joined-strand-store.ts`) — the only thing that names them after a restart, since
509
+ * this party's control database holds no row for them. Written by {@link formStrand}
510
+ * and by an {@link addStrand} of a foreign row; forgotten by {@link forgetJoinedStrand}
511
+ * and on self-revocation. Adopted from `config.joinedStrands.store`, else built over
512
+ * `config.keyStore`, else in-memory, by {@link initializeJoinedStrandStore}. Like
513
+ * {@link bootstrapPeerStore} it is deliberately NOT cleared by {@link cleanup}.
514
+ */
515
+ this.joinedStrandStore = null;
516
+ /**
517
+ * The strand watcher's view of {@link joinedStrandStore}: its records unioned with the
518
+ * control rows each poll. Per SESSION, unlike the store — rebuilt at every
519
+ * {@link start}, which is what ends a revoked join's "keep offering until this session
520
+ * ends" (see `JoinedStrandRows.forgetAfterThisSession`).
521
+ */
522
+ this.joinedStrandRows = null;
505
523
  /** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
506
524
  this.selfRegistrationTimer = null;
507
525
  /** TTL heartbeat that re-publishes the self record before it goes stale. */
@@ -788,6 +806,12 @@ export class CadreNode {
788
806
  // closed before any network bring-up, and the retained dial targets are
789
807
  // loaded before the first reconcile pass could consult them.
790
808
  this.initializeBootstrapPeerStore();
809
+ // The remembered cross-party joins, which the strand watcher built below polls
810
+ // beside the control rows. Before network bring-up, so a mis-scoped injected store
811
+ // fails closed like the two above.
812
+ this.initializeJoinedStrandStore();
813
+ // The strand peer book those joins are dialed from. Same placement, same reason.
814
+ this.initializeStrandPeerBookStore();
791
815
  // Read the party's enrolled-machine count out of its node-local record and
792
816
  // capture it for buildControlNodeOptions below. This MUST precede
793
817
  // createControlNode: Optimystic freezes the cluster policy when the node is
@@ -1135,6 +1159,48 @@ export class CadreNode {
1135
1159
  this.declaredEnrolledMachines = store.count();
1136
1160
  log('control repair yardstick will be declared from %o enrolled machine(s)', this.declaredEnrolledMachines);
1137
1161
  }
1162
+ /**
1163
+ * Construct (or adopt) the node-local joined-strand store (see
1164
+ * {@link joinedStrandStore}) and this session's watcher view over it.
1165
+ *
1166
+ * The store instance is kept across stop()→start(); the view is rebuilt. A store
1167
+ * scoped to a different party is a configuration error, fail closed: its joins
1168
+ * would be offered to this party's app as if this party had made them.
1169
+ */
1170
+ initializeJoinedStrandStore() {
1171
+ const partyId = this.config.controlNetwork.partyId;
1172
+ const { keyStore } = this.config;
1173
+ const store = this.joinedStrandStore
1174
+ ?? this.config.joinedStrands?.store
1175
+ ?? (keyStore ? new KeyStoreJoinedStrandStore(keyStore, partyId) : new MemoryJoinedStrandStore(partyId));
1176
+ if (store.partyId !== partyId) {
1177
+ throw new Error(`CadreNodeConfig: joinedStrands.store is scoped to party ${store.partyId}, ` +
1178
+ `but this node serves party ${partyId} — refusing to offer another party's joined strands`);
1179
+ }
1180
+ this.joinedStrandStore = store;
1181
+ this.joinedStrandRows = new JoinedStrandRows(store);
1182
+ }
1183
+ /**
1184
+ * Construct (or adopt) the node-local strand peer book (see
1185
+ * {@link strandPeerBookStore}). Synchronous for the reason
1186
+ * {@link initializeBootstrapPeerStore} is: an injected store has already loaded its
1187
+ * persisted entries by the time it is handed in, and the in-memory fallback has
1188
+ * nothing to load. The instance is kept across stop()→start(). A store scoped to a
1189
+ * different party is a configuration error, fail closed: another party's strand
1190
+ * peers must never enter this node's dial loop.
1191
+ */
1192
+ initializeStrandPeerBookStore() {
1193
+ const partyId = this.config.controlNetwork.partyId;
1194
+ if (this.strandPeerBookStore) {
1195
+ return;
1196
+ }
1197
+ const store = this.config.strandPeers?.store ?? new MemoryStrandPeerBookStore(partyId);
1198
+ if (store.partyId !== partyId) {
1199
+ throw new Error(`CadreNodeConfig: strandPeers.store is scoped to party ${store.partyId}, ` +
1200
+ `but this node serves party ${partyId} — refusing to mix strand peer books`);
1201
+ }
1202
+ this.strandPeerBookStore = store;
1203
+ }
1138
1204
  /**
1139
1205
  * The node-local enrolled-machine store (null before {@link start}) — what this
1140
1206
  * node last knew about its party's size. Exposed for diagnostics and for a host
@@ -1152,6 +1218,15 @@ export class CadreNode {
1152
1218
  getBootstrapPeerStore() {
1153
1219
  return this.bootstrapPeerStore;
1154
1220
  }
1221
+ /**
1222
+ * The node-local strand peer book (null before {@link start}) — per strand, the
1223
+ * strand peers this node dials first on launch, resume and refresh. Exposed for
1224
+ * diagnostics and for a host that wants to show "whom would this strand re-find
1225
+ * after a restart?".
1226
+ */
1227
+ getStrandPeerBookStore() {
1228
+ return this.strandPeerBookStore;
1229
+ }
1155
1230
  /**
1156
1231
  * The node-local trusted-owner anchor (null before {@link start}). This is
1157
1232
  * the set the authorized-membership predicate and seed-trust anchor consult —
@@ -1225,10 +1300,10 @@ export class CadreNode {
1225
1300
  * needs no work of its own: `createLibp2pNode` registers the block-transfer
1226
1301
  * handler on every node it builds, control node included, and this node's
1227
1302
  * per-stream gate (`authorizeInboundControlStream`) covers the inbound
1228
- * direction. A denied peer is retried on its next `connection:open`, and —
1229
- * because the production join order is connect-then-authorize — on every
1230
- * committed membership change via {@link refreshAuthorizedControlPeers}'s
1231
- * `scheduleConnectedPeers()` call.
1303
+ * direction. A denied peer is retried on its next `peer:identify` (a
1304
+ * reconnect re-runs identify), and — because the production join order is
1305
+ * connect-then-authorize — on every committed membership change via
1306
+ * {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
1232
1307
  *
1233
1308
  * No-ops (logged) when the embedder configured no control storage or the
1234
1309
  * node exposes no key network — the backfill would have nothing to read or
@@ -1425,6 +1500,10 @@ export class CadreNode {
1425
1500
  // whose event loop is saturated by pure-JS Noise crypto. A configured value
1426
1501
  // replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
1427
1502
  connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
1503
+ // libp2p's own dial and inbound-upgrade limits, from the same declared link as every
1504
+ // cadre dial budget. Left to db-p2p, both stay at 10 s, which cannot open a relayed
1505
+ // connection above a 2.5 s round trip — and the listener's side of that fails silently.
1506
+ connectionManager: connectionManagerTimeouts(network?.linkRoundTripMs),
1428
1507
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1429
1508
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1430
1509
  // then. Spread NEXT to `transports` because the two answer the same question.
@@ -1819,7 +1898,7 @@ export class CadreNode {
1819
1898
  // ever holds many such connections, or refreshes get frequent, skip
1820
1899
  // re-arming peers whose last pass was denied under the SAME membership
1821
1900
  // snapshot instead of re-arming unconditionally.
1822
- this.controlBackfill?.scheduleConnectedPeers();
1901
+ void this.controlBackfill?.scheduleConnectedPeers();
1823
1902
  }
1824
1903
  catch (error) {
1825
1904
  log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
@@ -1855,6 +1934,11 @@ export class CadreNode {
1855
1934
  }
1856
1935
  return this.bootstrapPeerIds;
1857
1936
  }
1937
+ /**
1938
+ * What the strand watcher polls: this party's control rows plus a row per remembered
1939
+ * cross-party join (see {@link joinedStrandRows}), so a joined strand is re-offered
1940
+ * and relaunched on the same path as the party's own.
1941
+ */
1858
1942
  createStrandQueryable() {
1859
1943
  return {
1860
1944
  queryStrands: async () => {
@@ -1863,7 +1947,8 @@ export class CadreNode {
1863
1947
  return [];
1864
1948
  }
1865
1949
  log('Querying strands from control database');
1866
- return await this.controlDatabase.queryStrands();
1950
+ const control = await this.controlDatabase.queryStrands();
1951
+ return this.joinedStrandRows ? await this.joinedStrandRows.withControlRows(control) : control;
1867
1952
  }
1868
1953
  };
1869
1954
  }
@@ -4145,6 +4230,10 @@ export class CadreNode {
4145
4230
  * published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
4146
4231
  * anyone else's row joins, without the caller needing to know which it is.
4147
4232
  *
4233
+ * A joined row (no `founder: true`) this party's control database does not hold — a
4234
+ * strand joined from another party — is remembered in the node's joined-strand store, so it is re-offered as
4235
+ * `strand:discovered` after a restart (see {@link forgetJoinedStrand}).
4236
+ *
4148
4237
  * A rejected call leaves nothing running but DOES leave the sApp config
4149
4238
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
4150
4239
  * automatic relaunch need it. A failed launch here hands the strand back to that
@@ -4175,6 +4264,12 @@ export class CadreNode {
4175
4264
  `(Type 'c'), but this row is Type '${strandRow.Type}' — an open strand seats no ` +
4176
4265
  'Member/Manager, so the key would be ignored.');
4177
4266
  }
4267
+ // A row this node offered itself came from the control table or is already a
4268
+ // remembered join, and a row this node founds is not a join, so only a joined row
4269
+ // from elsewhere can be a new join to remember. A founded row recorded here would
4270
+ // come back after a restart with `FounderOwnerKey: null` and relaunch as a joiner.
4271
+ // Captured before the backlog delete below.
4272
+ const rememberable = founder !== true && !this.discoveredStrands.has(strandRow.Id);
4178
4273
  // Store sApp config for this strand. The strand is claimed now, so it leaves the
4179
4274
  // unclaimed backlog — a later `getDiscoveredStrands()` drain must not re-offer it.
4180
4275
  this.sAppConfigs.set(strandRow.Id, sAppConfig);
@@ -4183,6 +4278,11 @@ export class CadreNode {
4183
4278
  // the strand again, or a party-wide removal would never stop it here.
4184
4279
  this.strandWatcher?.unsuppressStrand(strandRow.Id);
4185
4280
  log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ?? 'derived');
4281
+ // Before the launch, so a foreign strand whose launch fails, or whose app is killed
4282
+ // mid-launch, is still offered again: by the watcher's retry and by the next start.
4283
+ if (rememberable) {
4284
+ await this.rememberForeignStrand(strandRow);
4285
+ }
4186
4286
  // An unset `founder` is DERIVED from the row inside launchStrand (this node founds
4187
4287
  // iff the row's FounderOwnerKey is its own owner key); an explicit flag wins — the
4188
4288
  // formation/responder flows pass one deliberately, since their consent-seated rows
@@ -4203,7 +4303,8 @@ export class CadreNode {
4203
4303
  // only delays the watcher's own first attempt by one poll interval, which is what
4204
4304
  // should happen right after an attempt that just failed.
4205
4305
  //
4206
- // NOTE: the retry relaunches from the CONTROL row plus the registered config, not
4306
+ // NOTE: the retry relaunches from the CONTROL row (or, for a strand joined from
4307
+ // another party, the remembered join) plus the registered config, not
4207
4308
  // from the row and arguments passed here — so a caller that enriched either (a
4208
4309
  // synthetic `MemberPrivateKey`, an explicit `founder` or `partyMemberPrivateKey`)
4209
4310
  // is retried with less than it asked for. Inert today: founder-ness re-derives from
@@ -4539,6 +4640,9 @@ export class CadreNode {
4539
4640
  // A refused pre-split launch left no tracked instance, so the stop below never
4540
4641
  // reaches detachStrand for it — clear the refusal here so a re-founded id starts clean.
4541
4642
  this.strandLaunchRefusals.delete(trimmed);
4643
+ // A party-wide removal: the strand's peers must not stay dial targets for a strand
4644
+ // that no longer exists (a re-published id is a fresh strand with fresh peers).
4645
+ this.forgetStrandPeers(trimmed, 'unpublishStrand');
4542
4646
  // Clear THIS machine's own MemberPeer binding while the strand runtime — and the
4543
4647
  // retained party key — is still live: the delete above already destroyed the
4544
4648
  // party's StrandPartyKey row, so once this process forgets the key nothing can
@@ -4892,18 +4996,25 @@ export class CadreNode {
4892
4996
  // retained launch config carries this derived key, so hibernate → wake
4893
4997
  // (resumeStrand) reuses the same peerId.
4894
4998
  //
4999
+ // Without an identity key (a node configured with neither `keyStore` nor
5000
+ // `privateKey` — tests, and an embedder that opted out of a stable identity) the
5001
+ // strand node still runs under a key CADRE-CORE HOLDS: a fresh random Ed25519 key,
5002
+ // exactly what libp2p would generate internally if handed none, except that the
5003
+ // book swap can sign this node's own entry with it (`strand-peer-book-swap.ts`) and
5004
+ // the retained launch config keeps the same strand peer id across a hibernation
5005
+ // resume. Stability across RESTARTS still needs an identity key.
5006
+ //
4895
5007
  // NOTE: derivation requires an Ed25519 identity key, so a node configured
4896
5008
  // with some other key type now fails strand launch outright (surfaced as
4897
5009
  // `strand:error` / a rejected addStrand) where it previously started the
4898
5010
  // strand on that key. Ed25519 is already required for every control-DB
4899
5011
  // signing path, so nothing reachable today hits this; if a non-Ed25519
4900
- // identity is ever supported, fall back to `undefined` here — libp2p then
4901
- // generates a random per-strand key, which still avoids the collision but
4902
- // gives up peerId stability across restarts.
5012
+ // identity is ever supported, take the random-key branch below for it — it
5013
+ // still avoids the collision, and gives up only peerId stability across restarts.
4903
5014
  const identityKey = this.identityKey;
4904
5015
  const transportKey = identityKey
4905
5016
  ? await timed('strandTransportKey', () => strandTransportKey(identityKey, strand.Id))
4906
- : undefined;
5017
+ : await generateKeyPair('Ed25519');
4907
5018
  // Derived BEFORE seed resolution so the seed pass doubles as the delegate
4908
5019
  // announcement and every grant is recorded before `startStrand` runs
4909
5020
  // `libp2p.start()` (the responder records the grant before replying; the
@@ -4931,7 +5042,19 @@ export class CadreNode {
4931
5042
  backfill: this.config.strandBackfill,
4932
5043
  revocationEnforcement: this.config.strandRevocationEnforcement,
4933
5044
  membershipReconciliation: this.config.strandMembershipReconciliation,
4934
- onSelfRevoked: (revokedStrandId) => this.emit('strand:revoked', { strandId: revokedStrandId }),
5045
+ onSelfRevoked: (revokedStrandId) => {
5046
+ this.forgetRevokedJoin(revokedStrandId);
5047
+ this.emit('strand:revoked', { strandId: revokedStrandId });
5048
+ },
5049
+ // The strand peer book's observation writer: every strand peer the node
5050
+ // identifies is remembered with its dialable addresses, so a restart re-finds
5051
+ // it. Retained with the launch config, so a hibernation wake re-arms it.
5052
+ onStrandPeerIdentified: (observedStrandId, observation) => this.observeStrandPeer(observedStrandId, observation),
5053
+ // The book's third writer, the signed swap between strand peers: the strand node
5054
+ // reads and writes the store directly (`strand-peer-book-swap.ts`). Retained with
5055
+ // the launch config too. `?? undefined`: the field is `null` before start(), and a
5056
+ // strand cannot launch before start(), so this is belt and braces.
5057
+ strandPeerBook: this.strandPeerBookStore ?? undefined,
4935
5058
  onRejoinBlocked: (blockedStrandId) => this.emit('strand:rejoin-blocked', { strandId: blockedStrandId }),
4936
5059
  // The joiner's first-sync write gate (strand-first-sync-gate.ts): a launch that
4937
5060
  // comes up `'syncing'` announces the moment its database is published.
@@ -4994,11 +5117,31 @@ export class CadreNode {
4994
5117
  */
4995
5118
  async resolveCohortSeed(strandId, delegatePeerId) {
4996
5119
  const siblings = await this.resolveSiblingSeed(strandId, delegatePeerId);
4997
- // Sibling answers FIRST: they were resolved just now, while a contact-map entry is as
4998
- // old as the formation that produced it. `unionAddrs` appends only what the siblings
5120
+ // Sibling answers FIRST: they were resolved just now, while a book entry is as old
5121
+ // as the last connection to that peer. `unionAddrs` appends only what the siblings
4999
5122
  // did not already name, so a fresher answer is never displaced and each source keeps
5000
- // its own signaling-first ordering.
5001
- return unionAddrs(siblings, this.crossPartyStrandAddrs.get(strandId) ?? []);
5123
+ // its own signaling-first ordering. `delegatePeerId` IS this strand node's own id.
5124
+ return unionAddrs(siblings, this.strandPeerBookAddrs(strandId, delegatePeerId));
5125
+ }
5126
+ /**
5127
+ * The strand peer book's contribution to a strand's seed: every live entry's
5128
+ * addresses, freshest peer first, minus this node's own entry when `selfPeerId` is
5129
+ * known (the book swap files one under the strand transport id; a node must never
5130
+ * dial itself). Empty before {@link start} builds the store.
5131
+ */
5132
+ strandPeerBookAddrs(strandId, selfPeerId) {
5133
+ const store = this.strandPeerBookStore;
5134
+ if (!store) {
5135
+ return [];
5136
+ }
5137
+ const addrs = [];
5138
+ for (const entry of store.entries(strandId)) {
5139
+ if (entry.peerId === selfPeerId) {
5140
+ continue;
5141
+ }
5142
+ addrs.push(...entry.addrs);
5143
+ }
5144
+ return addrs;
5002
5145
  }
5003
5146
  /**
5004
5147
  * The own-party half of {@link resolveCohortSeed}: strand-network addresses resolved
@@ -5176,7 +5319,7 @@ export class CadreNode {
5176
5319
  * NOTE: against a relay that is DOWN this hook costs up to two strand-addr
5177
5320
  * timeouts (10 s each: dial by peer id, then by addr) before the reservation drive even
5178
5321
  * starts, so one failed re-drive holds the supervisor `driving` for those 20 s plus the
5179
- * drive's own deadline — 28 s at the default declared link round trip, and longer on a host
5322
+ * drive's own deadline — 34 s at the default declared link round trip, and longer on a host
5180
5323
  * that declared a slower one (`link-budget.ts`).
5181
5324
  * Bounded and harmless while the relay is unreachable anyway; if recovery
5182
5325
  * latency after a relay comes back ever matters, skip the announce when the
@@ -5261,8 +5404,8 @@ export class CadreNode {
5261
5404
  // `connectedSiblingTargets`' membership read is unbounded, and with zero
5262
5405
  // connections its answer is empty whatever the table holds, so decide it from the
5263
5406
  // connection list instead of paying for the read once a tick. Note this only skips
5264
- // the RPC: a strand carrying cross-party contact addresses still refreshes below,
5265
- // because those never came from a sibling in the first place.
5407
+ // the RPC: a strand with peer-book entries still refreshes below, because those
5408
+ // never came from a sibling in the first place.
5266
5409
  //
5267
5410
  // NOTE: one strand-addr RPC per (due strand × connected sibling) per refresh
5268
5411
  // interval — each a tiny request/response on an already-open control
@@ -5282,17 +5425,17 @@ export class CadreNode {
5282
5425
  }
5283
5426
  /**
5284
5427
  * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, union their
5285
- * answers with this strand's cross-party contact addresses, and merge the lot into the
5286
- * strand's address book. Errors are logged and swallowed so one strand's failure never
5287
- * costs the others their refresh.
5428
+ * answers with this strand's peer-book addresses, and merge the lot into the strand's
5429
+ * address book. Errors are logged and swallowed so one strand's failure never costs
5430
+ * the others their refresh.
5288
5431
  *
5289
5432
  * `targets` may be EMPTY — a solo node, or one whose only control peers are strangers.
5290
- * The cross-party addresses still have to be re-merged in that case: they were learned
5291
- * at formation and can never be re-resolved, so this periodic re-merge is the ONLY
5292
- * thing standing between them and the peerStore's one-hour expiry. A strand with
5293
- * neither a sibling to ask nor a contact entry does nothing and leaves its throttle
5294
- * unstamped, so the next reconcile tick retries rather than sitting out the whole
5295
- * refresh interval having done nothing.
5433
+ * The peer-book addresses still have to be re-merged in that case: nothing re-resolves
5434
+ * another party's addresses, so this periodic re-merge is the ONLY thing standing
5435
+ * between them and the peerStore's one-hour expiry. A strand with neither a sibling to
5436
+ * ask nor a book entry does nothing and leaves its throttle unstamped, so the next
5437
+ * reconcile tick retries rather than sitting out the whole refresh interval having
5438
+ * done nothing.
5296
5439
  */
5297
5440
  async refreshOneStrandPeerAddrs(strandId, targets, now) {
5298
5441
  const controlNode = this.controlNode;
@@ -5300,11 +5443,13 @@ export class CadreNode {
5300
5443
  if (!controlNode || !strandNode) {
5301
5444
  return;
5302
5445
  }
5303
- const contactAddrs = this.crossPartyStrandAddrs.get(strandId) ?? [];
5304
- if (targets.length === 0 && contactAddrs.length === 0) {
5305
- return;
5306
- }
5307
5446
  try {
5447
+ // Inside the try: a strand node torn down mid-pass may throw on any read, and one
5448
+ // strand's failure must never cost the others their refresh.
5449
+ const contactAddrs = this.strandPeerBookAddrs(strandId, strandNode.peerId.toString());
5450
+ if (targets.length === 0 && contactAddrs.length === 0) {
5451
+ return;
5452
+ }
5308
5453
  // The running strand node's own peerId is the delegate to announce — see
5309
5454
  // the relationship note on refreshStrandPeerAddrs.
5310
5455
  const siblingAddrs = targets.length === 0
@@ -5346,14 +5491,14 @@ export class CadreNode {
5346
5491
  * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
5347
5492
  * `backlog/strand-network-nat-relay-reachability`.
5348
5493
  *
5349
- * NOTE: one input does NOT age out — the cross-party addresses a formation
5350
- * carried back ({@link crossPartyStrandAddrs}). Nothing can re-resolve them, so
5351
- * {@link refreshStrandPeerAddrs} re-merges the same stored list every pass, which
5352
- * also means a junk entry from a responder survives for the node's lifetime rather
5353
- * than an hour. Bounded to {@link MAX_STRAND_ADDRS} per strand, from a party this
5354
- * node deliberately formed a strand with, and still authority-free — so the
5355
- * exposure is a handful of failed dials, not a trust hole. Evict on
5356
- * `unpublishStrand` if that ever stops being an acceptable price.
5494
+ * NOTE: one input ages out on a slower clock — the strand peer book's entries
5495
+ * ({@link strandPeerBookStore}). Nothing can re-resolve another party's addresses,
5496
+ * so {@link refreshStrandPeerAddrs} re-merges the book every pass, which means a
5497
+ * junk entry from a responder or a peer's identify survives until the book drops it
5498
+ * (14 days unrefreshed, or the peer's next connection replacing the entry) rather
5499
+ * than an hour. Bounded to 16 peers × 16 addrs per strand, forgotten on
5500
+ * `unpublishStrand` / `forgetJoinedStrand`, and still authority-free — so the
5501
+ * exposure is a handful of failed dials, not a trust hole.
5357
5502
  */
5358
5503
  async mergeStrandPeerAddrs(strandNode, addrs, strandId) {
5359
5504
  const counts = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
@@ -5419,6 +5564,128 @@ export class CadreNode {
5419
5564
  }
5420
5565
  return orderSignalingFirst(addrs);
5421
5566
  }
5567
+ /**
5568
+ * Leave a strand joined from another party: forget its remembered join, so no later
5569
+ * start re-offers it, then {@link stopStrand} it here. The "leave" a joiner has, since it
5570
+ * cannot {@link unpublishStrand} a row its party's control database never held. For one
5571
+ * of this party's own strands the forget is a no-op and this is just {@link stopStrand}.
5572
+ *
5573
+ * Touches nothing beyond this node — the other members are not told, and this party's
5574
+ * membership row in the strand stays.
5575
+ */
5576
+ async forgetJoinedStrand(strandId) {
5577
+ if (!this._running) {
5578
+ throw new Error('CadreNode not running');
5579
+ }
5580
+ await this.joinedStrandRows.forget(strandId);
5581
+ this.forgetStrandPeers(strandId, 'forgetJoinedStrand');
5582
+ await this.stopStrand(strandId);
5583
+ }
5584
+ /**
5585
+ * Record `row` as a join from another party when this party's control database holds no
5586
+ * row for it — the one case nothing else would re-offer after a restart. Skips the write
5587
+ * when an identical record is already there (an app re-claiming a remembered join on
5588
+ * every start), and keeps the first `joinedAt`.
5589
+ *
5590
+ * Best-effort, for {@link addStrand}: a failure costs the strand its re-offer after the
5591
+ * next restart, not this attach, so it is reported and the attach goes on.
5592
+ * {@link formStrand}, whose join nothing else would name, fails loudly instead.
5593
+ *
5594
+ * NOTE: "no control row" also describes a row the party has just unpublished. A claim
5595
+ * of such a row the app kept from an earlier offer, made after the watcher already
5596
+ * withdrew it, is remembered as a join and survives the removal on this machine until
5597
+ * {@link forgetJoinedStrand}. Contrived today (a claim of a row the node itself is still
5598
+ * offering skips this method); if it shows up, check the strand's `Revocation`
5599
+ * tombstone here before recording.
5600
+ */
5601
+ async rememberForeignStrand(row) {
5602
+ const store = this.joinedStrandStore;
5603
+ if (!store || !this.controlDatabase) {
5604
+ return;
5605
+ }
5606
+ try {
5607
+ if (await this.controlDatabase.queryStrand(row.Id)) {
5608
+ return;
5609
+ }
5610
+ const existing = (await store.list()).find((record) => record.Id === row.Id);
5611
+ if (existing?.Type === row.Type && existing.MemberPrivateKey === row.MemberPrivateKey) {
5612
+ return;
5613
+ }
5614
+ await store.record({
5615
+ Id: row.Id,
5616
+ Type: row.Type,
5617
+ MemberPrivateKey: row.MemberPrivateKey,
5618
+ joinedAt: existing?.joinedAt ?? Date.now()
5619
+ });
5620
+ }
5621
+ catch (error) {
5622
+ console.warn(`addStrand(${row.Id}): could not remember this strand as joined from another party, ` +
5623
+ 'so it will not be re-offered after a restart:', error);
5624
+ }
5625
+ }
5626
+ /**
5627
+ * Self-revocation arm of the joined-strand record: a party removed from a strand must
5628
+ * not re-attach it on every launch. The strand keeps running this session — the
5629
+ * `strand:revoked` contract is that nothing is torn down for the app — and is gone
5630
+ * after the next start. The join record is a no-op for this party's own strands, which
5631
+ * have none; the strand peer book is forgotten either way, since a removed party must
5632
+ * not keep dialing the strand's peers.
5633
+ *
5634
+ * NOTE: a manager can re-admit a removed party directly (`addMemberByManager`), and the
5635
+ * membership loop then finishes the join on its own — but the record is gone by then, so
5636
+ * the next start does not re-attach the strand. Re-forming records it again. If direct
5637
+ * re-admission becomes a routine flow, re-record the join when the revoked-peer gate
5638
+ * clears for this node.
5639
+ */
5640
+ forgetRevokedJoin(strandId) {
5641
+ void this.joinedStrandRows?.forgetAfterThisSession(strandId).catch((error) => {
5642
+ log('forgetting revoked joined strand %s failed; it will be re-offered on the next start: %o', strandId, error);
5643
+ });
5644
+ // The strand's peers go with the join: a removed party must not keep dialing them.
5645
+ this.forgetStrandPeers(strandId, 'self-revocation');
5646
+ }
5647
+ /**
5648
+ * Forget a strand's entries in the peer book (see {@link strandPeerBookStore}):
5649
+ * fire-and-log, like every book write — the removal is visible synchronously by the
5650
+ * store's contract, and a failed persist costs restart survival only.
5651
+ */
5652
+ forgetStrandPeers(strandId, caller) {
5653
+ void this.strandPeerBookStore?.forget(strandId).catch((error) => {
5654
+ log('%s: forgetting strand %s peers failed (continuing): %o', caller, strandId, error);
5655
+ });
5656
+ }
5657
+ /**
5658
+ * The strand peer book's observation writer (`StartStrandConfig.onStrandPeerIdentified`):
5659
+ * a strand node identified a strand peer at `observation.addrs`, so remember it as an
5660
+ * unsigned entry seen just now. The observer never reports self, and the store binds
5661
+ * every address to the peer id before filing it.
5662
+ */
5663
+ observeStrandPeer(strandId, observation) {
5664
+ this.rememberStrandPeer(strandId, {
5665
+ peerId: observation.peerId,
5666
+ addrs: observation.addrs,
5667
+ issuedAt: 0,
5668
+ lastSeenAt: Date.now()
5669
+ }, 'observeStrandPeer');
5670
+ }
5671
+ /**
5672
+ * Merge one entry into the peer book. Fire-and-log: the entry is visible
5673
+ * synchronously by the store's contract, and the promise tracks durability only. A
5674
+ * persist failure costs restart survival, never this session's seed — the same trade
5675
+ * {@link retainDialTarget} makes.
5676
+ */
5677
+ rememberStrandPeer(strandId, entry, caller) {
5678
+ const store = this.strandPeerBookStore;
5679
+ if (!store) {
5680
+ // Unreachable in production: start() builds the store before any strand can
5681
+ // launch or form. Logged rather than thrown, for the same reason as retainDialTarget.
5682
+ log('%s: no strand peer book yet — not remembering %s for strand %s', caller, entry.peerId, strandId);
5683
+ return;
5684
+ }
5685
+ void store.merge(strandId, entry).catch((error) => {
5686
+ log('%s: persisting strand %s peer %s failed (continuing): %o', caller, strandId, entry.peerId, error);
5687
+ });
5688
+ }
5422
5689
  /**
5423
5690
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
5424
5691
  * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
@@ -5427,7 +5694,9 @@ export class CadreNode {
5427
5694
  * id in the watcher (`StrandWatcher.suppressStrand`) and drops it from
5428
5695
  * {@link getDiscoveredStrands}, so neither a later poll nor a drain can undo a
5429
5696
  * deliberate stop. Only an explicit {@link addStrand} does, which is the caller
5430
- * reversing its own decision. Party-wide removal is {@link unpublishStrand}.
5697
+ * reversing its own decision. Party-wide removal is {@link unpublishStrand}. A strand
5698
+ * joined from another party comes back the same way, from its remembered join;
5699
+ * {@link forgetJoinedStrand} is how to leave one for good.
5431
5700
  */
5432
5701
  async stopStrand(strandId) {
5433
5702
  if (!this._running) {
@@ -5677,7 +5946,7 @@ export class CadreNode {
5677
5946
  *
5678
5947
  * BUDGET: the supervisor's first attempt is what `start()` waits on — deliberately the drive's
5679
5948
  * ordinary deadline rather than a boot-specific one. That deadline is now COUNTED, four link
5680
- * round trips at the declared `network.linkRoundTripMs` (8 s at its default, where it was a
5949
+ * round trips at the declared `network.linkRoundTripMs` (14 s at its default, where it was a
5681
5950
  * fixed 10 s): a healthy dial-plus-reserve is sub-second even over a WAN, so this is slack for
5682
5951
  * a slow link, while going much longer would make a dead relay indistinguishable from a hung
5683
5952
  * start and much shorter would fail nodes on links that were merely slow. A host that declares
@@ -6576,7 +6845,7 @@ export class CadreNode {
6576
6845
  this.initializeStrandSolicitation();
6577
6846
  }
6578
6847
  const result = await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
6579
- this.recordCrossPartyStrandAddrs(result.strandId, result.strandAddrs);
6848
+ this.recordFormationStrandPeers(result.strandId, result.strandAddrs);
6580
6849
  // A closed host strand's approval carries the joiner's own membership invitation —
6581
6850
  // persist this party's identity key and stage the invitation for bring-up to redeem.
6582
6851
  // Runs AFTER the addr recording so a persistence failure (which throws — see the
@@ -6584,8 +6853,34 @@ export class CadreNode {
6584
6853
  if (result.membershipInvite) {
6585
6854
  await this.adoptFormationMembershipInvite(result.strandId, result.membershipInvite);
6586
6855
  }
6856
+ await this.rememberFormedStrand(result);
6587
6857
  return result;
6588
6858
  }
6859
+ /**
6860
+ * Remember the strand a formation just joined (see {@link joinedStrandStore}), so it
6861
+ * is re-offered after a restart even when the app is killed before its `addStrand`.
6862
+ * Last in {@link formStrand}, after the membership adoption, so a failure here leaves
6863
+ * the party key and staged invitation in place for the re-formation it asks for.
6864
+ *
6865
+ * Throws on failure, like {@link adoptFormationMembershipInvite}: the formation's
6866
+ * token is spent, and a join no store names would vanish at the next restart.
6867
+ */
6868
+ async rememberFormedStrand(result) {
6869
+ try {
6870
+ await this.joinedStrandStore.record({
6871
+ Id: result.strandId,
6872
+ Type: result.memberPrivateKey ? 'c' : 'o',
6873
+ MemberPrivateKey: result.memberPrivateKey ?? null,
6874
+ joinedAt: Date.now()
6875
+ });
6876
+ }
6877
+ catch (error) {
6878
+ throw new Error(`Formation for strand ${result.strandId} was approved (its one-time token is spent), but ` +
6879
+ 'remembering the join in this node\'s joined-strand store failed, so the strand would not ' +
6880
+ 'come back after a restart. Fix the store (usually the configured keyStore), then redeem a ' +
6881
+ 'fresh invitation.', { cause: error });
6882
+ }
6883
+ }
6589
6884
  /**
6590
6885
  * Adopt an approved closed-strand formation's membership half on the JOINER:
6591
6886
  *
@@ -6727,35 +7022,41 @@ export class CadreNode {
6727
7022
  }
6728
7023
  /**
6729
7024
  * Remember the responder's strand-network addresses for a strand this node just
6730
- * formed, so the strand's discovery seed has something to work with when the app
6731
- * launches it (see {@link crossPartyStrandAddrs}).
6732
- *
6733
- * Scoped strictly to `strandId`: these addresses reach ONE strand node of ONE other
6734
- * party and must never seed another strand's mesh or the control peerStore. An empty
6735
- * list is recorded as no entry at all rather than an empty one, so a later formation
6736
- * against the same strand that DOES disclose addresses is not shadowed.
6737
- */
6738
- recordCrossPartyStrandAddrs(strandId, strandAddrs) {
7025
+ * formed in the strand peer book (see {@link strandPeerBookStore}), so the strand's
7026
+ * discovery seed has something to work with when the app launches it — and again
7027
+ * after every restart.
7028
+ *
7029
+ * The carried list is peer-agnostic (`sanitizeStrandAddrs` bounds and parses it,
7030
+ * nothing more), so it is attributed per peer here (`groupAddrsByPeerId`, the same
7031
+ * rule the address-book merge applies) and each peer becomes one unsigned entry seen
7032
+ * just now: the responder disclosed the addresses live a moment ago. An entry naming
7033
+ * no destination peer is dropped there. Scoped strictly to `strandId`: these
7034
+ * addresses reach ONE strand node of ONE other party and must never seed another
7035
+ * strand's mesh or the control peerStore. An empty list records nothing, so a later
7036
+ * formation against the same strand that DOES disclose addresses is not shadowed.
7037
+ */
7038
+ recordFormationStrandPeers(strandId, strandAddrs) {
6739
7039
  if (strandAddrs.length === 0) {
6740
7040
  log('formStrand: responder disclosed no strand addrs for %s — cross-party seed stays empty', strandId);
6741
7041
  return;
6742
7042
  }
6743
- // Capped at the same `MAX_STRAND_ADDRS` one arriving list is, because this
6744
- // ACCUMULATES: re-forming against the same strand (the documented recovery for a
6745
- // responder whose relay reservation rotated) unions a fresh list onto the old one,
6746
- // and nothing here can tell a dead entry from a live one. Without the cap, the seed
6747
- // — and the peerStore rows every refresh pass re-merges from it — would grow by up
6748
- // to 16 entries per redemption for the node's lifetime. Oldest entries fall off the
6749
- // end, which is the right end to lose: `unionAddrs` keeps the freshest list first.
6750
- const merged = unionAddrs(strandAddrs, this.crossPartyStrandAddrs.get(strandId) ?? [])
6751
- .slice(0, MAX_STRAND_ADDRS);
6752
- this.crossPartyStrandAddrs.set(strandId, merged);
7043
+ const now = Date.now();
7044
+ let peers = 0;
7045
+ for (const [peerId, addrs] of groupAddrsByPeerId([...strandAddrs])) {
7046
+ this.rememberStrandPeer(strandId, {
7047
+ peerId,
7048
+ addrs: addrs.map((ma) => ma.toString()),
7049
+ issuedAt: 0,
7050
+ lastSeenAt: now
7051
+ }, 'formStrand');
7052
+ peers++;
7053
+ }
6753
7054
  // A strand already running when this lands (a re-formation) would otherwise wait out
6754
7055
  // the rest of its ~10-minute refresh throttle before the new addresses reached its
6755
7056
  // address book. Clearing the stamp makes the next 15 s reconcile tick merge them,
6756
7057
  // which matters precisely because re-forming is the recovery path for a dead entry.
6757
7058
  this.strandPeerAddrRefreshAt.delete(strandId);
6758
- log('formStrand: recorded %d cross-party strand addr(s) for %s', merged.length, strandId);
7059
+ log('formStrand: recorded %d cross-party strand addr(s) for %s under %d peer(s)', strandAddrs.length, strandId, peers);
6759
7060
  }
6760
7061
  /**
6761
7062
  * Encode an open invitation for out-of-band delivery (QR, link, etc.).