@serfab/cadre-core 0.12.0 → 0.13.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 (86) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-node.d.ts +328 -25
  3. package/dist/cadre-node.js +760 -59
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-database.d.ts +19 -0
  6. package/dist/control-database.js +51 -9
  7. package/dist/control-database.js.map +1 -1
  8. package/dist/control-schema.d.ts +1 -1
  9. package/dist/control-schema.js +30 -2
  10. package/dist/control-schema.js.map +1 -1
  11. package/dist/enrolled-machine-store-file.d.ts +13 -0
  12. package/dist/enrolled-machine-store-file.js +41 -0
  13. package/dist/enrolled-machine-store-file.js.map +1 -0
  14. package/dist/enrolled-machine-store.d.ts +81 -0
  15. package/dist/enrolled-machine-store.js +225 -0
  16. package/dist/enrolled-machine-store.js.map +1 -0
  17. package/dist/file-durable-slot.js +5 -3
  18. package/dist/file-durable-slot.js.map +1 -1
  19. package/dist/index.d.ts +5 -3
  20. package/dist/index.js +23 -5
  21. package/dist/index.js.map +1 -1
  22. package/dist/membership-connection-gater.d.ts +15 -1
  23. package/dist/membership-connection-gater.js +9 -3
  24. package/dist/membership-connection-gater.js.map +1 -1
  25. package/dist/node-local-snapshot.d.ts +6 -0
  26. package/dist/node-local-snapshot.js +7 -1
  27. package/dist/node-local-snapshot.js.map +1 -1
  28. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  29. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  30. package/dist/peer-join-backfill.js.map +1 -0
  31. package/dist/relay-addrs.d.ts +73 -0
  32. package/dist/relay-addrs.js +195 -0
  33. package/dist/relay-addrs.js.map +1 -1
  34. package/dist/strand-database.d.ts +12 -0
  35. package/dist/strand-database.js +16 -0
  36. package/dist/strand-database.js.map +1 -1
  37. package/dist/strand-formation-manager.d.ts +12 -0
  38. package/dist/strand-formation-manager.js +10 -3
  39. package/dist/strand-formation-manager.js.map +1 -1
  40. package/dist/strand-formation-protocol.d.ts +76 -2
  41. package/dist/strand-formation-protocol.js +78 -2
  42. package/dist/strand-formation-protocol.js.map +1 -1
  43. package/dist/strand-instance-manager.d.ts +147 -14
  44. package/dist/strand-instance-manager.js +225 -45
  45. package/dist/strand-instance-manager.js.map +1 -1
  46. package/dist/strand-membership-writer.d.ts +12 -0
  47. package/dist/strand-membership-writer.js +23 -1
  48. package/dist/strand-membership-writer.js.map +1 -1
  49. package/dist/strand-network-config.d.ts +84 -0
  50. package/dist/strand-network-config.js +136 -0
  51. package/dist/strand-network-config.js.map +1 -0
  52. package/dist/strand-revocation-enforcer.d.ts +392 -0
  53. package/dist/strand-revocation-enforcer.js +533 -0
  54. package/dist/strand-revocation-enforcer.js.map +1 -0
  55. package/dist/strand-solicitation.d.ts +7 -0
  56. package/dist/strand-solicitation.js +8 -2
  57. package/dist/strand-solicitation.js.map +1 -1
  58. package/dist/strand-watcher.js +6 -1
  59. package/dist/strand-watcher.js.map +1 -1
  60. package/dist/types.d.ts +210 -16
  61. package/dist/types.js +7 -1
  62. package/dist/types.js.map +1 -1
  63. package/package.json +16 -8
  64. package/src/cadre-node.ts +6353 -5603
  65. package/src/control-database.ts +53 -9
  66. package/src/control-schema.ts +30 -2
  67. package/src/enrolled-machine-store-file.ts +43 -0
  68. package/src/enrolled-machine-store.ts +280 -0
  69. package/src/file-durable-slot.ts +5 -3
  70. package/src/index.ts +52 -10
  71. package/src/membership-connection-gater.ts +9 -3
  72. package/src/node-local-snapshot.ts +13 -1
  73. package/src/peer-addr-book.ts +251 -250
  74. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  75. package/src/relay-addrs.ts +218 -0
  76. package/src/strand-database.ts +17 -0
  77. package/src/strand-formation-manager.ts +22 -3
  78. package/src/strand-formation-protocol.ts +808 -692
  79. package/src/strand-instance-manager.ts +317 -54
  80. package/src/strand-membership-writer.ts +23 -1
  81. package/src/strand-network-config.ts +172 -0
  82. package/src/strand-revocation-enforcer.ts +653 -0
  83. package/src/strand-solicitation.ts +484 -471
  84. package/src/strand-watcher.ts +319 -314
  85. package/src/types.ts +1554 -1354
  86. package/dist/strand-backfill.js.map +0 -1
@@ -4,7 +4,7 @@ import { peerIdFromString, peerIdFromPrivateKey } from '@libp2p/peer-id';
4
4
  import { createLibp2pNode } from '@optimystic/db-p2p';
5
5
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
6
6
  import { multiaddr } from '@multiformats/multiaddr';
7
- import { CONTROL_CLUSTER_POLICY, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS } from './types.js';
7
+ import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS } from './types.js';
8
8
  import { sign } from '@optimystic/quereus-plugin-crypto';
9
9
  import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64 } from './ed25519-key.js';
10
10
  import { strandTransportKey } from './strand-transport-key.js';
@@ -12,6 +12,7 @@ import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
12
12
  import { loadOrCreateIdentityKey } from './identity-key.js';
13
13
  import { MemoryTrustedOwnerStore } from './trusted-owner-store.js';
14
14
  import { MemoryBootstrapPeerStore } from './bootstrap-peer-store.js';
15
+ import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
15
16
  import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
16
17
  import { verifyCadrePeerVoucher } from './peer-authorization.js';
17
18
  import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
@@ -19,19 +20,21 @@ import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSign
19
20
  import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
20
21
  import { StrandWatcher } from './strand-watcher.js';
21
22
  import { StrandInstanceManager } from './strand-instance-manager.js';
23
+ import { PeerJoinBackfill } from './peer-join-backfill.js';
22
24
  import { deriveCohortMembers } from './strand-cohort.js';
23
25
  import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
24
26
  import { withDeadline } from './control-stream.js';
25
27
  import { EnrollmentService } from './enrollment.js';
26
28
  import { HibernationManager } from './hibernation-manager.js';
27
- import { ControlDatabase } from './control-database.js';
29
+ import { ControlDatabase, isStrandIdConflict } from './control-database.js';
28
30
  import { SeedBootstrapService } from './seed-bootstrap.js';
29
31
  import { StrandSolicitationService } from './strand-solicitation.js';
32
+ import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
30
33
  import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS, UnauthorizedReservationBudget } from './membership-connection-gater.js';
31
34
  import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
32
35
  import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
33
36
  import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, pruneStoppedStrandAnnounces, peerStrandKey } from './delegate-admission.js';
34
- import { relayCircuitAddrs, resolveListenAddrs, RelayReservationFailedError } from './relay-addrs.js';
37
+ import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
35
38
  import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
36
39
  import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
37
40
  import { PushFanoutService } from './push-fanout.js';
@@ -64,6 +67,30 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
64
67
  function relayStrandAddrPeer(relay) {
65
68
  return { peerId: relay.relayPeerId, addrs: [multiaddr(relay.relayAddr)] };
66
69
  }
70
+ /**
71
+ * `primary` followed by the entries of `extra` it does not already contain, de-duplicated
72
+ * and order-preserving.
73
+ *
74
+ * Used wherever a strand's discovery seed unions two sources of address strings — the
75
+ * freshly-RPC'd sibling answers and the cross-party addresses learned at formation. The
76
+ * caller passes the FRESHER source as `primary`, so a stale entry can only ever be
77
+ * appended, never promoted ahead of a current one, and each source keeps whatever
78
+ * signaling-first ordering it arrived with.
79
+ */
80
+ function unionAddrs(primary, extra) {
81
+ if (extra.length === 0) {
82
+ return [...primary];
83
+ }
84
+ const out = [...primary];
85
+ const seen = new Set(primary);
86
+ for (const addr of extra) {
87
+ if (seen.has(addr))
88
+ continue;
89
+ seen.add(addr);
90
+ out.push(addr);
91
+ }
92
+ return out;
93
+ }
67
94
  /**
68
95
  * Build the signing callback the `ControlDatabase` writers expect: they hand it the
69
96
  * canonical row-bound message BYTES (see `buildAuthorizationMessage`), and it ed25519-signs
@@ -84,6 +111,56 @@ function requireNonBlank(value, label) {
84
111
  }
85
112
  return trimmed;
86
113
  }
114
+ /**
115
+ * Is the live `Strand` row the one a publish of `desired` would have produced?
116
+ *
117
+ * "Identical content" means `(Type, MemberPrivateKey)` and deliberately NOT
118
+ * `FounderOwnerKey`: that column is provenance (WHICH machine published the row), not
119
+ * content. Two machines of one party racing to found the same id must keep resolving as
120
+ * "the winner's row stands" — the loser adopts the row and, since the winner's key is on
121
+ * it, correctly comes up as a joiner (see `launchStrand`'s founder derivation). Comparing
122
+ * it here would turn that benign race into a hard throw. `Id` is the lookup key and
123
+ * `StampId` is a single-use nonce, so the two compared columns are all the content there
124
+ * is. A live row matching on both is, by construction, the state a repeat publish would
125
+ * have reached, whichever branch of `Strand.AuthorizedInsert` seated it (owner-signed, or
126
+ * the unsigned consent branch an invite redemption uses).
127
+ *
128
+ * Returns the mismatching column names, empty when the row matches. The KEY's value is
129
+ * deliberately never returned or logged — it is the closed strand's read-gating secret.
130
+ */
131
+ function strandRowMismatches(live, desired) {
132
+ const mismatches = [];
133
+ if (live.Type !== desired.Type) {
134
+ mismatches.push(`Type is '${live.Type}', not the requested '${desired.Type}'`);
135
+ }
136
+ if ((live.MemberPrivateKey ?? null) !== (desired.MemberPrivateKey ?? null)) {
137
+ mismatches.push('MemberPrivateKey differs from the one supplied');
138
+ }
139
+ return mismatches;
140
+ }
141
+ /**
142
+ * The live row when it matches `desired` ({@link strandRowMismatches}); otherwise throw
143
+ * naming the columns that differ.
144
+ *
145
+ * A mismatch must stay a hard error: accepting one would let a retry reopen a strand the
146
+ * party closed, or swap the key that gates its reads — so the two cases the raw
147
+ * `UNIQUE constraint failed: Strand.Id` could not distinguish ("already done, carry on"
148
+ * vs. "genuine conflict, stop") are separated here rather than left to the caller.
149
+ *
150
+ * @param situation - How the row came to be there, as a verb phrase completing
151
+ * `Strand <id> …`, e.g. `'is already published'`.
152
+ */
153
+ function requireMatchingStrandRow(live, desired, situation) {
154
+ const mismatches = strandRowMismatches(live, desired);
155
+ if (mismatches.length > 0) {
156
+ throw new Error(`Strand ${desired.Id} ${situation} with DIFFERENT content, so this publish is a ` +
157
+ `conflict, not a repeat: ${mismatches.join('; ')}. Reconcile deliberately — attach ` +
158
+ 'the existing strand with addStrand/foundStrand to keep it, or unpublishStrand first ' +
159
+ 'to re-seat it (destructive for a closed strand: its MemberPrivateKey is stored ' +
160
+ 'nowhere else).');
161
+ }
162
+ return live;
163
+ }
87
164
  /**
88
165
  * CadreNode is the main entry point for a cadre member.
89
166
  * It manages:
@@ -106,6 +183,14 @@ export class CadreNode {
106
183
  this.trustedOwnerStore = null;
107
184
  this.controlNode = null;
108
185
  this.controlDatabase = null;
186
+ /**
187
+ * The CONTROL network's peer-join block catch-up — same module as the
188
+ * per-strand ones, but membership-gated (see {@link startControlBackfill}).
189
+ * Created in {@link start} after the control node and database are up, stopped
190
+ * and dropped in {@link cleanup} before either is torn down, so a stop()→
191
+ * start() cycle rebuilds it with a fresh caught-up-peer memo.
192
+ */
193
+ this.controlBackfill = null;
109
194
  this.strandWatcher = null;
110
195
  this.seedBootstrapService = null;
111
196
  this.strandSolicitationService = null;
@@ -272,6 +357,36 @@ export class CadreNode {
272
357
  * stale stamp.
273
358
  */
274
359
  this.strandPeerAddrRefreshAt = new Map();
360
+ /**
361
+ * CROSS-PARTY strand-network addresses learned at formation, keyed by strandId — the
362
+ * responder's live strand addrs, carried back on the formation result
363
+ * (`FormationResultMessage.strandAddrs`) and recorded by {@link formStrand}.
364
+ *
365
+ * This is the ONLY cross-party discovery input there is. The strand-addr RPC that
366
+ * resolves a strand's addresses is membership-gated and answers own-party siblings
367
+ * only, so without this map a joiner's cohort seed for a two-party strand is empty and
368
+ * the mesh never forms.
369
+ *
370
+ * Read by {@link resolveCohortSeed} (launch + hibernation resume) and re-merged by
371
+ * every {@link refreshStrandPeerAddrs} pass — the re-merge is what keeps the entries
372
+ * alive past the peerStore's one-hour address expiry, since nothing can re-resolve
373
+ * them.
374
+ *
375
+ * IN-MEMORY and ONE-SHOT, deliberately (see `docs/strands.md`): the addresses die with
376
+ * the process, and a restarted initiator with no sibling running the strand is back to
377
+ * an empty seed until the durability work lands
378
+ * (`backlog/feat-cross-party-strand-addr-durability`). They are also never refreshed,
379
+ * so a responder whose relay reservation rotates before the initiator dials leaves a
380
+ * dead entry behind; recovery today is a fresh invitation.
381
+ *
382
+ * NOTE: entries are kept for the node's lifetime, including across a
383
+ * {@link stopStrand} — a stopped strand may be rediscovered by the watcher and must
384
+ * still get its seed. Each strand's list is capped at `MAX_STRAND_ADDRS`, but the
385
+ * number of KEYS is bounded only by the strands this node has ever formed as an
386
+ * initiator (not by time); if a node ever forms strands at scale, evict on
387
+ * `unpublishStrand` or cap the map.
388
+ */
389
+ this.crossPartyStrandAddrs = new Map();
275
390
  /**
276
391
  * Cold-start bootstrap dial targets: the owner-flagged peers of every seed
277
392
  * this node has applied, keyed by peer id, valued by the seed's multiaddr
@@ -306,6 +421,16 @@ export class CadreNode {
306
421
  * the party if it is ever stranded again.
307
422
  */
308
423
  this.bootstrapPeerStore = null;
424
+ /**
425
+ * Node-local record of the party's enrolled-machine count (see
426
+ * `enrolled-machine-store.ts`), kept so the CONTROL node can declare a
427
+ * block-repair yardstick at bring-up. Constructed (or adopted from
428
+ * `config.enrolledMachines.store`) by {@link initializeEnrolledMachineStore};
429
+ * written by {@link refreshAuthorizedControlPeers}. Like
430
+ * {@link bootstrapPeerStore} it is deliberately NOT cleared by {@link cleanup},
431
+ * so it survives a stop()→start() cycle of the same node instance.
432
+ */
433
+ this.enrolledMachineStore = null;
309
434
  /** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
310
435
  this.selfRegistrationTimer = null;
311
436
  /** TTL heartbeat that re-publishes the self record before it goes stale. */
@@ -547,6 +672,13 @@ export class CadreNode {
547
672
  // closed before any network bring-up, and the retained dial targets are
548
673
  // loaded before the first reconcile pass could consult them.
549
674
  this.initializeBootstrapPeerStore();
675
+ // Read the party's enrolled-machine count out of its node-local record and
676
+ // capture it for buildControlNodeOptions below. This MUST precede
677
+ // createControlNode: Optimystic freezes the cluster policy when the node is
678
+ // built, and the ControlDatabase that could answer the question live does not
679
+ // exist until after that. Remembering the number across the restart is the
680
+ // only way to declare it at all — see `enrolled-machine-store.ts`.
681
+ this.initializeEnrolledMachineStore();
550
682
  // Arm the connection gate's BRING-UP QUIET PERIOD before the libp2p node
551
683
  // exists, so no connection can form ahead of it: `@libp2p/bootstrap` emits
552
684
  // its discovery events ~1 s after `libp2p.start()` and the connection
@@ -660,6 +792,12 @@ export class CadreNode {
660
792
  // delayed refresh path) so no early `connection:open` is missed — the
661
793
  // write-while-alone re-replication drain must fire on the first 0→≥1 edge.
662
794
  this.wireControlConnectionListeners();
795
+ // Arm the control network's peer-join block catch-up now that the control
796
+ // DB (which the push-time membership gate reads) is up. Armed even when
797
+ // this node is alone — like the strand one, it only does work when the
798
+ // control libp2p node reports a peer connection, and arming it at start is
799
+ // what closes the "genesised alone, headers never replicate" hole.
800
+ this.startControlBackfill();
663
801
  // Seed the per-stream gate's materialized authorized set now that the
664
802
  // control DB is up (non-blocking; the helper never rejects). Until it
665
803
  // lands the snapshot is empty, which the stream gate treats as cold
@@ -846,6 +984,46 @@ export class CadreNode {
846
984
  }
847
985
  this.bootstrapPeerStore = store;
848
986
  }
987
+ /**
988
+ * Construct (or adopt) the node-local enrolled-machine store and capture the
989
+ * count this run will declare into {@link declaredEnrolledMachines}.
990
+ *
991
+ * Synchronous for the same reason {@link initializeBootstrapPeerStore} is: an
992
+ * injected store has already loaded its persisted count by the time it is handed
993
+ * in (its `open` is the async part), and the in-memory default has nothing to
994
+ * load. Must run BEFORE {@link createControlNode} — that is the whole point of
995
+ * the record; see `enrolled-machine-store.ts`.
996
+ *
997
+ * The store instance is kept across stop()→start() (so a count recorded during
998
+ * the previous run is not lost with the ephemeral default), but the DECLARED
999
+ * count is re-read every time — which is how a restart applies a number the
1000
+ * previous run learned.
1001
+ *
1002
+ * A store scoped to a different party is a configuration error, fail closed. It
1003
+ * is only a repair hint, but a hint sized by a foreign party's membership is a
1004
+ * number nobody chose, and the mismatch always means a miswired embedder.
1005
+ */
1006
+ initializeEnrolledMachineStore() {
1007
+ const partyId = this.config.controlNetwork.partyId;
1008
+ const store = this.enrolledMachineStore
1009
+ ?? this.config.enrolledMachines?.store
1010
+ ?? new MemoryEnrolledMachineStore(partyId);
1011
+ if (store.partyId !== partyId) {
1012
+ throw new Error(`CadreNodeConfig: enrolledMachines.store is scoped to party ${store.partyId}, ` +
1013
+ `but this node serves party ${partyId} — refusing to size a repair yardstick from a foreign party`);
1014
+ }
1015
+ this.enrolledMachineStore = store;
1016
+ this.declaredEnrolledMachines = store.count();
1017
+ log('control repair yardstick will be declared from %o enrolled machine(s)', this.declaredEnrolledMachines);
1018
+ }
1019
+ /**
1020
+ * The node-local enrolled-machine store (null before {@link start}) — what this
1021
+ * node last knew about its party's size. Exposed for diagnostics and for a host
1022
+ * that wants to show which repair yardstick the next launch will declare.
1023
+ */
1024
+ getEnrolledMachineStore() {
1025
+ return this.enrolledMachineStore;
1026
+ }
849
1027
  /**
850
1028
  * The node-local cold-start bootstrap-peer store (null before {@link start}) —
851
1029
  * the retained owner addresses {@link reconcileControlCohort}'s cold-start
@@ -892,6 +1070,90 @@ export class CadreNode {
892
1070
  async createControlNode() {
893
1071
  return await createLibp2pNode(this.buildControlNodeOptions());
894
1072
  }
1073
+ /**
1074
+ * The control libp2p node's Optimystic network name — ONE binding for every
1075
+ * derivation from it (`db-p2p` namespaces all of the node's protocol ids as
1076
+ * `/optimystic/<networkName>/...`), so the node options and the block-transfer
1077
+ * protocol prefix the control backfill dials can never drift apart.
1078
+ */
1079
+ controlNetworkName() {
1080
+ return `control-${this.config.controlNetwork.partyId}`;
1081
+ }
1082
+ /**
1083
+ * Arm the CONTROL network's peer-join block catch-up: push every block in the
1084
+ * control database's own raw store to each newly connected AUTHORIZED member
1085
+ * this runtime has not yet caught up. This is what physically replicates
1086
+ * control blocks committed while the writer was alone — the named
1087
+ * collection-header blocks written once at genesis above all, whose revision
1088
+ * never moves again, so no later commit ever carries them to a member that
1089
+ * joined after them. Without it, such a member that restarts offline reads
1090
+ * the affected control tables as EMPTY, silently (`isMember()` answers false
1091
+ * for peers it knew about before the restart).
1092
+ *
1093
+ * Unlike the per-strand instances (see `strand-instance-manager.ts`), pushes
1094
+ * are gated on {@link isAuthorizedMember}, judged at push time: the control
1095
+ * network's inbound connection gate deliberately admits non-members in
1096
+ * several states (seed delivery to an un-enrolled node, an open enrollment
1097
+ * window, an outstanding invitation, configured bootstrap/relay peers), and
1098
+ * pushing the whole control store to such a peer would hand a stranger the
1099
+ * party's entire membership, addresses and strand list. The receiving side
1100
+ * needs no work of its own: `createLibp2pNode` registers the block-transfer
1101
+ * handler on every node it builds, control node included, and this node's
1102
+ * per-stream gate (`authorizeInboundControlStream`) covers the inbound
1103
+ * direction. A denied peer is retried on its next `connection:open`, and —
1104
+ * because the production join order is connect-then-authorize — on every
1105
+ * committed membership change via {@link refreshAuthorizedControlPeers}'s
1106
+ * `scheduleConnectedPeers()` call.
1107
+ *
1108
+ * No-ops (logged) when the embedder configured no control storage or the
1109
+ * node exposes no key network — the backfill would have nothing to read or
1110
+ * no way to dial.
1111
+ */
1112
+ startControlBackfill() {
1113
+ if (this.config.controlBackfill?.enabled === false || !this.controlNode) {
1114
+ return;
1115
+ }
1116
+ // Through the resolver, not the {@link controlStorage} memo field: the resolver is
1117
+ // idempotent (it returns the same wrapped store `buildControlNodeOptions` already
1118
+ // resolved), so the catch-up cannot be silently disarmed by a future reordering that
1119
+ // moves this call ahead of the one that populated the field.
1120
+ const storage = this.resolveControlStorage();
1121
+ const keyNetwork = this.controlNode.keyNetwork;
1122
+ if (!storage || !keyNetwork) {
1123
+ log('Control peer-join block catch-up is inert: %s', !storage ? 'no control storage configured' : 'control node exposes no keyNetwork');
1124
+ return;
1125
+ }
1126
+ const networkName = this.controlNetworkName();
1127
+ this.controlBackfill = new PeerJoinBackfill({
1128
+ label: networkName,
1129
+ libp2p: this.controlNode,
1130
+ peerNetwork: keyNetwork,
1131
+ storage,
1132
+ // The same prefix the receiver registered its block-transfer handler
1133
+ // under — derived from the same networkName binding the node options
1134
+ // used, never re-spelled here.
1135
+ protocolPrefix: `/optimystic/${networkName}`,
1136
+ authorizePeer: (peerId) => this.isAuthorizedMember(peerId)
1137
+ }, {
1138
+ // A shorter settle window than the strand default (1000 ms): the control
1139
+ // store is small (a party's membership — dozens of blocks), a re-push is
1140
+ // idempotent, and the window a joiner is connected before going offline
1141
+ // can be short (converge one row, then stop) — a slow debounce is the
1142
+ // difference between that joiner holding the collection headers and
1143
+ // reading its own membership as empty after an offline restart.
1144
+ //
1145
+ // NOTE: 250 is reasoned, not swept — the delete-while-alone scenario's
1146
+ // phases 1-3 finish in about a second, so the 1000 ms strand default left
1147
+ // the push racing the joiner's stop, and 250 gives margin. Nobody measured
1148
+ // the shape of the curve either side of it. If the control gates start
1149
+ // flaking on a joiner that stopped before being caught up, lower this
1150
+ // before looking anywhere else; if catch-up pushes start showing up as
1151
+ // connection-churn noise, raise it.
1152
+ debounceMs: 250,
1153
+ ...this.config.controlBackfill
1154
+ });
1155
+ this.controlBackfill.start();
1156
+ }
895
1157
  /**
896
1158
  * The control database's cache-wrapped raw storage, resolved from
897
1159
  * `config.storage.provider` on first use and then held in {@link controlStorage}
@@ -974,6 +1236,11 @@ export class CadreNode {
974
1236
  // the END of `start()`, so the control database is built while this node holds
975
1237
  // zero control connections. See `relay-addrs.ts` and `start()`.
976
1238
  const listenAddrs = resolveListenAddrs(network, 'search');
1239
+ // The transports those listen entries imply. A `/ws` entry switches db-p2p's
1240
+ // WebSocket transport on (without it the entry binds nothing and libp2p reports
1241
+ // nothing); anything outside {tcp, ws/wss, p2p-circuit} throws here rather than
1242
+ // being silently dropped at bring-up. See `relay-addrs.ts`.
1243
+ const transportOptions = resolveTransportOptions(network, listenAddrs);
977
1244
  const enableRelay = this.relayServerEnabled();
978
1245
  // The control node's own storage, resolved once per runtime (see
979
1246
  // {@link resolveControlStorage}) rather than per call.
@@ -981,7 +1248,7 @@ export class CadreNode {
981
1248
  const nodeOptions = {
982
1249
  port: 0,
983
1250
  bootstrapNodes: controlNetwork.bootstrapNodes,
984
- networkName: `control-${controlNetwork.partyId}`,
1251
+ networkName: this.controlNetworkName(),
985
1252
  storage: controlStorageProvider,
986
1253
  fretProfile: profile === 'storage' ? 'core' : 'edge',
987
1254
  relay: enableRelay,
@@ -992,10 +1259,22 @@ export class CadreNode {
992
1259
  // CONTROL_CLUSTER_POLICY: only the admission gate defaults to 2, while the read-repair
993
1260
  // corroboration floor would otherwise fall back to this clusterSize of 16.
994
1261
  clusterSize: CONTROL_REPLICATION_BREADTH,
995
- clusterPolicy: CONTROL_CLUSTER_POLICY,
1262
+ // The base control policy with the block-repair corroboration yardstick declared
1263
+ // from the machines this party had enrolled at this node's last look
1264
+ // ({@link declaredEnrolledMachines}, read in start() before this runs). Handed
1265
+ // `undefined` — a brand-new node, an unreadable record, or an embedder that
1266
+ // injected no store — this returns the frozen CONTROL_CLUSTER_POLICY object
1267
+ // itself, so the unknown case is provably byte-for-byte the old behaviour.
1268
+ // The yardstick moves ALONE: `assumedClusterSize` stays pinned at 2, because a
1269
+ // party of phones cannot promise three quarters of its machines are awake.
1270
+ clusterPolicy: controlClusterPolicy(this.declaredEnrolledMachines),
996
1271
  arachnode: { enableRingZulu: profile === 'storage' },
997
1272
  ...(identityKey && { privateKey: identityKey }),
998
1273
  ...(network?.transports && { transports: network.transports }),
1274
+ // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1275
+ // `{}` when `network.transports` is set, since the embedder owns transport policy
1276
+ // then. Spread NEXT to `transports` because the two answer the same question.
1277
+ ...transportOptions,
999
1278
  // Configured `listenAddrs`, plus the bare `/p2p-circuit` search listener when
1000
1279
  // `network.relayAddrs` is set — that listener registers the pending reservation
1001
1280
  // `driveControlRelayReservation` fills after bring-up.
@@ -1325,6 +1604,68 @@ export class CadreNode {
1325
1604
  const members = await this.listAuthorizedMembers(false);
1326
1605
  this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
1327
1606
  log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
1607
+ // Remember the party's size for the NEXT launch's control-node repair yardstick
1608
+ // (see `enrolled-machine-store.ts`): this is the only place the count is known,
1609
+ // and the control node that needs it was built long before this ran. Recorded
1610
+ // from the snapshot just materialized — no second membership query.
1611
+ //
1612
+ // CONTROL-NETWORK ONLY, and that scope is load-bearing. Every enrolled machine
1613
+ // runs the control node by construction, so here the party's machine count IS
1614
+ // the count of machines serving the network — the quantity
1615
+ // `resolveRepairYardstick` asks for. It is NOT that quantity for a strand, which
1616
+ // launches only on machines whose embedder registered its sApp config (see
1617
+ // {@link addStrand}), so this number must never be routed to a strand node:
1618
+ // over-declaring pins Optimystic's repair corroboration floor at two peers and a
1619
+ // strand that can field only one can then never repair. `launchStrand` therefore
1620
+ // declares nothing; see the NOTE there.
1621
+ //
1622
+ // `+ 1` for this node, which is not in its own authorized set. An EMPTY snapshot
1623
+ // is recorded as 1 rather than skipped. That is deliberate, and the reason
1624
+ // is a party that genuinely SHRANK: if empty meant "record nothing", a party
1625
+ // whose other machines were all revoked would keep declaring its old, larger
1626
+ // number forever, and over-declaring is the unsafe direction — at a yardstick of
1627
+ // 3 or more Optimystic pins the repair corroboration floor at two peers, so a
1628
+ // cohort that can only field one peer could never repair at all. Recording 1
1629
+ // lets the number come back down; the floor then turns it into 2, which is
1630
+ // exactly what declaring nothing resolves to.
1631
+ //
1632
+ // NOTE: the cost of that choice is that a TRANSIENT empty read overwrites a good
1633
+ // remembered count with 1 — a membership snapshot is legitimately empty early in
1634
+ // a run on a node whose rows have not replicated yet or whose trusted-owner
1635
+ // anchor is still unseeded (`listAuthorizedMembers` authorizes no one without an
1636
+ // anchor). Bounded and safe: the next refresh once rows land re-records the real
1637
+ // count, and the only node that loses anything is one stopped inside that window,
1638
+ // which then declares 2 — today's behaviour — on its next launch. If a node ever
1639
+ // needs to declare the right number on its FIRST post-restart launch, the fix is
1640
+ // to distinguish "no rows yet" from "no members" at this site, not to skip empty.
1641
+ //
1642
+ // A removed peer stops counting at REVOCATION, not at reap: `queryCadrePeers`
1643
+ // drops rows whose `StampId` is retired, and `listAuthorizedMembers` reads
1644
+ // through it. That is why there is no reap hook writing this record.
1645
+ //
1646
+ // `void`: the store never rejects and logs its own persist failures, and this
1647
+ // method's contract is never-rejects. A failed write leaves the in-memory count
1648
+ // correct for this session and re-lands on the next refresh.
1649
+ void this.enrolledMachineStore?.record(this.authorizedControlPeers.size + 1);
1650
+ // Membership moved: give the control backfill another look at every
1651
+ // connected peer. The production join order is connect-then-authorize, so
1652
+ // a joiner's first catch-up pass was denied at the gate while its
1653
+ // connection stays up — without this re-arm it would wait for a
1654
+ // reconnect. Cheap and idempotent: caught-up peers are skipped before any
1655
+ // timer is set, a peer whose pass is in flight is deferred to the end of
1656
+ // that pass (the pass may already be past its gate check — this very
1657
+ // commit is what would authorize it), and the gate re-judges at push time.
1658
+ //
1659
+ // NOTE: every refresh re-arms every connected peer that is not yet caught
1660
+ // up, and each re-armed pass costs one `CadrePeer` query at the gate. A
1661
+ // connection that is admitted but never authorized — a configured
1662
+ // bootstrap or relay peer, the steady state for a NAT'd node — is
1663
+ // therefore re-judged on every membership write and every timed reconcile
1664
+ // tick, forever. Negligible at a cadre's handful of connections; if a node
1665
+ // ever holds many such connections, or refreshes get frequent, skip
1666
+ // re-arming peers whose last pass was denied under the SAME membership
1667
+ // snapshot instead of re-arming unconditionally.
1668
+ this.controlBackfill?.scheduleConnectedPeers();
1328
1669
  }
1329
1670
  catch (error) {
1330
1671
  log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
@@ -1544,6 +1885,29 @@ export class CadreNode {
1544
1885
  }
1545
1886
  return null;
1546
1887
  }
1888
+ /**
1889
+ * Does the `Strand` row name THIS machine as its founder — i.e. is its
1890
+ * `FounderOwnerKey` this node's own owner key (the key behind its PeerId)?
1891
+ * A null/absent column (a consent-seated strand, or a hand-built row) and a
1892
+ * node with no owner key both derive `false`: without a positive match this
1893
+ * machine must attach, never bootstrap. Pure key derivation, no I/O — cheap
1894
+ * enough for {@link launchStrand}'s tracked-instance early return.
1895
+ *
1896
+ * NOTE: reads "one owner key per machine" — the reference model, where the owner
1897
+ * key IS the key behind the PeerId. Two machines running the SAME identity key
1898
+ * would both derive `true` and each bootstrap on its own replica, which is the
1899
+ * double-`Header` hazard this derivation exists to avoid. Unreachable today: that
1900
+ * configuration also gives both machines one PeerId, which already breaks control
1901
+ * networking well before any strand launches. Revisit if machines ever share an
1902
+ * owner key while holding distinct transport identities — the derivation would
1903
+ * then need a per-machine discriminator (the `CadrePeer` PeerId) on the row.
1904
+ */
1905
+ isSelfFoundedRow(strand) {
1906
+ if (strand.FounderOwnerKey == null) {
1907
+ return false;
1908
+ }
1909
+ return strand.FounderOwnerKey === this.getSelfSigningKey()?.publicKeyB64;
1910
+ }
1547
1911
  /**
1548
1912
  * The owner keypair (base64url Ed25519) derived from this node's resolved
1549
1913
  * identity key. In the single-key reference model the owner signing key is
@@ -1788,21 +2152,26 @@ export class CadreNode {
1788
2152
  * One address bound to `peerId`, or `null` when it cannot be — logged either
1789
2153
  * way, never thrown, so every caller's list-shaping stays total.
1790
2154
  *
2155
+ * Used both to normalize addresses this node DIALS (a `CadrePeer` row, a
2156
+ * seed-supplied bootstrap addr) and to normalize the ones it ANNOUNCES
2157
+ * ({@link getStrandMultiaddrs}); the rule is the same either way — an address
2158
+ * that does not reach `peerId` is not an address for `peerId`.
2159
+ *
1791
2160
  * `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
1792
2161
  * that does not parse. Unreachable for a `CadrePeer` row (the binding gate
1793
- * above parsed it already), reachable for a seed-supplied one
1794
- * ({@link bootstrapDialAddrs}).
2162
+ * above parsed it already) and for our own node's id, reachable for a
2163
+ * seed-supplied one ({@link bootstrapDialAddrs}).
1795
2164
  */
1796
2165
  bindAddrToPeer(addr, peerId) {
1797
2166
  try {
1798
2167
  const bound = withTrailingPeerId(addr, peerId);
1799
2168
  if (!bound) {
1800
- log('normalizeDialAddrs: dropping addr %s — it names a peer other than %s', addr.toString(), peerId);
2169
+ log('bindAddrToPeer: dropping addr %s — it names a peer other than %s', addr.toString(), peerId);
1801
2170
  }
1802
2171
  return bound;
1803
2172
  }
1804
2173
  catch (error) {
1805
- log('normalizeDialAddrs: cannot bind %s to %s: %o', addr.toString(), peerId, error);
2174
+ log('bindAddrToPeer: cannot bind %s to %s: %o', addr.toString(), peerId, error);
1806
2175
  return null;
1807
2176
  }
1808
2177
  }
@@ -3052,6 +3421,14 @@ export class CadreNode {
3052
3421
  // announce timestamps that would suppress the next session's refresh.
3053
3422
  this.delegateAdmission.clear();
3054
3423
  this.delegateAnnounceAt.clear();
3424
+ // Control backfill first — before the database closes and the control node
3425
+ // stops — so no NEW catch-up push is issued against a torn-down transport.
3426
+ // A push already in flight is not awaited; it fails into the module's own
3427
+ // per-chunk catch. (Mirrors releaseRuntime in strand-instance-manager.ts.)
3428
+ if (this.controlBackfill) {
3429
+ this.controlBackfill.stop();
3430
+ this.controlBackfill = null;
3431
+ }
3055
3432
  // Close control database (this also shuts down the collection factory).
3056
3433
  // Detach the membership hub first: nothing this teardown does should drive a
3057
3434
  // gate refresh, and a notification arriving after this point is a no-op.
@@ -3152,8 +3529,8 @@ export class CadreNode {
3152
3529
  /**
3153
3530
  * Rebuild a quiesced strand's runtime, re-resolving the volatile cohort input
3154
3531
  * first: the discovery seed may have grown since the strand last ran. Shared by
3155
- * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so
3156
- * both apply the same fresh seed resolution. `resumeStrand` is idempotent
3532
+ * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so both
3533
+ * apply the same fresh resolution. `resumeStrand` is idempotent
3157
3534
  * (returns the live instance unchanged) as a backstop against double-resume.
3158
3535
  */
3159
3536
  async resumeStrandRuntime(strandId) {
@@ -3164,6 +3541,9 @@ export class CadreNode {
3164
3541
  ? peerIdFromPrivateKey(await strandTransportKey(this.identityKey, strandId)).toString()
3165
3542
  : undefined;
3166
3543
  const bootstrapNodes = await this.resolveCohortSeed(strandId, delegatePeerId);
3544
+ // NOTE: no `servingMachines` override — see `launchStrand` for why a strand node
3545
+ // declares no repair yardstick, and `StartStrandConfig.servingMachines` for the
3546
+ // count that would legitimately go here once one exists.
3167
3547
  const instance = await this.strandManager.resumeStrand(strandId, { bootstrapNodes });
3168
3548
  // Same reason as the launch path: `bootstrapNodes` only reaches the address
3169
3549
  // book through @libp2p/bootstrap discovery, so merge it directly as well.
@@ -3297,6 +3677,14 @@ export class CadreNode {
3297
3677
  * Add a strand with its sApp configuration.
3298
3678
  * The hosting application must provide the sApp schema when creating a strand.
3299
3679
  *
3680
+ * This is the ATTACH half only — it starts the local instance and never publishes the
3681
+ * `Strand` row. A joiner (the row arrived over the control network) wants exactly this; a
3682
+ * FOUNDER wants {@link foundStrand}, which publishes and attaches in one resumable call.
3683
+ * With no explicit `founder` flag, founder-ness is derived from the row's
3684
+ * `FounderOwnerKey` (see {@link StrandConfig.founder}) — so attaching a row THIS machine
3685
+ * published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
3686
+ * anyone else's row joins, without the caller needing to know which it is.
3687
+ *
3300
3688
  * A rejected call leaves nothing running but DOES leave the sApp config
3301
3689
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
3302
3690
  * automatic relaunch need it. So once the strand's row is visible on the control
@@ -3312,9 +3700,11 @@ export class CadreNode {
3312
3700
  const { strandRow, sAppConfig, founder } = config;
3313
3701
  // Store sApp config for this strand
3314
3702
  this.sAppConfigs.set(strandRow.Id, sAppConfig);
3315
- log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ?? false);
3316
- // `founder` only flows from the explicit addStrand path — the control-discovered
3317
- // join path never founds, so its rows arrive via sync (see handleStrandAdded).
3703
+ log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ?? 'derived');
3704
+ // An unset `founder` is DERIVED from the row inside launchStrand (this node founds
3705
+ // iff the row's FounderOwnerKey is its own owner key); an explicit flag wins — the
3706
+ // formation/responder flows pass one deliberately, since their consent-seated rows
3707
+ // carry a null column. See StrandConfig.founder.
3318
3708
  return await this.launchStrand(strandRow, sAppConfig, founder);
3319
3709
  }
3320
3710
  /**
@@ -3322,16 +3712,41 @@ export class CadreNode {
3322
3712
  * owner identity, so other cadre members discover it via control-network
3323
3713
  * sync (their {@link StrandWatcher} fires `strand:discovered`).
3324
3714
  *
3325
- * This is the owner-signed `Strand` INSERT that {@link addStrand}
3326
- * deliberately omits: `addStrand` only starts the LOCAL strand instance,
3327
- * whereas publishing makes the strand visible cadre-wide. A typical creator
3328
- * does both (start locally + publish); a discovering peer only does
3329
- * `addStrand` (the row already exists).
3715
+ * **Founding a strand is TWO steps** — this one (publish the row cadre-wide) and
3716
+ * {@link addStrand} (start the local instance, `founder: true` to write the strand's
3717
+ * `Header`/founding membership). `addStrand` deliberately omits the insert: it only
3718
+ * starts the LOCAL instance, whereas publishing makes the strand visible cadre-wide. A
3719
+ * discovering peer only does `addStrand` (the row already exists). Callers founding a
3720
+ * strand should use {@link foundStrand}, which performs both steps and is safe to
3721
+ * re-run — hand-rolling the pair is what left strands half-founded when an app was
3722
+ * killed between them.
3723
+ *
3724
+ * **Idempotent for identical content.** A live row whose `(Type, MemberPrivateKey)` match
3725
+ * the arguments is the state this call would have produced, so the call logs and returns
3726
+ * that row instead of writing — a publish interrupted after it committed can be repeated.
3727
+ * DIFFERENT content on the same id throws, naming the columns that differ, rather than
3728
+ * surfacing the raw `UNIQUE constraint failed: Strand.Id`: silently accepting it would
3729
+ * let a retry reopen a closed strand or swap the key gating its reads. The read and the
3730
+ * insert are not atomic, so a concurrent founder (two machines of one party, same id) is
3731
+ * caught on the insert's uniqueness rejection and resolved the same way — re-read, no-op
3732
+ * on a match, rethrow otherwise. Never an overwrite either way.
3733
+ *
3734
+ * A tombstoned strand is NOT resurrected here: {@link unpublishStrand} deletes the row, so
3735
+ * the idempotent branch is unreachable and the ordinary publish path re-seats it, exactly
3736
+ * as that method documents.
3737
+ *
3738
+ * **Already stuck on `UNIQUE constraint failed: Strand.Id`?** The row is already published
3739
+ * — attach, do NOT republish: `addStrand` (or {@link foundStrand}) with the id and, for a
3740
+ * closed strand, the `MemberPrivateKey` read back from the row. `unpublishStrand` + a
3741
+ * fresh publish also clears it for an OPEN strand, but is destructive for a closed one
3742
+ * (the key exists nowhere else). No app-data wipe is needed for either.
3330
3743
  *
3331
3744
  * The insert is signed with the ed25519 key behind this node's PeerId — which
3332
3745
  * {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
3333
- * so peer identity and owner key are one and the same. That key must be
3334
- * enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
3746
+ * so peer identity and owner key are one and the same. That key is also persisted on the
3747
+ * row as `FounderOwnerKey` (the schema pins the column to the verified signer), which is
3748
+ * what later lets any launch derive "this machine is the founder" from the row alone.
3749
+ * The key must be enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
3335
3750
  * at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
3336
3751
  * Failing loudly here is intentional: a silently-unpublished strand would run
3337
3752
  * as a local-only island that no peer could ever discover or join.
@@ -3340,16 +3755,148 @@ export class CadreNode {
3340
3755
  * {@link addStrand}).
3341
3756
  * @param type - `'o'` for open (default) or `'c'` for closed.
3342
3757
  * @param memberPrivateKey - Optional membership key for a closed strand.
3343
- * @throws if the node is not started, exposes no owner signing key, the id is blank, or
3344
- * the control DB rejects the (unauthorized) insert.
3758
+ * @returns The live `Strand` row — the one just inserted, or the matching one already
3759
+ * there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
3760
+ * on a repeat it is the stored key, not the argument.
3761
+ * @throws if the node is not started, exposes no owner signing key, the id is blank, a
3762
+ * row with the same id holds different content, or the control DB rejects the
3763
+ * (unauthorized) insert.
3345
3764
  */
3346
3765
  async publishStrand(strandId, type = 'o', memberPrivateKey) {
3347
3766
  const signingKey = this.requireOwnerSigningKey(`publish strand ${strandId}`);
3348
3767
  // Trim/reject here so the id that lands matches the one unpublishStrand looks up: it
3349
3768
  // trims too, and an untrimmed row would be unreachable by the same string.
3350
3769
  const trimmed = requireNonBlank(strandId, 'strand id');
3351
- await this.controlDatabase.insertStrand(trimmed, type, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64), memberPrivateKey);
3770
+ // FounderOwnerKey records THIS machine as the row's publisher (the schema pins it to
3771
+ // the signing owner), which is what later lets a relaunch derive founder-ness.
3772
+ const desired = {
3773
+ Id: trimmed,
3774
+ Type: type,
3775
+ MemberPrivateKey: memberPrivateKey ?? null,
3776
+ FounderOwnerKey: signingKey.publicKeyB64,
3777
+ };
3778
+ // Read first rather than leaning on the insert's collision for BOTH readings. The
3779
+ // ordinary resume (an app killed after its own publish committed) then needs no error
3780
+ // matching at all: the collision path can only recognise itself by the engine's message
3781
+ // TEXT (see isStrandIdConflict), and an upstream reword there must degrade the RARE race,
3782
+ // not the common resume. It also spends no stamp or signature on a known no-op.
3783
+ const existing = await this.controlDatabase.queryStrand(trimmed);
3784
+ if (existing) {
3785
+ // NOTE: reads local converged state, and `queryStrand` does not filter rows whose
3786
+ // StampId a Revocation has retired. So on a sibling that still physically holds a row
3787
+ // deleted elsewhere while ALONE (the delete-while-alone gap in docs/architecture.md),
3788
+ // an owner-signed republish now no-ops onto that doomed row instead of re-seating the
3789
+ // id under a fresh stamp. Harmless while that gap is itself unresolved — both
3790
+ // behaviours end at the same unconverged state — but if delete-while-alone ever gains
3791
+ // real replay, filter retired stamps here (queryStrandStampId + the Revocation read)
3792
+ // so a re-seat is not mistaken for a repeat.
3793
+ const matched = requireMatchingStrandRow(existing, desired, 'is already published');
3794
+ log('publishStrand(%s): already published (type %s) with identical content — no-op', trimmed, type);
3795
+ return matched;
3796
+ }
3797
+ try {
3798
+ await this.controlDatabase.insertStrand(trimmed, type, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64), memberPrivateKey);
3799
+ }
3800
+ catch (error) {
3801
+ // Only the uniqueness collision is a candidate for the idempotent reading; every
3802
+ // other rejection (unauthorized signer, retired stamp) must keep surfacing.
3803
+ if (!isStrandIdConflict(error)) {
3804
+ throw error;
3805
+ }
3806
+ const landed = await this.controlDatabase.queryStrand(trimmed);
3807
+ if (!landed) {
3808
+ // Collided, yet no row is readable: not the race this branch handles (a row
3809
+ // deleted between the rejection and the re-read, or a stale local view). Surface
3810
+ // the original rather than guessing.
3811
+ throw error;
3812
+ }
3813
+ log('publishStrand(%s): lost a concurrent founding race; re-read the landed row', trimmed);
3814
+ return requireMatchingStrandRow(landed, desired, 'landed concurrently from another founder');
3815
+ }
3352
3816
  log('Published strand %s (type %s) to control DB under owner %s', trimmed, type, signingKey.publicKeyB64);
3817
+ return desired;
3818
+ }
3819
+ /**
3820
+ * Found a strand — publish its row cadre-wide AND start the local instance as its
3821
+ * founder — in one resumable call. The single entry point for creating a strand; a
3822
+ * caller joining one someone else founded uses {@link addStrand} alone.
3823
+ *
3824
+ * Safe to re-run from any point of interruption, which hand-rolling
3825
+ * {@link publishStrand} + {@link addStrand} is not. Either half can already have
3826
+ * happened:
3827
+ *
3828
+ * - **row already published** → the stored row is adopted rather than re-published, so a
3829
+ * run killed after the insert committed no longer dies on
3830
+ * `UNIQUE constraint failed: Strand.Id`. For a closed strand the row's STORED
3831
+ * `MemberPrivateKey` wins over `config.memberPrivateKey`: a caller that mints a key per
3832
+ * attempt (as the reference apps do) would otherwise present a key that does not match
3833
+ * the membership already seated in the strand.
3834
+ * - **instance already running** → the tracked instance is returned, and — because the
3835
+ * `Strand` row records its publishing machine (`FounderOwnerKey`) — a founder request
3836
+ * against an instance that was first launched as a joiner now runs the (idempotent,
3837
+ * insert-if-absent) founder bootstrap on it rather than silently skipping it
3838
+ * ({@link StrandInstanceManager.foundExistingStrand}). So a strand that something else
3839
+ * attached first — the reference RN app's `strand:discovered` handler after a restart,
3840
+ * or this node's own {@link StrandWatcher} poll winning `launchStrand`'s
3841
+ * `resolveCohortSeed` window — still ends up with its `Strand.Header` written.
3842
+ *
3843
+ * Founder-ness is resolved FROM THE ROW, not assumed: this machine founds iff the
3844
+ * resolved row's `FounderOwnerKey` is its own owner key. A fresh publish records this
3845
+ * machine, so the common path founds; a machine that lost a concurrent founding race to
3846
+ * a sibling adopts the sibling's row and ATTACHES instead — deliberately, since two
3847
+ * machines bootstrapping the same strand on separate replicas is the double-`Header`
3848
+ * hazard. The returned {@link FoundStrandResult.founded} says which happened.
3849
+ *
3850
+ * `Type` is compared, not adopted: a stored row of the other type means the caller and
3851
+ * the control plane disagree about what this strand IS, so it throws.
3852
+ *
3853
+ * @returns The instance AND the row the strand actually runs under — read the membership
3854
+ * key from the returned row, not from a freshly minted one ({@link FoundStrandResult}).
3855
+ * @throws if the node is not started, the id is blank, a published row of the same id has
3856
+ * a different `Type`, or either half rejects.
3857
+ */
3858
+ async foundStrand(config) {
3859
+ const { strandId, type = 'o', memberPrivateKey, sAppConfig } = config;
3860
+ if (!this._running || !this.controlDatabase) {
3861
+ throw new Error(`CadreNode must be started before attempting to found strand ${strandId}`);
3862
+ }
3863
+ const trimmed = requireNonBlank(strandId, 'strand id');
3864
+ const published = await this.controlDatabase.queryStrand(trimmed);
3865
+ const strandRow = published
3866
+ ? this.adoptPublishedStrand(published, type, memberPrivateKey)
3867
+ : await this.publishStrand(trimmed, type, memberPrivateKey);
3868
+ // Derived, not hardcoded `true`: adopting a row another machine published means that
3869
+ // machine runs the bootstrap — this one must attach or it would write a second Header.
3870
+ const founded = this.isSelfFoundedRow(strandRow);
3871
+ if (!founded) {
3872
+ log('foundStrand(%s): the stored row was published by a different machine ' +
3873
+ '(FounderOwnerKey is not this node\'s owner key) — attaching as a joiner; ' +
3874
+ 'the founder bootstrap runs on the publishing machine', trimmed);
3875
+ }
3876
+ const instance = await this.addStrand({ strandRow, sAppConfig, founder: founded });
3877
+ return { instance, strandRow, founded };
3878
+ }
3879
+ /**
3880
+ * Resume onto an already-published `Strand` row: keep its stored content, reject a type
3881
+ * disagreement. The stored `MemberPrivateKey` is authoritative — see {@link foundStrand}
3882
+ * for why a caller-minted key must lose here (and why that is NOT the same rule as
3883
+ * {@link publishStrand}'s, which throws on a key mismatch because it is being asked to
3884
+ * WRITE that key, not to resume onto what already exists).
3885
+ */
3886
+ adoptPublishedStrand(published, type, memberPrivateKey) {
3887
+ if (published.Type !== type) {
3888
+ throw new Error(`Cannot found strand ${published.Id} as type '${type}': it is already published as ` +
3889
+ `type '${published.Type}'. Reconcile deliberately rather than re-founding — a ` +
3890
+ 'published open strand cannot be closed, nor a closed one opened, by re-publishing.');
3891
+ }
3892
+ if (memberPrivateKey !== undefined && memberPrivateKey !== published.MemberPrivateKey) {
3893
+ log('foundStrand(%s): already published — resuming on the STORED MemberPrivateKey; ' +
3894
+ 'the supplied key is discarded (read it back from the returned instance)', published.Id);
3895
+ }
3896
+ else {
3897
+ log('foundStrand(%s): already published (type %s) — adopting the stored row', published.Id, type);
3898
+ }
3899
+ return published;
3353
3900
  }
3354
3901
  /**
3355
3902
  * Remove this party's `Strand` row from the shared control database — the owner-signed
@@ -3554,19 +4101,49 @@ export class CadreNode {
3554
4101
  * seed, starts the strand, and registers it with the hibernation manager
3555
4102
  * before emitting `strand:started`.
3556
4103
  *
3557
- * Idempotent no-op when the strand manager already tracks `strand.Id` — the
3558
- * ordinary founding sequence is `addStrand` (starts it locally) followed by
3559
- * `publishStrand` (makes it visible), so this node's own `StrandWatcher`
3560
- * rediscovers a row it already started and calls this again via
3561
- * `handleStrandAdded`. Without the guard that re-entry would resolve a fresh
4104
+ * Founder-ness: an explicit `founder` argument wins (the formation/responder flows pass
4105
+ * one deliberately — their consent-seated rows carry a null `FounderOwnerKey`); when
4106
+ * unset it is DERIVED from the row, so the control-discovered path
4107
+ * (`handleStrandAdded`) and a restart's re-attach found this machine's own strands
4108
+ * without the caller having to know. The derivation is pure key comparison — no I/O.
4109
+ *
4110
+ * Idempotent when the strand manager already tracks `strand.Id` — the watcher
4111
+ * rediscovers a row this node already started and calls this again via
4112
+ * `handleStrandAdded`; without the guard that re-entry would resolve a fresh
3562
4113
  * (already-connected) cohort seed and re-emit `strand:started` for an
3563
4114
  * instance that never stopped. The guard runs before the cohort-seed RPC
3564
- * fan-out, so a rediscovery costs nothing beyond the map lookup.
4115
+ * fan-out, so a rediscovery costs nothing beyond the map lookup plus the (pure)
4116
+ * founder derivation. NOT a silent no-op any more when the launch resolves as a
4117
+ * FOUNDER: the tracked instance may have been launched first as a joiner (an app
4118
+ * attach, or this node's own watcher poll winning the `resolveCohortSeed` window
4119
+ * below), which used to drop the founder request and leave the strand headerless —
4120
+ * now the tracked instance is founded in place
4121
+ * ({@link StrandInstanceManager.foundExistingStrand}), waking it first if quiesced
4122
+ * so the bootstrap actually runs before this resolves.
3565
4123
  */
3566
4124
  async launchStrand(strand, sAppConfig, founder) {
4125
+ const resolvedFounder = founder ?? this.isSelfFoundedRow(strand);
3567
4126
  const existing = this.strandManager.getInstance(strand.Id);
3568
4127
  if (existing) {
3569
- log('launchStrand: strand %s already tracked locally — skipping re-launch', strand.Id);
4128
+ if (resolvedFounder) {
4129
+ const outcome = await this.strandManager.foundExistingStrand(strand.Id);
4130
+ if (outcome === 'needs-resume') {
4131
+ // Quiesced instance: the retained config now founds, but founding promises
4132
+ // the bootstrap has RUN by the time the caller resolves — wake through the
4133
+ // hibernation manager (coalesced with any in-flight wake, timer-aware) so
4134
+ // the rebuild executes it now rather than at some eventual wake.
4135
+ await this.wakeStrand(strand.Id);
4136
+ // The wake's rebuild founds — UNLESS a wake was already in flight when the
4137
+ // config flipped, in which case it had already read the pre-flip config and
4138
+ // rebuilt as a joiner, and `wakeStrand` merely coalesced onto it. Re-run the
4139
+ // (insert-if-absent) bootstrap so founding never resolves headerless.
4140
+ await this.strandManager.ensureFounderBootstrap(strand.Id);
4141
+ }
4142
+ log('launchStrand: strand %s already tracked — founder request honored (%s)', strand.Id, outcome);
4143
+ }
4144
+ else {
4145
+ log('launchStrand: strand %s already tracked locally — skipping re-launch', strand.Id);
4146
+ }
3570
4147
  return existing;
3571
4148
  }
3572
4149
  // Each strand node gets its own transport identity, derived from the cadre
@@ -3604,8 +4181,18 @@ export class CadreNode {
3604
4181
  bootstrapNodes,
3605
4182
  requireSignedSchemas: this.config.requireSignedSchemas,
3606
4183
  clusterSize: this.config.strandClusterSize,
4184
+ // NOTE: deliberately NO `servingMachines`, so the strand node declares no repair
4185
+ // yardstick and runs the frozen STRAND_CLUSTER_POLICY. The only count this node holds
4186
+ // is the party's enrolled machines (`authorizedControlPeers`), and passing that here
4187
+ // is the regression `bug-strand-yardstick-counts-party-machines` removed — a strand
4188
+ // runs on a subset of the party, and over-declaring makes repair impossible rather
4189
+ // than merely weak. The full argument, and the count that will legitimately go here,
4190
+ // are on `StartStrandConfig.servingMachines`.
3607
4191
  backfill: this.config.strandBackfill,
3608
- founder
4192
+ revocationEnforcement: this.config.strandRevocationEnforcement,
4193
+ onSelfRevoked: (revokedStrandId) => this.emit('strand:revoked', { strandId: revokedStrandId }),
4194
+ // The RESOLVED flag, never the raw argument — see the doc comment above.
4195
+ founder: resolvedFounder
3609
4196
  });
3610
4197
  // The seed reached the node as `bootstrapNodes`, which only enters the
3611
4198
  // address book via @libp2p/bootstrap discovery. Merge it directly too, so a
@@ -3645,6 +4232,20 @@ export class CadreNode {
3645
4232
  * for a relay we are not yet connected to.
3646
4233
  */
3647
4234
  async resolveCohortSeed(strandId, delegatePeerId) {
4235
+ const siblings = await this.resolveSiblingSeed(strandId, delegatePeerId);
4236
+ // Sibling answers FIRST: they were resolved just now, while a contact-map entry is as
4237
+ // old as the formation that produced it. `unionAddrs` appends only what the siblings
4238
+ // did not already name, so a fresher answer is never displaced and each source keeps
4239
+ // its own signaling-first ordering.
4240
+ return unionAddrs(siblings, this.crossPartyStrandAddrs.get(strandId) ?? []);
4241
+ }
4242
+ /**
4243
+ * The own-party half of {@link resolveCohortSeed}: strand-network addresses resolved
4244
+ * from CONNECTED cohort siblings over the control-mesh strand-addr RPC, doubling as
4245
+ * the delegate announcement when `delegatePeerId` is given. Empty when the control DB
4246
+ * or node is absent (not yet started / torn down).
4247
+ */
4248
+ async resolveSiblingSeed(strandId, delegatePeerId) {
3648
4249
  if (!this.controlDatabase || !this.controlNode) {
3649
4250
  return [];
3650
4251
  }
@@ -3851,35 +4452,41 @@ export class CadreNode {
3851
4452
  if (due.length === 0) {
3852
4453
  return;
3853
4454
  }
3854
- // A due strand with nobody to ask leaves its throttle unstamped (see below),
3855
- // so this pass re-enters on EVERY 15 s reconcile tick for as long as the node
3856
- // is alone — which is the steady state of a solo node running a strand.
3857
4455
  // `connectedSiblingTargets`' membership read is unbounded, and with zero
3858
- // connections its answer is empty whatever the table holds, so decide it here
3859
- // from the connection list instead of paying for the read once a tick.
3860
- if (this.controlNode.getConnections().length === 0) {
3861
- return;
3862
- }
4456
+ // connections its answer is empty whatever the table holds, so decide it from the
4457
+ // connection list instead of paying for the read once a tick. Note this only skips
4458
+ // the RPC: a strand carrying cross-party contact addresses still refreshes below,
4459
+ // because those never came from a sibling in the first place.
4460
+ //
3863
4461
  // NOTE: one strand-addr RPC per (due strand × connected sibling) per refresh
3864
4462
  // interval — each a tiny request/response on an already-open control
3865
4463
  // connection. If a node ever runs MANY strands at once, batch the RPC to
3866
4464
  // carry several strand ids per request rather than one fan-out per strand.
3867
- const targets = await this.connectedSiblingTargets().catch((error) => {
3868
- log('refreshStrandPeerAddrs: sibling enumeration failed (skipping pass): %o', error);
3869
- return [];
3870
- });
3871
- // No sibling to ask (or a shutdown landed mid-enumeration): leave every stamp
3872
- // untouched so the next reconcile tick retries, rather than sitting out the
3873
- // whole refresh interval having asked nobody.
3874
- if (targets.length === 0 || !this._running || !this.controlNode) {
4465
+ const targets = this.controlNode.getConnections().length === 0
4466
+ ? []
4467
+ : await this.connectedSiblingTargets().catch((error) => {
4468
+ log('refreshStrandPeerAddrs: sibling enumeration failed (skipping pass): %o', error);
4469
+ return [];
4470
+ });
4471
+ // A shutdown landed mid-enumeration.
4472
+ if (!this._running || !this.controlNode) {
3875
4473
  return;
3876
4474
  }
3877
4475
  await Promise.all(due.map((strandId) => this.refreshOneStrandPeerAddrs(strandId, targets, now)));
3878
4476
  }
3879
4477
  /**
3880
- * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, then
3881
- * merge their answers into this strand's address book. Errors are logged and
3882
- * swallowed so one strand's failure never costs the others their refresh.
4478
+ * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, union their
4479
+ * answers with this strand's cross-party contact addresses, and merge the lot into the
4480
+ * strand's address book. Errors are logged and swallowed so one strand's failure never
4481
+ * costs the others their refresh.
4482
+ *
4483
+ * `targets` may be EMPTY — a solo node, or one whose only control peers are strangers.
4484
+ * The cross-party addresses still have to be re-merged in that case: they were learned
4485
+ * at formation and can never be re-resolved, so this periodic re-merge is the ONLY
4486
+ * thing standing between them and the peerStore's one-hour expiry. A strand with
4487
+ * neither a sibling to ask nor a contact entry does nothing and leaves its throttle
4488
+ * unstamped, so the next reconcile tick retries rather than sitting out the whole
4489
+ * refresh interval having done nothing.
3883
4490
  */
3884
4491
  async refreshOneStrandPeerAddrs(strandId, targets, now) {
3885
4492
  const controlNode = this.controlNode;
@@ -3887,13 +4494,19 @@ export class CadreNode {
3887
4494
  if (!controlNode || !strandNode) {
3888
4495
  return;
3889
4496
  }
4497
+ const contactAddrs = this.crossPartyStrandAddrs.get(strandId) ?? [];
4498
+ if (targets.length === 0 && contactAddrs.length === 0) {
4499
+ return;
4500
+ }
3890
4501
  try {
3891
4502
  // The running strand node's own peerId is the delegate to announce — see
3892
4503
  // the relationship note on refreshStrandPeerAddrs.
3893
- const addrs = await collectStrandAddrs(controlNode, [...targets], strandId, {
3894
- delegatePeerId: strandNode.peerId.toString()
3895
- });
3896
- // Stamped on the RPC having happened, not on its answer: the fan-out is
4504
+ const siblingAddrs = targets.length === 0
4505
+ ? []
4506
+ : await collectStrandAddrs(controlNode, [...targets], strandId, {
4507
+ delegatePeerId: strandNode.peerId.toString()
4508
+ });
4509
+ // Stamped on the pass having happened, not on its answer: the fan-out is
3897
4510
  // what the throttle exists to bound, and an all-empty round is a normal
3898
4511
  // steady state for a strand no connected sibling currently runs.
3899
4512
  this.strandPeerAddrRefreshAt.set(strandId, now);
@@ -3902,7 +4515,7 @@ export class CadreNode {
3902
4515
  if (!this._running || this.strandManager.getInstance(strandId)?.libp2pNode !== strandNode) {
3903
4516
  return;
3904
4517
  }
3905
- await this.mergeStrandPeerAddrs(strandNode, addrs, strandId);
4518
+ await this.mergeStrandPeerAddrs(strandNode, unionAddrs(siblingAddrs, contactAddrs), strandId);
3906
4519
  }
3907
4520
  catch (error) {
3908
4521
  log('refreshStrandPeerAddrs: strand %s refresh failed (continuing): %o', strandId, error);
@@ -3926,6 +4539,15 @@ export class CadreNode {
3926
4539
  * handshake, and a bad entry costs one failed dial that ages out at the
3927
4540
  * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
3928
4541
  * `backlog/strand-network-nat-relay-reachability`.
4542
+ *
4543
+ * NOTE: one input does NOT age out — the cross-party addresses a formation
4544
+ * carried back ({@link crossPartyStrandAddrs}). Nothing can re-resolve them, so
4545
+ * {@link refreshStrandPeerAddrs} re-merges the same stored list every pass, which
4546
+ * also means a junk entry from a responder survives for the node's lifetime rather
4547
+ * than an hour. Bounded to {@link MAX_STRAND_ADDRS} per strand, from a party this
4548
+ * node deliberately formed a strand with, and still authority-free — so the
4549
+ * exposure is a handful of failed dials, not a trust hole. Evict on
4550
+ * `unpublishStrand` if that ever stops being an acceptable price.
3929
4551
  */
3930
4552
  async mergeStrandPeerAddrs(strandNode, addrs, strandId) {
3931
4553
  const counts = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
@@ -3967,7 +4589,29 @@ export class CadreNode {
3967
4589
  if (!node) {
3968
4590
  return [];
3969
4591
  }
3970
- return orderSignalingFirst(node.getMultiaddrs().map((ma) => ma.toString()));
4592
+ // Every entry is bound to THIS strand node's id, because both consumers — the
4593
+ // strand-addr RPC answer and the formation result's `strandAddrs` — are attributed
4594
+ // per peer on arrival (`groupAddrsByPeerId`) and an entry naming no destination is
4595
+ // dropped there. libp2p already appends the id to its announced addresses, so this
4596
+ // is normally a no-op; it is the guard for a bare listen addr and for a relay hop
4597
+ // whose trailing `/p2p/` names the RELAY. An addr terminating in a DIFFERENT id is
4598
+ // dropped rather than announced — it does not reach this node, and announcing it
4599
+ // would file our address under someone else's id in the receiver's book.
4600
+ //
4601
+ // Re-parsed through this package's own `multiaddr` rather than passed straight in:
4602
+ // libp2p hands back its nested `@multiformats/multiaddr` copy, a structurally
4603
+ // different type from the one `withTrailingPeerId` takes (see
4604
+ // `backlog/reference-app-web-libp2p-interface-dedup`). Re-parsing a string libp2p
4605
+ // itself produced cannot fail.
4606
+ const selfId = node.peerId.toString();
4607
+ const addrs = [];
4608
+ for (const ma of node.getMultiaddrs()) {
4609
+ const bound = this.bindAddrToPeer(multiaddr(ma.toString()), selfId);
4610
+ if (bound !== null) {
4611
+ addrs.push(bound.toString());
4612
+ }
4613
+ }
4614
+ return orderSignalingFirst(addrs);
3971
4615
  }
3972
4616
  /**
3973
4617
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
@@ -4670,6 +5314,25 @@ export class CadreNode {
4670
5314
  await this.membershipGateDrain;
4671
5315
  }
4672
5316
  }
5317
+ /**
5318
+ * Re-materialize a CLOSED strand's revoked-peer deny set now, and hang up any
5319
+ * connected peer it newly covers — the strand-side counterpart to
5320
+ * {@link refreshMembershipGate}.
5321
+ *
5322
+ * Unlike that one, NOTHING calls this automatically: strand membership is
5323
+ * written through the strand's own `Database` handle
5324
+ * (`strand-membership-writer.ts`), which raises no notification this runtime
5325
+ * can hook, and a revocation that arrives by REPLICATION raises none either.
5326
+ * The gate therefore polls (default 30 s). An app that has just called
5327
+ * `revokeMember` or `leaveStrand` should follow the write with this call so
5328
+ * the cut is immediate rather than up to one poll interval late.
5329
+ *
5330
+ * Quiet no-op for a strand that is not running, is quiesced, is open, or has
5331
+ * the gate disabled. Never rejects; resolves once the sweep has finished.
5332
+ */
5333
+ async refreshRevocationEnforcement(strandId) {
5334
+ await this.strandManager.refreshRevocationEnforcement(strandId);
5335
+ }
4673
5336
  /**
4674
5337
  * The single in-flight refresh loop behind {@link refreshMembershipGate}: read
4675
5338
  * until the stale flag stays clear, then release the slot.
@@ -4981,7 +5644,11 @@ export class CadreNode {
4981
5644
  this.strandSolicitationService = new StrandSolicitationService({
4982
5645
  ...options,
4983
5646
  partyId: this.config.controlNetwork.partyId,
4984
- cadrePeerAddrs: this.getMultiaddrs()
5647
+ cadrePeerAddrs: this.getMultiaddrs(),
5648
+ // Overrides any caller-supplied hook, exactly like partyId/cadrePeerAddrs: only
5649
+ // this node can say which strand-network addresses it is actually listening on,
5650
+ // and a wrong answer here seeds a joiner's mesh with addresses that reach nobody.
5651
+ resolveStrandAddrs: (strandId) => this.getStrandMultiaddrs(strandId)
4985
5652
  });
4986
5653
  // Register as responder on the control node
4987
5654
  this.strandSolicitationService.registerResponder(this.controlNode);
@@ -5026,7 +5693,41 @@ export class CadreNode {
5026
5693
  if (!this.strandSolicitationService) {
5027
5694
  this.initializeStrandSolicitation();
5028
5695
  }
5029
- return await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
5696
+ const result = await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
5697
+ this.recordCrossPartyStrandAddrs(result.strandId, result.strandAddrs);
5698
+ return result;
5699
+ }
5700
+ /**
5701
+ * Remember the responder's strand-network addresses for a strand this node just
5702
+ * formed, so the strand's discovery seed has something to work with when the app
5703
+ * launches it (see {@link crossPartyStrandAddrs}).
5704
+ *
5705
+ * Scoped strictly to `strandId`: these addresses reach ONE strand node of ONE other
5706
+ * party and must never seed another strand's mesh or the control peerStore. An empty
5707
+ * list is recorded as no entry at all rather than an empty one, so a later formation
5708
+ * against the same strand that DOES disclose addresses is not shadowed.
5709
+ */
5710
+ recordCrossPartyStrandAddrs(strandId, strandAddrs) {
5711
+ if (strandAddrs.length === 0) {
5712
+ log('formStrand: responder disclosed no strand addrs for %s — cross-party seed stays empty', strandId);
5713
+ return;
5714
+ }
5715
+ // Capped at the same `MAX_STRAND_ADDRS` one arriving list is, because this
5716
+ // ACCUMULATES: re-forming against the same strand (the documented recovery for a
5717
+ // responder whose relay reservation rotated) unions a fresh list onto the old one,
5718
+ // and nothing here can tell a dead entry from a live one. Without the cap, the seed
5719
+ // — and the peerStore rows every refresh pass re-merges from it — would grow by up
5720
+ // to 16 entries per redemption for the node's lifetime. Oldest entries fall off the
5721
+ // end, which is the right end to lose: `unionAddrs` keeps the freshest list first.
5722
+ const merged = unionAddrs(strandAddrs, this.crossPartyStrandAddrs.get(strandId) ?? [])
5723
+ .slice(0, MAX_STRAND_ADDRS);
5724
+ this.crossPartyStrandAddrs.set(strandId, merged);
5725
+ // A strand already running when this lands (a re-formation) would otherwise wait out
5726
+ // the rest of its ~10-minute refresh throttle before the new addresses reached its
5727
+ // address book. Clearing the stamp makes the next 15 s reconcile tick merge them,
5728
+ // which matters precisely because re-forming is the recovery path for a dead entry.
5729
+ this.strandPeerAddrRefreshAt.delete(strandId);
5730
+ log('formStrand: recorded %d cross-party strand addr(s) for %s', merged.length, strandId);
5030
5731
  }
5031
5732
  /**
5032
5733
  * Encode an open invitation for out-of-band delivery (QR, link, etc.).